More things
This commit is contained in:
@@ -24,7 +24,12 @@ function escapeHtml(text: string): string {
|
||||
}
|
||||
|
||||
export function useAppLocale() {
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
/* The global composer, not `useI18n()`: that one insists on a component
|
||||
instance, and this composable also runs from the auth plugin, which has
|
||||
none — a plugin runs in the Nuxt app's context, never in a `setup`. It is
|
||||
the same composer `useI18n()` hands to a component anyway, since no part of
|
||||
the wiki keeps messages of its own. */
|
||||
const { locale, setLocale, t } = useNuxtApp().$i18n
|
||||
|
||||
/* Translate a message that is drawn with v-html. Every interpolated value
|
||||
is escaped — tags, slugs and usernames wander into these — while the
|
||||
|
||||
@@ -44,17 +44,22 @@ export function useArticleLibrary() {
|
||||
}
|
||||
|
||||
function put(key: string, article: ReaderArticle) {
|
||||
/* the one address a version answers to, whatever asked for it: the short
|
||||
/wiki/<slug> form fetches by document and the versioned form by
|
||||
(identity, language), and both are the same page of the shelf */
|
||||
const version = versionKey(identityOf(article), articleLanguage(article))
|
||||
delete unreadable.value[key]
|
||||
delete unreadable.value[versionKey(identityOf(article), article.language ?? 'en')]
|
||||
delete unreadable.value[version]
|
||||
shelf.value[key] = article
|
||||
/* a version also answers to its document's own slug, where it was fetched
|
||||
from and where the panes keep it */
|
||||
from and where the panes keep it, and to the version it is */
|
||||
shelf.value[article.slug] = article
|
||||
shelf.value[version] = article
|
||||
}
|
||||
|
||||
function forget(slug: string) {
|
||||
const article = shelf.value[slug]
|
||||
if (article) delete shelf.value[versionKey(identityOf(article), article.language ?? 'en')]
|
||||
if (article) delete shelf.value[versionKey(identityOf(article), articleLanguage(article))]
|
||||
delete shelf.value[slug]
|
||||
delete unreadable.value[slug]
|
||||
}
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
export interface ArticleTab {
|
||||
/** the article this page is of — its canonical slug, and the page's key */
|
||||
identity: string
|
||||
/** the language this page is turned to */
|
||||
lang: string
|
||||
/** the version in view, as the library files it: its stored slug */
|
||||
slug: string
|
||||
/** and the document behind it — which a retitle keeps, so the article's page
|
||||
* follows it to its new address instead of a second page opening beside it */
|
||||
docId?: string
|
||||
title: string
|
||||
/** the language version this page holds */
|
||||
lang?: string
|
||||
/** the article this document is a version of — its canonical slug */
|
||||
identity?: string
|
||||
}
|
||||
|
||||
const MAX_TABS = 10
|
||||
|
||||
/** The address a page of the book was read from: the article's own URL for
|
||||
* the canonical version, /wiki/<lang>/<slug> for the others. */
|
||||
export function tabHref(tab: Pick<ArticleTab, 'slug' | 'lang' | 'identity'>): string {
|
||||
const identity = tab.identity ?? tab.slug
|
||||
return versionHref(identity, { language: tab.lang, slug: tab.slug })
|
||||
/** The address a page of the book was read from: the version it is turned to. */
|
||||
export function tabHref(tab: Pick<ArticleTab, 'identity' | 'lang'>): string {
|
||||
return versionHref(tab.identity, tab.lang)
|
||||
}
|
||||
|
||||
/** Which page of the stack an address points at, if any. */
|
||||
/** Which page of the stack an address points at, if any. An address that
|
||||
* names no language points at the article's page however it is turned. */
|
||||
export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null {
|
||||
const loc = parseReaderPath(path)
|
||||
if (!loc) return null
|
||||
const exact = tabs.find((t) => {
|
||||
const identity = t.identity ?? t.slug
|
||||
if (loc.lang) return identity === loc.identity && (t.lang ?? 'en') === loc.lang
|
||||
return identity === loc.identity && t.slug === loc.identity
|
||||
})
|
||||
/* an address naming a document directly (older /slug-fr form) still finds it */
|
||||
return exact ?? tabs.find((t) => t.slug === loc.identity) ?? null
|
||||
/* one article is one page of the book, in whichever language that page is
|
||||
turned to — so the address names its page by the article alone */
|
||||
return tabs.find((t) => t.identity === loc.identity) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The session stack of articles opened in the reader — the pages of the
|
||||
* book, in the order they were opened, newest at the right. The page
|
||||
* book, in the order they were opened, newest at the right. One article is one
|
||||
* page however many languages it exists in, so turning an article to another
|
||||
* language turns that page over instead of adding another to the row. The page
|
||||
* currently in view is simply wherever the route points; the stack lives in
|
||||
* shared state and survives every client navigation (reset on reload), so the
|
||||
* open pages are still there when you come back to the reading surface.
|
||||
@@ -43,26 +44,56 @@ export function useArticleTabs() {
|
||||
/* the last page seen outside the reading surface, so closing the last tab
|
||||
returns there instead of always going home */
|
||||
const origin = useState<string>('article-tabs:origin', () => '')
|
||||
/* the page last in view, so the stack can be found again from any page that
|
||||
hides the reading surface — the editor, a new article, the graph */
|
||||
const lastSlug = useState<string>('article-tabs:last', () => '')
|
||||
/* the article last in view, so the stack can be found again from any page
|
||||
that hides the reading surface — the editor, a new article, the graph */
|
||||
const lastIdentity = useState<string>('article-tabs:last', () => '')
|
||||
|
||||
function ensureTab(
|
||||
slug: string,
|
||||
title: string,
|
||||
meta?: { lang?: string; identity?: string }
|
||||
) {
|
||||
const existing = tabs.value.find((t) => t.slug === slug)
|
||||
if (existing) {
|
||||
existing.title = title // follow the tab label after a rename
|
||||
if (meta?.lang) existing.lang = meta.lang
|
||||
if (meta?.identity) existing.identity = meta.identity
|
||||
/**
|
||||
* Lay one version on the book: a fresh page at the right of the row, or —
|
||||
* when the article already has a page open — that same page turned to this
|
||||
* language. Reading an article in French is not opening a second article,
|
||||
* so the page keeps its place in the row and only its text changes.
|
||||
*/
|
||||
function openVersion(article: {
|
||||
_id?: string
|
||||
slug: string
|
||||
title: string
|
||||
language?: string
|
||||
baseSlug?: string | null
|
||||
}) {
|
||||
const identity = identityOf(article)
|
||||
const lang = articleLanguage(article)
|
||||
/* the article's own page, or — failing that — the page holding this very
|
||||
document, which is how a retitle is recognised: the article moved to a
|
||||
new address and its page follows it rather than doubling up */
|
||||
const page = tabs.value.find(
|
||||
(t) => t.identity === identity || (!!article._id && t.docId === article._id)
|
||||
)
|
||||
if (page) {
|
||||
/* the page follows the version it holds: the article's identity, the
|
||||
document its language asks for, and the title that version carries (a
|
||||
save that retitled an article moves it and relabels the page; a
|
||||
language turn relabels it again and takes it to another document) */
|
||||
page.identity = identity
|
||||
page.docId = article._id
|
||||
page.slug = article.slug
|
||||
page.lang = lang
|
||||
page.title = article.title
|
||||
return
|
||||
}
|
||||
const stack = [...tabs.value, { slug, title, lang: meta?.lang, identity: meta?.identity }]
|
||||
const stack = [
|
||||
...tabs.value,
|
||||
{
|
||||
identity,
|
||||
lang,
|
||||
slug: article.slug,
|
||||
docId: article._id,
|
||||
title: article.title,
|
||||
},
|
||||
]
|
||||
if (stack.length > MAX_TABS) {
|
||||
/* don't stack forever — retire the oldest tab that isn't in view */
|
||||
const drop = stack.findIndex((t) => t.slug !== slug)
|
||||
/* don't stack forever — retire the oldest page that isn't this one */
|
||||
const drop = stack.findIndex((t) => t.identity !== identity)
|
||||
if (drop !== -1) stack.splice(drop, 1)
|
||||
}
|
||||
tabs.value = stack
|
||||
@@ -72,17 +103,17 @@ export function useArticleTabs() {
|
||||
stack and the rest settle; closing the page in view turns back to the
|
||||
page before it — or returns to the last page outside the book once the
|
||||
last page is gone. */
|
||||
function closeTab(slug: string) {
|
||||
const at = tabs.value.findIndex((t) => t.slug === slug)
|
||||
const isActive = tabAt(tabs.value, route.path)?.slug === slug
|
||||
tabs.value = tabs.value.filter((t) => t.slug !== slug)
|
||||
function closeTab(identity: string) {
|
||||
const at = tabs.value.findIndex((t) => t.identity === identity)
|
||||
const isActive = tabAt(tabs.value, route.path)?.identity === identity
|
||||
tabs.value = tabs.value.filter((t) => t.identity !== identity)
|
||||
if (isActive) {
|
||||
const back = tabs.value[Math.max(0, Math.min(at - 1, tabs.value.length - 1))]
|
||||
void router.push(back ? tabHref(back) : origin.value || '/')
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs, origin, lastSlug, ensureTab, closeTab }
|
||||
return { tabs, origin, lastIdentity, openVersion, closeTab }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,14 +125,15 @@ export function useArticleTabs() {
|
||||
*/
|
||||
export function useArticleTabsSync() {
|
||||
const route = useRoute()
|
||||
const { tabs, lastSlug, origin } = useArticleTabs()
|
||||
const { tabs, lastIdentity, origin } = useArticleTabs()
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(to) => {
|
||||
/* filed under the tab's own slug, so a language version is found again
|
||||
as the version it was, not merely as the article */
|
||||
if (isReaderPath(to)) lastSlug.value = tabAt(tabs.value, to)?.slug ?? wikiSlugFromPath(to) ?? ''
|
||||
/* filed under the article's identity, so a version turned to another
|
||||
language is still found as the same page of the book */
|
||||
if (isReaderPath(to))
|
||||
lastIdentity.value = tabAt(tabs.value, to)?.identity ?? wikiSlugFromPath(to) ?? ''
|
||||
else if (!isReadingSurface(to)) origin.value = to
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
39
frontend/app/composables/usePendingRequests.ts
Normal file
39
frontend/app/composables/usePendingRequests.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/* How many edit requests wait for an admin. The sidebar wears this number on
|
||||
the door to the review desk, so the waiting pile shows without opening it.
|
||||
Shared state rather than a ref: the sidebar draws the count, and the desk —
|
||||
which already holds every request — lays the honest number on it each time
|
||||
it loads or a request is decided. */
|
||||
export function usePendingRequests() {
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const { isAdmin, authHeaders } = useAuth()
|
||||
|
||||
const pending = useState<number>('proposals:pending', () => 0)
|
||||
|
||||
/* Ask the API how many wait. Only an admin is answered, so nobody else
|
||||
carries a count — and a request that never got through (the backend is
|
||||
restarting) leaves the last number standing rather than emptying the door. */
|
||||
async function refresh() {
|
||||
if (!isAdmin.value) {
|
||||
pending.value = 0
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { pending: waiting } = await $fetch<{ pending: number }>(
|
||||
`${api}/admin/proposals/count`,
|
||||
{ headers: authHeaders() }
|
||||
)
|
||||
pending.value = waiting
|
||||
} catch {
|
||||
/* keep what the door already said */
|
||||
}
|
||||
}
|
||||
|
||||
/* The desk counts its own loaded pile — same fetch that brought the requests
|
||||
answers the door too, so approving or refusing keeps the number in step
|
||||
without asking the API twice. */
|
||||
function report(count: number) {
|
||||
pending.value = count
|
||||
}
|
||||
|
||||
return { pending, refresh, report }
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
export interface ViewedArticle {
|
||||
/** the article read, by its identity — the canonical version's slug. An
|
||||
* entry written before versions had addresses of their own may hold a
|
||||
* document's own slug, which the reader still resolves. */
|
||||
slug: string
|
||||
title: string
|
||||
/** the language it was last read in, so a jump returns to that version.
|
||||
* Absent from entries written when one address stood for the whole article */
|
||||
lang?: string
|
||||
viewedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'mathew-recently-viewed'
|
||||
const MAX_ENTRIES = 8
|
||||
|
||||
/** where a jump leads: the version the article was last read in, or — for an
|
||||
* entry that recorded no language — the short address, which the reader turns
|
||||
* onto whichever version the slug speaks */
|
||||
export function viewedHref(viewed: Pick<ViewedArticle, 'slug' | 'lang'>): string {
|
||||
return viewed.lang
|
||||
? versionHref(viewed.slug, viewed.lang)
|
||||
: `/wiki/${encodeURIComponent(viewed.slug)}`
|
||||
}
|
||||
|
||||
let loaded = false
|
||||
|
||||
function readStored(): ViewedArticle[] {
|
||||
@@ -36,14 +51,25 @@ export function useRecentlyViewed() {
|
||||
if (stored.length) viewed.value = stored
|
||||
})
|
||||
|
||||
function record(article: { slug: string; title: string }) {
|
||||
function record(article: {
|
||||
slug: string
|
||||
title: string
|
||||
baseSlug?: string | null
|
||||
language?: string
|
||||
}) {
|
||||
/* filed by the article's identity and turned to the language it was read
|
||||
in: an article read in two languages is still one article remembered
|
||||
once, at the version it was last opened at */
|
||||
const entry = {
|
||||
slug: identityOf(article),
|
||||
lang: articleLanguage(article),
|
||||
title: article.title,
|
||||
}
|
||||
/* merge with what's already saved so recording before the list has
|
||||
loaded (e.g. deep-linking straight to an article) can't clobber it */
|
||||
const base = viewed.value.length ? viewed.value : readStored()
|
||||
viewed.value = [
|
||||
{ ...article, viewedAt: Date.now() },
|
||||
...base.filter((v) => v.slug !== article.slug),
|
||||
].slice(0, MAX_ENTRIES)
|
||||
const fresh = { ...entry, viewedAt: Date.now() }
|
||||
viewed.value = [fresh, ...base.filter((v) => v.slug !== entry.slug)].slice(0, MAX_ENTRIES)
|
||||
loaded = true
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(viewed.value))
|
||||
|
||||
Reference in New Issue
Block a user