First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
/* The open state of the ⋮ menu on a row of the admin dashboard, shared like the
other popups. One row's menu at a time, so it holds the id of the account
whose menu is open — '' when none is — rather than a boolean per row. */
export function useAccountActionsMenu() {
const openId = useState<string>('account-actions-menu:open', () => '')
function show(id: string) {
openId.value = id
}
function hide() {
openId.value = ''
}
return { openId, show, hide }
}

View File

@@ -0,0 +1,60 @@
/* The interface language — the thing the settings popup offers, distinct from
the language an *article* is written in (see utils/languages.ts).
Where the choice lives: a `mathew-locale` cookie, which the server reads so
pages render in the right language from the first draw, and — for a signed-in
person — the account itself, so the choice follows them across devices. */
export const APP_LANGUAGES = [
{ code: 'en', language: 'en-US', name: 'English' },
{ code: 'es', language: 'es-ES', name: 'Español' },
{ code: 'ca', language: 'ca-ES', name: 'Català' },
{ code: 'fr', language: 'fr-FR', name: 'Français' },
{ code: 'de', language: 'de-DE', name: 'Deutsch' },
] as const
const SUPPORTED = new Set<string>(APP_LANGUAGES.map((l) => l.code))
function escapeHtml(text: string): string {
return text.replace(
/[&<>"']/g,
(c) =>
(({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }) as Record<string, string>)[c]!
)
}
export function useAppLocale() {
const { locale, setLocale, t } = useI18n()
/* Translate a message that is drawn with v-html. Every interpolated value
is escaped — tags, slugs and usernames wander into these — while the
messages themselves are ours and carry their own <code>/<span> markup. */
function th(key: string, params?: Record<string, unknown>): string {
if (!params) return t(key)
const escaped: Record<string, unknown> = {}
for (const [k, v] of Object.entries(params)) escaped[k] = typeof v === 'string' ? escapeHtml(v) : v
return t(key, escaped)
}
/* Turn the interface to another language. setLocale loads the messages and
keeps the detection cookie in step; where the person also has an account,
the caller saves it there too (see SettingsModal). */
async function choose(code: string) {
if (!SUPPORTED.has(code) || code === locale.value) return
await setLocale(code)
}
/* Follow an account's own choice — at startup or on signing in, where the
account outranks whatever this device was showing. */
async function applyAccount(code?: string) {
if (!code || !SUPPORTED.has(code) || code === locale.value) return
try {
await setLocale(code)
} catch {
/* the device keeps its own language; nothing else about the session
depends on this */
}
}
return { locale, th, choose, applyAccount }
}

View File

@@ -0,0 +1,15 @@
/* The open state of the article's ⋮ actions menu, shared like the other
popups: the book's Esc handler defers to whatever popup is on top, and the
menu steps aside when search or settings opens — popups don't stack. */
export function useArticleActionsMenu() {
const open = useState<boolean>('article-actions-menu:open', () => false)
function show() {
open.value = true
}
function hide() {
open.value = false
}
return { open, show, hide }
}

View File

@@ -0,0 +1,118 @@
import type { ArticleMeta } from '~/components/ArticleCard.vue'
export interface ReaderArticle extends ArticleMeta {
content?: string
}
/* Requests in flight on the client, so two panes asking for the same article
only trouble the API once. (Not touched during SSR, where only the page in
view is ever fetched.) */
const pending = new Map<string, Promise<ReaderArticle | null>>()
/** What a language version answers to from its address: `fr:bayes-theorem`. */
export function versionKey(identity: string, lang: string): string {
return `${lang}:${identity}`
}
/** The article a document belongs to: its own slug unless it translates one. */
export function identityOf(article: { slug: string; baseSlug?: string | null }): string {
return article.baseSlug || article.slug
}
/**
* The text of the pages the open book holds: one entry per article version,
* in shared state so the book, the route hosts and the middleware all see the
* same copy. A document answers both to its stored slug (what the panes and
* tabs are filed under) and, for language versions, to the `lang:identity`
* key its /wiki/<lang>/<slug> address asks for. The /wiki routes' middleware
* is what usually fills it, before anything renders — so the server's first
* render already has the page in view written out.
*/
export function useArticleLibrary() {
const api = `${useRuntimeConfig().public.apiBase}/api`
const shelf = useState<Record<string, ReaderArticle>>('article-book:shelf', () => ({}))
/* request key -> the status of the request that failed to bring it in */
const unreadable = useState<Record<string, number>>('article-book:unreadable', () => ({}))
function get(slug: string): ReaderArticle | null {
return shelf.value[slug] ?? null
}
/** the version an address like /wiki/fr/bayes-theorem asks for, if held */
function getVersion(identity: string, lang: string): ReaderArticle | null {
return shelf.value[versionKey(identity, lang)] ?? null
}
function put(key: string, article: ReaderArticle) {
delete unreadable.value[key]
delete unreadable.value[versionKey(identityOf(article), article.language ?? 'en')]
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 */
shelf.value[article.slug] = article
}
function forget(slug: string) {
const article = shelf.value[slug]
if (article) delete shelf.value[versionKey(identityOf(article), article.language ?? 'en')]
delete shelf.value[slug]
delete unreadable.value[slug]
}
async function fetchOntoShelf(key: string, url: string): Promise<ReaderArticle | null> {
const running = pending.get(key)
if (running) return running
const request = (async () => {
try {
const article = await $fetch<ReaderArticle>(url)
put(key, article)
return article
} catch (err) {
/* remember why, and let the page say so in its own words */
const status = (err as { statusCode?: number; status?: number }) ?? {}
unreadable.value[key] = status.statusCode ?? status.status ?? 0
return null
} finally {
pending.delete(key)
}
})()
pending.set(key, request)
return request
}
/** Bring a document onto the shelf, once. */
async function load(slug: string): Promise<ReaderArticle | null> {
const known = shelf.value[slug]
if (known) return known
return fetchOntoShelf(slug, `${api}/articles/slug/${encodeURIComponent(slug)}`)
}
/** Bring one language version onto the shelf, once. */
async function loadVersion(identity: string, lang: string): Promise<ReaderArticle | null> {
const key = versionKey(identity, lang)
const known = shelf.value[key]
if (known) return known
return fetchOntoShelf(
key,
`${api}/articles/slug/${encodeURIComponent(identity)}?lang=${encodeURIComponent(lang)}`
)
}
/**
* Read a page again without waiting for it: a page already open is served
* from the shelf at once, but an edit made elsewhere should still find its
* way onto the paper.
*/
function revalidate(slug: string, lang?: string) {
const key = lang ? versionKey(slug, lang) : slug
if (import.meta.server || pending.has(key)) return
const url = lang
? `${api}/articles/slug/${encodeURIComponent(slug)}?lang=${encodeURIComponent(lang)}`
: `${api}/articles/slug/${encodeURIComponent(slug)}`
void fetchOntoShelf(key, url)
}
return { get, getVersion, put, forget, load, loadVersion, revalidate, unreadable }
}

View File

@@ -0,0 +1,109 @@
export interface ArticleTab {
slug: 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 })
}
/** Which page of the stack an address points at, if any. */
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
}
/**
* 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
* 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.
*/
export function useArticleTabs() {
const route = useRoute()
const router = useRouter()
const tabs = useState<ArticleTab[]>('article-tabs', () => [])
/* 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', () => '')
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
return
}
const stack = [...tabs.value, { slug, title, lang: meta?.lang, identity: meta?.identity }]
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)
if (drop !== -1) stack.splice(drop, 1)
}
tabs.value = stack
}
/* Close a page. Closing one that isn't in view just takes it out of the
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)
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 }
}
/**
* Keeps the book's stack of pages in step with the route: which page was last
* in view, and the last plain page outside the reading surface. The stack
* itself is left alone — it survives the whole session, so wandering off to
* the graph or the article list and coming back finds the pages as they were.
* Call once, high in the app.
*/
export function useArticleTabsSync() {
const route = useRoute()
const { tabs, lastSlug, 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) ?? ''
else if (!isReadingSurface(to)) origin.value = to
},
{ immediate: true }
)
}

View File

@@ -0,0 +1,158 @@
/* Who is using the wiki right now.
A session is a bearer token the API handed us, kept in a cookie rather than
localStorage so the server can see it too: the sidebar then knows who is
signed in on the very first draw, before any script runs. The token itself is
never decoded here — the API is asked who it belongs to (see restoreSession). */
export interface AuthUser {
id: string
username: string
/** Profile picture as a data URL; "" when the person has not chosen one. */
avatar: string
/** An admin opens the account dashboard; a member reads and writes. */
role: AuthRole
/** The interface language saved on the account; "" follows the browser. */
locale: string
}
export type AuthRole = 'admin' | 'member'
interface Session {
token: string
user: AuthUser
}
const TOKEN_COOKIE = 'mathew-session'
const WEEK_IN_SECONDS = 60 * 60 * 24 * 7
export function useAuth() {
const api = `${useRuntimeConfig().public.apiBase}/api`
const token = useCookie<string | null>(TOKEN_COOKIE, {
default: () => null,
maxAge: WEEK_IN_SECONDS,
sameSite: 'lax',
})
const user = useState<AuthUser | null>('auth:user', () => null)
const loggedIn = computed(() => user.value !== null)
/* Whether this session may open the admin dashboard. The API checks the
account again on every admin request — this only decides what to draw. */
const isAdmin = computed(() => user.value?.role === 'admin')
/* Header for the write endpoints. The read endpoints are public and ignore it. */
function authHeaders(): Record<string, string> {
return token.value ? { Authorization: `Bearer ${token.value}` } : {}
}
function openSession(session: Session) {
token.value = session.token
user.value = session.user
}
/* Signing out is local: the token simply stops being sent, and it lapses on
its own — the API issues nothing that needs revoking. */
function signOut() {
token.value = null
user.value = null
}
async function signIn(username: string, password: string) {
const session = await $fetch<Session>(`${api}/auth/login`, {
method: 'POST',
body: { username: username.trim().toLowerCase(), password },
})
openSession(session)
return session.user
}
/* Opening an account signs you straight into it: the API answers with the
same token pair as a login would. */
async function signUp(username: string, password: string) {
const session = await $fetch<Session>(`${api}/auth/register`, {
method: 'POST',
body: { username: username.trim().toLowerCase(), password },
})
openSession(session)
return session.user
}
/* Runs once at startup, on the server and in the browser: asks the API who a
stored token belongs to. A token the API refuses — expired, or the account
is gone — is dropped here rather than at the first save. A request that
never got through (the backend is restarting) leaves it in place. */
async function restoreSession() {
if (!token.value || user.value) return
try {
const { user: known } = await $fetch<{ user: AuthUser }>(`${api}/auth/me`, {
headers: authHeaders(),
})
user.value = known
} catch (err: any) {
if (err?.statusCode === 401 || err?.response?.status === 401) token.value = null
}
}
/* The account updates from the profile popup. Each comes back with the
account as the API now has it, so the sidebar and the popup are showing
the truth the moment the request resolves. */
async function updateUsername(username: string) {
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/me`, {
method: 'PUT',
headers: authHeaders(),
body: { username: username.trim().toLowerCase() },
})
user.value = updated
return updated
}
async function changePassword(currentPassword: string, newPassword: string) {
await $fetch(`${api}/auth/password`, {
method: 'PUT',
headers: authHeaders(),
body: { currentPassword, newPassword },
})
}
/* The picture arrives already shrunk to a small data URL (see ProfileModal);
"" clears it and leaves the initial in its place. */
async function setAvatar(dataUrl: string) {
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/avatar`, {
method: 'PUT',
headers: authHeaders(),
body: { avatar: dataUrl },
})
user.value = updated
return updated
}
/* The interface language saved on the account, so the choice follows the
person across devices. Switching this device's language is the caller's
business (useAppLocale) — this only stores it. */
async function updateLocale(locale: string) {
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/locale`, {
method: 'PUT',
headers: authHeaders(),
body: { locale },
})
user.value = updated
return updated
}
return {
user,
loggedIn,
isAdmin,
authHeaders,
signIn,
signUp,
signOut,
restoreSession,
updateUsername,
updateLocale,
changePassword,
setAvatar,
}
}

View File

@@ -0,0 +1,15 @@
/* The open state of the reader's language menu, shared like the other popups:
the book's Esc handler defers to whatever popup is on top, and the menu
steps aside when search or settings opens — popups don't stack in this app. */
export function useLanguageMenu() {
const open = useState<boolean>('language-menu:open', () => false)
function show() {
open.value = true
}
function hide() {
open.value = false
}
return { open, show, hide }
}

View File

@@ -0,0 +1,20 @@
/* Shared open state for the profile popup (ProfileModal.vue), shaped like
useSettings and useSignInPopup. The account row in the sidebar flips this
flag; the popup itself owns the account form. */
export function useProfileMenu() {
const open = useState<boolean>('profile:open', () => false)
function show() {
open.value = true
}
function hide() {
open.value = false
}
function toggle() {
open.value = !open.value
}
return { open, show, hide, toggle }
}

View File

@@ -0,0 +1,56 @@
export interface ViewedArticle {
slug: string
title: string
viewedAt: number
}
const STORAGE_KEY = 'mathew-recently-viewed'
const MAX_ENTRIES = 8
let loaded = false
function readStored(): ViewedArticle[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as ViewedArticle[]) : []
} catch {
/* ignore corrupt storage */
return []
}
}
/**
* Tracks which articles the visitor has opened, in `localStorage`, so the
* sidebar can offer a "recently viewed" list. Shared across pages via
* `useState`, so it updates live as you browse without extra fetches.
*/
export function useRecentlyViewed() {
const viewed = useState<ViewedArticle[]>('recently-viewed', () => [])
/* read the saved history once, after mount, so the server render and the
client hydration both start from an empty list */
onMounted(() => {
if (loaded) return
loaded = true
const stored = readStored()
if (stored.length) viewed.value = stored
})
function record(article: { slug: string; title: string }) {
/* 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)
loaded = true
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(viewed.value))
} catch {
/* private mode etc. — history just won't persist */
}
}
return { viewed, record }
}

View File

@@ -0,0 +1,20 @@
/* Shared open state for the article search popup (SearchModal.vue).
The sidebar search bar and the keyboard shortcuts flip this flag;
the modal itself owns the live searching. */
export function useSearch() {
const open = useState<boolean>('search:open', () => false)
function show() {
open.value = true
}
function hide() {
open.value = false
}
function toggle() {
open.value = !open.value
}
return { open, show, hide, toggle }
}

View File

@@ -0,0 +1,19 @@
/* Shared open state for the settings popup (SettingsModal.vue).
The sidebar cog flips this flag; the modal itself owns the settings. */
export function useSettings() {
const open = useState<boolean>('settings:open', () => false)
function show() {
open.value = true
}
function hide() {
open.value = false
}
function toggle() {
open.value = !open.value
}
return { open, show, hide, toggle }
}

View File

@@ -0,0 +1,27 @@
/* Shared collapse state for the site sidebar. The choice is remembered in
localStorage, and the flag is also set on <html> so the fixed reading
surface (--sidebar-w) and the rail itself follow it together. Mobile's
top bar is always expanded — the collapsed styles only apply above the
900px breakpoint. */
export function useSidebar() {
const collapsed = useState<boolean>('sidebar:collapsed', () => false)
if (import.meta.client) {
watch(collapsed, (is) => {
document.documentElement.classList.toggle('sidebar-collapsed', is)
})
}
function restoreSidebar() {
if (import.meta.client)
collapsed.value = localStorage.getItem('mathew:sidebar-collapsed') === '1'
}
function toggleSidebar() {
collapsed.value = !collapsed.value
if (import.meta.client)
localStorage.setItem('mathew:sidebar-collapsed', collapsed.value ? '1' : '0')
}
return { collapsed: readonly(collapsed), toggleSidebar, restoreSidebar }
}

View File

@@ -0,0 +1,26 @@
/* Shared open state for the sign-in popup (AuthModal.vue), shaped like
useSearch and useSettings. The account row in the sidebar, the gate on the
editor pages and the editor itself all reach the popup through this, so only
one is ever on screen. */
export type SignInMode = 'signin' | 'signup'
export function useSignInPopup() {
const open = useState<boolean>('signin:open', () => false)
const mode = useState<SignInMode>('signin:mode', () => 'signin')
/* Why the popup was opened — shown above the form when a session has just
expired mid-edit, so the person knows what happened to their save. */
const notice = useState<string>('signin:notice', () => '')
function show(opts: { mode?: SignInMode; notice?: string } = {}) {
mode.value = opts.mode ?? 'signin'
notice.value = opts.notice ?? ''
open.value = true
}
function hide() {
open.value = false
}
return { open, mode, notice, show, hide }
}