More things

This commit is contained in:
2026-10-01 02:29:21 +02:00
parent e1df4d3ae1
commit 13b659662e
54 changed files with 4409 additions and 911 deletions

View File

@@ -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

View File

@@ -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]
}

View File

@@ -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 }

View 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 }
}

View File

@@ -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))