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,123 @@
<script setup lang="ts">
/* One account's actions on the dashboard, folded into a quiet ⋮: give the admin
role or take it back, and delete the account. The menu behaves like the
article's (see ArticleActionsMenu) — closed by a click outside, by esc, by
navigating, and by search, settings or the profile popup opening over it —
and only one row's menu is open at a time, which is what the shared state
keys off. */
import type { AuthRole } from '~/composables/useAuth'
const props = defineProps<{
/** the account this row belongs to */
accountId: string
/** whose account it is — the menu says so where the ⋮ waits for a hover */
username: string
/** what that account may do right now */
role: AuthRole
/** a request about this account is in flight: the menu waits with it */
busy: boolean
}>()
const emit = defineEmits<{
'set-role': [role: AuthRole]
remove: []
}>()
const route = useRoute()
const { t } = useI18n()
const { openId, show, hide } = useAccountActionsMenu()
const { open: searchOpen } = useSearch()
const { open: settingsOpen } = useSettings()
const { open: profileOpen } = useProfileMenu()
const open = computed(() => openId.value === props.accountId)
function toggle() {
if (open.value) hide()
else show(props.accountId)
}
const wrapEl = ref<HTMLElement | null>(null)
function onDocClick(e: MouseEvent) {
if (open.value && !wrapEl.value?.contains(e.target as Node)) hide()
}
/* Captured so this runs before the page's own Esc handler sees the key: while a
menu is open, esc folds it away rather than what the page would do with it. */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !open.value) return
hide()
e.stopPropagation()
}
onMounted(() => {
document.addEventListener('click', onDocClick)
window.addEventListener('keydown', onKeydown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
window.removeEventListener('keydown', onKeydown, true)
})
watch(() => route.fullPath, hide)
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
if (search || settings || profile) hide()
})
</script>
<template>
<div ref="wrapEl" class="kebab">
<button
type="button"
class="kebab-toggle"
:class="{ open }"
:aria-expanded="String(open)"
aria-haspopup="menu"
:title="t('accountActions.forUser', { username })"
:aria-label="t('accountActions.aria')"
@click="toggle"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="5" r="0.6" />
<circle cx="12" cy="12" r="0.6" />
<circle cx="12" cy="19" r="0.6" />
</svg>
</button>
<div v-if="open" class="kebab-dropdown" role="menu" :aria-label="t('accountActions.aria')">
<button
type="button"
class="kebab-item"
role="menuitem"
:disabled="busy"
@click="hide(); emit('set-role', role === 'admin' ? 'member' : 'admin')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
<path v-if="role === 'admin'" d="m9 12 2 2 4-4" />
<path v-else d="M9 12h6" />
</svg>
{{ role === 'admin' ? t('accountActions.removeAdmin') : t('accountActions.makeAdmin') }}
</button>
<div class="kebab-sep" />
<button
type="button"
class="kebab-item kebab-danger"
role="menuitem"
:disabled="busy"
@click="hide(); emit('remove')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M10 11v6M14 11v6" />
</svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
const { show: openSearch } = useSearch()
const { show: openSettings } = useSettings()
const { tabs, lastSlug } = useArticleTabs()
const { user, isAdmin, signOut } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { show: openProfile } = useProfileMenu()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
/* The open articles live in the reading surface: the book ArticleBookStack
lays their pages side by side, older ones parked with only their spines
showing. The stack stays open all session, but the view of it is only drawn
in the reader — so this button is the way back: press it wherever you are
(the graph, the list, the editor) and every article you have open comes back
into view, stacked as you left it. */
const inReader = computed(() => wikiSlugFromPath(route.path) !== null)
function showOpenArticles() {
if (inReader.value) return // the pages are already spread out in view
/* turn back to the page that was last in view — every tab knows its own
address, language versions included */
const target =
tabs.value.find((t) => t.slug === lastSlug.value) ?? tabs.value[tabs.value.length - 1]
if (target) void router.push(tabHref(target))
}
/* the shortcut hint beside the search bar — "/" keeps focus on the home
page's inline box there, so the popup's universal key is Ctrl/⌘+K */
const kbdHint = ref('/')
onMounted(() => {
kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K'
restoreSidebar()
})
/* the account row wears an initial in a circle, so it still reads as a person
once the collapsed rail has dropped the name beside it */
const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperCase())
</script>
<template>
<aside class="site-sidebar" :class="{ collapsed }">
<NuxtLink to="/" class="logo" :aria-label="t('sidebar.home')">
<span class="logo-mark">∑</span>
<span class="hide-collapsed">math<span class="logo-accent">ew</span></span>
</NuxtLink>
<nav class="sidebar-nav">
<button
type="button"
class="sidebar-search"
:aria-label="t('common.searchArticles')"
@click="openSearch()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" />
</svg>
<span class="sidebar-search-label hide-collapsed">{{ t('sidebar.search') }}</span>
<kbd class="kbd-hint hide-collapsed">{{ kbdHint }}</kbd>
</button>
<button
v-if="tabs.length"
type="button"
class="nav-link sidebar-open"
:class="{ 'is-current': inReader }"
:aria-label="t('sidebar.openAria', tabs.length)"
:title="t('sidebar.openTitle', tabs.length)"
@click="showOpenArticles()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2 2 7l10 5 10-5-10-5z" />
<path d="m2 12 10 5 10-5" />
<path d="m2 17 10 5 10-5" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.open') }}</span>
<span class="tabs-count hide-collapsed">{{ tabs.length }}</span>
</button>
<NuxtLink to="/articles" class="nav-link" :title="t('sidebar.articles')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.articles') }}</span>
</NuxtLink>
<NuxtLink to="/graph" class="nav-link" :title="t('sidebar.graph')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.graph') }}</span>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin"
class="nav-link"
:title="t('sidebar.adminHint')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.admin') }}</span>
</NuxtLink>
<NuxtLink to="/new" class="btn btn-primary btn-sm" :title="t('sidebar.newArticle')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.newArticle') }}</span>
</NuxtLink>
</nav>
<div class="sidebar-footer">
<!-- who is using the wiki. Reading needs no account, so a visitor is given
the door in; a signed-in person sees who writes as themselves and can
open their profile — the picture, the account, the way out. -->
<div v-if="user" class="account-row">
<button
type="button"
class="account-open"
:title="t('sidebar.signedInAs', { name: user.username })"
@click="openProfile()"
>
<span class="account-avatar" aria-hidden="true">
<img v-if="user.avatar" :src="user.avatar" alt="" />
<template v-else>{{ initials }}</template>
</span>
<span class="hide-collapsed account-name">{{ user.username }}</span>
</button>
<button
type="button"
class="account-exit hide-collapsed"
:aria-label="t('common.signOut')"
:title="t('common.signOut')"
@click="signOut()"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
</button>
</div>
<button
v-else
type="button"
class="icon-btn"
:title="t('sidebar.signInHint')"
@click="openSignIn()"
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8.5" cy="15.5" r="4" />
<path d="m11.6 12.4 8.4-8.4 2 2-2 2 1.5 1.5-2 2-1.5-1.5-1.9 1.9" />
</svg>
<span class="hide-collapsed">{{ t('auth.signin') }}</span>
</button>
<button
type="button"
class="icon-btn"
:aria-label="t('settings.title')"
:title="t('settings.title')"
@click="openSettings()"
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
<span class="hide-collapsed">{{ t('settings.title') }}</span>
</button>
<button
type="button"
class="panel-icon-btn sidebar-collapse-btn"
:aria-label="collapsed ? t('sidebar.expand') : t('sidebar.collapse')"
:title="collapsed ? t('sidebar.expand') : t('sidebar.collapse')"
@click="toggleSidebar()"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.collapseLabel') }}</span>
</button>
</div>
</aside>
</template>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
/* The article's actions folded into one ⋮ button: Edit, the language menu,
and Delete — so the title row keeps its space to itself. The menu behaves
like the app's other popups: closed by a click outside, by esc (unless the
language submenu is open, since that claims the key first), by navigating,
and by search or settings opening over it. The language menu rides inside
as a menu item of its own — its list drops over the rest of this menu. */
import type { ArticleVersion } from './ArticleCard.vue'
defineProps<{
/** the article the versions belong to — its canonical slug */
identity: string
versions: ArticleVersion[]
/** the version in view and the language it is written in */
currentSlug: string
language: string
/** the slug the editor answers to */
editorSlug: string
}>()
const emit = defineEmits<{
remove: []
}>()
const route = useRoute()
const { t } = useI18n()
const { open, hide } = useArticleActionsMenu()
const { open: langMenuOpen } = useLanguageMenu()
const { open: searchOpen } = useSearch()
const { open: settingsOpen } = useSettings()
const { open: profileOpen } = useProfileMenu()
function toggle() {
open.value = !open.value
}
const wrapEl = ref<HTMLElement | null>(null)
function onDocClick(e: MouseEvent) {
if (open.value && !wrapEl.value?.contains(e.target as Node)) hide()
}
/* Captured so this runs before the book's own Esc handler sees the key: while
the menu is open, esc folds it away instead of closing the page. The
language submenu sits on top of this menu, so it gets the key first. */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !open.value) return
if (langMenuOpen.value) return
hide()
e.stopPropagation()
}
onMounted(() => {
document.addEventListener('click', onDocClick)
window.addEventListener('keydown', onKeydown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
window.removeEventListener('keydown', onKeydown, true)
})
watch(() => route.fullPath, hide)
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
if (search || settings || profile) hide()
})
</script>
<template>
<div ref="wrapEl" class="kebab">
<button
type="button"
class="kebab-toggle"
:class="{ open }"
:aria-expanded="String(open)"
:title="t('articleActions.aria')"
:aria-label="t('articleActions.aria')"
@click="toggle"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="5" r="0.6" />
<circle cx="12" cy="12" r="0.6" />
<circle cx="12" cy="19" r="0.6" />
</svg>
</button>
<div v-if="open" class="kebab-dropdown" role="menu" :aria-label="t('articleActions.aria')">
<NuxtLink
:to="{ path: `/wiki/${editorSlug}/edit`, query: { lang: language } }"
class="kebab-item"
role="menuitem"
@click="hide"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
{{ t('common.edit') }}
</NuxtLink>
<div class="kebab-sep" />
<ArticleLanguageMenu
:identity="identity"
:versions="versions"
:current-slug="currentSlug"
:language="language"
:editor-slug="editorSlug"
/>
<div class="kebab-sep" />
<button
type="button"
class="kebab-item kebab-danger"
role="menuitem"
@click="hide(); emit('remove')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M10 11v6M14 11v6" />
</svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
/* The reading surface: an open book of the articles from this session. Each
one is a page laid out beside the last, and you move along them sideways.
A page turned to slides over the older ones, which park along the left
edge with only their spines showing; click anywhere on a page — spine or
body — to turn back to it, or × to close it. Routes stay the source of
truth — every page is a plain /wiki/<slug> URL — and useArticleTabs keeps
the stack of pages.
Pages further along the book than the one in view wait off the right edge
of the desk, where you would have to scroll sideways to reach them. Their
spines are copied into a stack docked at the right edge of the desk instead
— turning one takes you to the page as if you had scrolled to it — and a
spine leaves the stack as its page is brought into view.
Narrow screens fold the book to the one page in view, with its neighbours
as chips along the top. */
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { tabs, closeTab } = useArticleTabs()
const { load, forget } = useArticleLibrary()
const { open: searchOpen } = useSearch()
const { open: settingsOpen } = useSettings()
const { open: profileOpen } = useProfileMenu()
const { open: langMenuOpen } = useLanguageMenu()
const { open: actionsMenuOpen } = useArticleActionsMenu()
const { collapsed } = useSidebar()
const activeTab = computed(() => tabAt(tabs.value, route.path))
const activeSlug = computed(() => activeTab.value?.slug ?? null)
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
const trackEl = ref<HTMLElement | null>(null)
/** Whether the book is open flat on the desk (wide) or folded (narrow). */
function liesFlat() {
return import.meta.client && !window.matchMedia('(max-width: 900px)').matches
}
/** Which page of the book is the one in view, or -1 when the route names
* none of them. Only the pages beyond it can be waiting for you. */
const openIndex = computed(() => {
const slug = activeSlug.value
return slug === null ? -1 : tabs.value.findIndex((t) => t.slug === slug)
})
/**
* Bring the page in view back to its place in the stack. Pages are laid out
* a page-width apart and each one parks `--i` spines from the left edge, so
* scrolling to i * (page width − spine width) sets the whole row: older
* pages fanned into spines on the left, the rest waiting off the right edge.
*/
async function alignToActive(behavior: ScrollBehavior = 'smooth') {
await nextTick()
const track = trackEl.value
const slug = activeSlug.value
if (!track || !slug || !liesFlat()) return
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`)
const i = Number(page?.dataset.index)
if (!page || !Number.isFinite(i)) return
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
track.scrollTo({ left: i * (page.offsetWidth - spine), behavior })
measureDock()
}
/* ---------- the dock: pages waiting beyond the right edge of the desk -----
A page further along the book than the one in view has its spine off the
right hand side of the screen, and only a sideways scroll reaches it. While
it is there its spine is kept at the right edge of the desk, so the page is
a click away rather than a drag away. Which pages are out there is measured
off the row, not guessed at: slide the book and a spine leaves the stack as
its page comes into view, and joins it again as the page slides out. */
/** The slugs of the pages whose spines are in the dock. */
const waitingSlugs = ref<string[]>([])
/** Those pages, in the order they were opened. */
const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug)))
/**
* Fill the dock with the pages beyond the one in view whose spines have no
* room left on the desk. A page keeps its place while it straddles the edge,
* so a page sliding past doesn't flicker in and out of the stack.
*/
function measureDock() {
const track = trackEl.value
const open = openIndex.value
if (!track || open === -1 || !liesFlat()) {
waitingSlugs.value = []
return
}
const edge = track.getBoundingClientRect().right
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
const held = new Set(waitingSlugs.value)
for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) {
const i = Number(page.dataset.index)
const slug = page.dataset.slug
if (!slug || !Number.isFinite(i) || i <= open) continue
const left = page.getBoundingClientRect().left
if (left > edge - 6) held.add(slug)
else if (left < edge - spine - 10) held.delete(slug)
}
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug)
}
/* the row moves on scroll, so the dock is measured once a frame while it does */
let measuring = 0
function watchDock() {
if (measuring) return
measuring = requestAnimationFrame(() => {
measuring = 0
measureDock()
})
}
onBeforeUnmount(() => cancelAnimationFrame(measuring))
/* turning to an article slides the book round to it */
watch(activeSlug, () => void alignToActive('smooth'))
/* and the row changing shape — however it changed — changes which pages are
waiting out beyond the edge of the desk */
watch(tabs, () => void nextTick(measureDock))
/* a page opened or closed changes how wide every page is — settle the row
again so the one in view doesn't drift */
watch(
() => tabs.value.length,
() => void alignToActive('auto')
)
/* pages are sized against the window, so keep them honest as it moves */
function onResize() {
void alignToActive('auto')
measureDock()
}
onMounted(() => {
void alignToActive('auto')
void nextTick(measureDock)
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
/* collapsing the sidebar moves the reading surface's left edge, which
resizes every page — settle the row again once the rail has finished
sliding */
watch(collapsed, () => {
setTimeout(() => void alignToActive('auto'), 260)
})
/* every page of the book should hold its article: pull the ones the route
host hasn't already written in (background pages, client-side only) */
watch(
tabs,
() => {
for (const t of tabs.value) void load(t.slug)
},
{ immediate: true }
)
function turnTo(slug: string) {
const tab = tabs.value.find((t) => t.slug === slug)
if (tab && slug !== activeSlug.value) void router.push(tabHref(tab))
}
async function removeArticle(slug: string) {
const title = tabs.value.find((t) => t.slug === slug)?.title ?? slug
if (!window.confirm(t('book.deleteConfirm', { title }))) return
try {
await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' })
forget(slug)
closeTab(slug)
} catch {
window.alert(t('book.deleteFailed'))
}
}
/* Esc closes the page in view — unless a popup is open, since those
claim the key first */
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') return
if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value)
return
const current = tabAt(tabs.value, route.path)?.slug
if (current) closeTab(current)
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
</script>
<template>
<div v-if="visible" class="book-layer" :style="{ '--panes': tabs.length }">
<nav
v-if="tabs.length > 1"
class="book-chips"
role="tablist"
:aria-label="t('book.openAria')"
>
<div
v-for="tab in tabs"
:key="tab.slug"
class="book-chip"
:class="{ active: tab.slug === activeSlug }"
role="tab"
tabindex="0"
:aria-selected="tab.slug === activeSlug"
:title="t('book.chipHint', { title: tab.title })"
@click="turnTo(tab.slug)"
@keydown.enter.prevent="turnTo(tab.slug)"
>
<span class="chip-title">{{ tab.title }}</span>
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</nav>
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
<ArticlePane
v-for="(tab, i) in tabs"
:key="tab.slug"
:slug="tab.slug"
:index="i"
:active="tab.slug === activeSlug"
@activate="turnTo(tab.slug)"
@close="closeTab(tab.slug)"
@remove="removeArticle(tab.slug)"
/>
</div>
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
<div
v-for="tab in waiting"
:key="tab.slug"
class="dock-spine"
role="button"
tabindex="0"
:title="t('book.dockHint', { title: tab.title })"
@click="turnTo(tab.slug)"
@keydown.enter.prevent="turnTo(tab.slug)"
>
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<span class="spine-title">{{ tab.title }}</span>
</div>
</nav>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
/* One language version of an article, as the version switcher lists them. */
export interface ArticleVersion {
language: string
slug: string
title: string
updatedAt?: string
}
export interface ArticleMeta {
_id: string
slug: string
title: string
tags?: string[]
language?: string
/* set on translations: the canonical (original) version's slug */
baseSlug?: string | null
/* every language version the article exists in, from the single-article GET */
versions?: ArticleVersion[]
createdAt?: string
updatedAt?: string
}
const props = defineProps<{ article: ArticleMeta }>()
const { t, locale } = useI18n()
/* shown only when an article steps off the English default — a quiet badge
so the shelf's other languages are visible at a glance; when the article
exists in several languages, its codes ride together instead */
const language = computed(() => articleLanguage(props.article))
const languageLabel = computed(() => languageName(language.value))
const langs = computed(() => props.article.versions ?? [])
const multiLang = computed(() => langs.value.length > 1)
const langCodes = computed(() => langs.value.map((v) => v.language.toUpperCase()).join(' · '))
const langNames = computed(() => langs.value.map((v) => languageName(v.language)).join(', '))
/* where the card leads: the language address of the version it shows */
const href = computed(() =>
versionHref(identityOf(props.article), { language: language.value, slug: props.article.slug })
)
const updated = computed(() => {
const d = props.article.updatedAt ? new Date(props.article.updatedAt) : null
if (!d || Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(locale.value, { year: 'numeric', month: 'short', day: 'numeric' })
})
</script>
<template>
<NuxtLink :to="href" class="article-card">
<div class="card-top">
<span class="card-kicker">{{ article.baseSlug ? t('card.kickerTranslation') : t('card.kickerArticle') }}</span>
<span v-if="multiLang" class="card-langs" :title="t('card.existsIn', { names: langNames })">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{{ langCodes }}
</span>
<span
v-else-if="language !== 'en'"
class="lang-badge"
:title="t('common.writtenIn', { lang: languageLabel })"
>{{ languageLabel }}</span
>
</div>
<h3 class="card-title" :lang="language">{{ article.title }}</h3>
<div v-if="article.tags?.length" class="tags">
<span v-for="tag in article.tags.slice(0, 3)" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="card-meta">
<span>{{ href }}</span>
<span v-if="updated">{{ t('card.updated', { date: updated }) }}</span>
</div>
</NuxtLink>
</template>

View File

@@ -0,0 +1,786 @@
<script setup lang="ts">
import type { ArticleMeta } from './ArticleCard.vue'
const props = defineProps<{
mode: 'create' | 'edit'
article?: ArticleMeta & { content?: string }
/* Translating: the version being translated. The form opens with its text
and publishes a new language version of the same article. */
translateFrom?: ArticleMeta & { content?: string }
/* Languages the article being translated already exists in */
takenLanguages?: string[]
}>()
const api = `${useRuntimeConfig().public.apiBase}/api`
const router = useRouter()
const route = useRoute()
/* Writing is the one thing on this page that takes an account: the save carries
the session token, and a save the API refuses is the editor's own signal that
the session has gone while this draft was open. */
const { authHeaders } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t } = useI18n()
const original = props.translateFrom ?? null
/* the version group the new document joins, shared by every translation */
const baseSlug = original ? original.baseSlug || original.slug : ''
/* an edit is always an article — every language version of it answers to the
same identity, which is what its reader and editor addresses speak */
const isVersioned = computed(() => props.mode === 'edit' && !!props.article)
const identity = props.article ? props.article.baseSlug || props.article.slug : ''
/* versions under edit -------------------------------------------------
Editing an article happens one language at a time: each version keeps its
own draft beside the others, so typing French does not overwrite English —
switching turns the page, it does not clear it. */
interface VersionDraft {
language: string
/** saved document's slug; null for a version not written yet */
slug: string | null
title: string
tagsText: string
content: string
/** the text as it was loaded or last saved — what "dirty" compares against */
saved: { title: string; tagsText: string; content: string }
/** whether the version's full text has arrived from the API */
loaded: boolean
}
const drafts = ref<Record<string, VersionDraft>>({})
function seedDraft(language: string, slug: string | null, doc?: ArticleMeta & { content?: string }): VersionDraft {
return {
language,
slug,
title: doc?.title ?? '',
tagsText: (doc?.tags ?? []).join(', '),
content: doc?.content ?? '',
saved: {
title: doc?.title ?? '',
tagsText: (doc?.tags ?? []).join(', '),
content: doc?.content ?? '',
},
loaded: !!doc,
}
}
if (isVersioned.value && props.article) {
const doc = props.article
const list = doc.versions?.length
? doc.versions
: [{ language: doc.language ?? 'en', slug: doc.slug, title: doc.title }]
for (const v of list) {
/* the document already in hand needs no fetch; the rest arrive on demand
with only their titles known up front — enough to fill the field while
the text comes over */
const draft = seedDraft(v.language, v.slug, v.slug === doc.slug ? doc : undefined)
if (!draft.loaded && v.title) draft.title = v.title
drafts.value[v.language] = draft
}
}
/* the language this editor is showing — its address is ?lang= on the route */
const initialLang = String(route.query.lang ?? '').trim().toLowerCase()
const activeLang = ref(
drafts.value[initialLang]?.language ?? props.article?.language ?? 'en'
)
/* one draft is in view and edited through these; the rest wait in `drafts` */
const title = ref(activeDraft()?.title ?? original?.title ?? '')
const tagsText = ref(activeDraft()?.tagsText ?? (original?.tags ?? []).join(', '))
const content = ref(activeDraft()?.content ?? original?.content ?? '')
/* the language a fresh (non-versioned) article is written in — the reader's
language menu asks for one on the route (/new?translate=x&lang=de): start
there, unless the article is already written in it */
const requestedLang = String(route.query.lang ?? '').trim().toLowerCase()
const preselected =
LANGUAGES.some((l) => l.code === requestedLang) &&
!(props.takenLanguages ?? []).includes(requestedLang)
? requestedLang
: ''
const language = ref(
props.article?.language?.trim().toLowerCase() ||
preselected ||
nextFreeLanguage(props.takenLanguages ?? [])
)
function activeDraft(): VersionDraft | undefined {
return drafts.value[activeLang.value]
}
const versionList = computed(() => Object.values(drafts.value))
const freeLanguages = computed(() => LANGUAGES.filter((l) => !drafts.value[l.code]))
function draftCurrent(d: VersionDraft) {
return d.language === activeLang.value
? { title: title.value, tagsText: tagsText.value, content: content.value }
: { title: d.title, tagsText: d.tagsText, content: d.content }
}
function draftChanged(d: VersionDraft) {
if (!d.loaded) return false
return JSON.stringify(draftCurrent(d)) !== JSON.stringify(d.saved)
}
const dirtyCount = computed(
() => (isVersioned.value ? versionList.value.filter(draftChanged).length : singleDirty.value ? 1 : 0)
)
/* fetch a version's text the first time the editor turns to it */
async function hydrate(d: VersionDraft) {
if (!d.slug) return
/* remember what the fields held on arrival: if anything was typed while the
fetch was on its way, the typing wins over what comes back */
const untouched = { title: title.value, tagsText: tagsText.value, content: content.value }
try {
const doc = await $fetch<ArticleMeta & { content?: string }>(
`${api}/articles/slug/${encodeURIComponent(d.slug)}`
)
d.title = doc.title ?? ''
d.tagsText = (doc.tags ?? []).join(', ')
d.content = doc.content ?? ''
d.saved = { title: d.title, tagsText: d.tagsText, content: d.content }
d.loaded = true
/* if the editor still rests on this version, and nothing was typed into
it since, show what arrived */
if (
d.language === activeLang.value &&
JSON.stringify(untouched) ===
JSON.stringify({ title: title.value, tagsText: tagsText.value, content: content.value })
) {
title.value = d.title
tagsText.value = d.tagsText
content.value = d.content
}
} catch {
/* an unreadable version stays a blank page; saving will say why it failed */
}
}
function putDraftFields(d: VersionDraft | undefined) {
if (!d) return
title.value = d.title
tagsText.value = d.tagsText
content.value = d.content
}
function stashActive() {
const d = activeDraft()
if (!d || !d.loaded) return
d.title = title.value
d.tagsText = tagsText.value
d.content = content.value
}
async function switchLang(lang: string) {
if (lang === activeLang.value) return
stashActive()
activeLang.value = lang
const d = drafts.value[lang]
putDraftFields(d)
if (d && !d.loaded) await hydrate(d)
void router.replace({ query: { ...route.query, lang } })
}
/* the language dropdown beside the chips -------------------------------- */
const addOpen = ref(false)
const addWrapEl = ref<HTMLElement | null>(null)
function onDocClick(e: MouseEvent) {
if (addOpen.value && !addWrapEl.value?.contains(e.target as Node)) addOpen.value = false
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
function addLanguage(code: string) {
addOpen.value = false
stashActive()
/* a fresh version starts empty under the hood and carries the article's
tags across — they describe the topic, not the language */
drafts.value[code] = seedDraft(code, null, {
_id: '',
slug: '',
title: '',
tags: tagsText.value
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean),
content: '',
} as ArticleMeta)
activeLang.value = code
title.value = ''
tagsText.value = drafts.value[code]!.tagsText
content.value = ''
void router.replace({ query: { ...route.query, lang: code } })
}
/* the version in view is fetched on demand, even on first arrival (?lang=fr) */
onMounted(() => {
const d = activeDraft()
if (isVersioned.value && d && !d.loaded) void hydrate(d)
})
const languages = computed(() =>
LANGUAGES.map((l) => ({
...l,
taken: !!original && (props.takenLanguages ?? []).includes(l.code),
}))
)
function slugifyText(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/* the address being edited or published: versions live at /wiki/<lang>/<slug>,
the canonical article at /wiki/<slug> — where a canonical retitle moves it */
const slugPreview = computed(() => {
if (isVersioned.value) {
const d = activeDraft()
if (d && d.slug && d.slug !== identity) return `/wiki/${d.language}/${identity}`
return `/wiki/${slugifyText(title.value) || savedSlug}`
}
if (original) return `/wiki/${language.value}/${baseSlug}`
return `/wiki/${slugifyText(title.value) || '…'}`
})
const savedSlug = props.article?.slug ?? ''
/* dirty tracking -------------------------------------------------
A versioned edit is dirty when any version's text differs from what it was
loaded or saved as; the plain create form compares against what it opened
with. */
interface FormState {
title: string
tagsText: string
content: string
}
function currentState(): FormState {
return { title: title.value, tagsText: tagsText.value, content: content.value }
}
const savedState = ref<FormState>(currentState())
const singleDirty = computed(
() => !isVersioned.value && JSON.stringify(currentState()) !== JSON.stringify(savedState.value)
)
const dirty = computed(() =>
isVersioned.value ? versionList.value.some(draftChanged) : singleDirty.value
)
function markSaved() {
savedState.value = currentState()
for (const d of versionList.value) if (draftChanged(d)) d.saved = draftCurrent(d)
}
onBeforeRouteLeave(async () => {
if (!dirty.value) return true
return window.confirm(t('editor.leaveGuard'))
})
/* preview ---------------------------------------------------------- */
/* The textarea's seed stays partly in code: the LaTeX snippet cannot live
verbatim in a message catalog (`{` would read as a parameter), so only the
words are translated and the maths rides along here. */
const SEED_MATH = '$$ \\zeta(s) = \\sum_{n=1}^{\\infty} \\frac{1}{n^s} $$'
const contentPlaceholder = computed(
() => `# ${t('editor.seedHeading')}\n\n${t('editor.seedTerms')}\n\n${SEED_MATH}`
)
type Mode = 'write' | 'split' | 'preview'
const mode = ref<Mode>('split')
const previewContent = computed(() => content.value)
/* fullscreen -------------------------------------------------------- */
const fullscreen = ref(false)
const editorBodyEl = ref<HTMLElement | null>(null)
let fullscreenAnim: Animation | null = null
/* FLIP the toggle: measure where the editor currently sits, flip the
class (static box -> fixed full-screen box), then animate the old
rect into the new one so the editor grows into the screen edges —
or shrinks back to its place — instead of snapping. */
async function toggleFullscreen() {
const el = editorBodyEl.value
if (!el) return
const first = el.getBoundingClientRect() // current visual rect, even mid-animation
fullscreenAnim?.cancel()
fullscreen.value = !fullscreen.value
await nextTick()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const last = el.getBoundingClientRect()
const entering = fullscreen.value
fullscreenAnim = el.animate(
[
{
transformOrigin: 'top left',
transform: `translate(${first.left - last.left}px, ${first.top - last.top}px) scale(${first.width / last.width}, ${first.height / last.height})`,
borderRadius: entering ? '15px' : '0px',
},
{ transformOrigin: 'top left', transform: 'none', borderRadius: entering ? '0px' : '15px' },
],
{ duration: 300, easing: 'cubic-bezier(0.32, 0.72, 0, 1)' }
)
fullscreenAnim.onfinish = () => {
fullscreenAnim = null
}
}
/* cheatsheet --------------------------------------------------------- */
/* Popups don't stack in this app: SearchModal and SettingsModal hide
each other on open. Search, settings and the profile can also be
opened from anywhere (header, shortcuts) while the editor shows, so
the cheatsheet steps aside whenever one of them opens. */
const { open: searchOpen } = useSearch()
const { open: settingsOpen } = useSettings()
const { open: profileOpen } = useProfileMenu()
const cheatsheet = ref(false)
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
if (search || settings || profile) cheatsheet.value = false
})
// keep the page behind from scrolling while the editor fills the screen
watch(fullscreen, (on) => {
document.body.style.overflow = on ? 'hidden' : ''
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
/* submit ----------------------------------------------------------- */
const saving = ref(false)
const errorMsg = ref('')
function parseTags(text: string): string[] {
return text
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
}
async function request(body: Record<string, unknown>, method: 'POST' | 'PUT', url: string) {
return $fetch<ArticleMeta>(url, { method, body, headers: authHeaders() })
}
async function save() {
errorMsg.value = ''
if (saving.value) return
saving.value = true
try {
if (isVersioned.value) {
/* every language version edited since the last save goes up together;
brand-new ones join the article through its identity */
const pendingDrafts = versionList.value.filter(draftChanged)
if (!pendingDrafts.length) {
await router.push(slugPreview.value)
return
}
const missing = pendingDrafts.find((d) => !draftCurrent(d).title.trim())
if (missing) {
errorMsg.value = t('editor.versionNeedsTitle', { name: languageName(missing.language) })
void switchLang(missing.language)
return
}
let savedActive: ArticleMeta | null = null
let lastSaved: ArticleMeta | null = null
for (const d of pendingDrafts) {
const cur = draftCurrent(d)
const body = {
title: cur.title.trim(),
content: cur.content,
tags: parseTags(cur.tagsText),
/* a version written here joins the article it belongs to */
...(d.slug ? {} : { baseSlug: identity, language: d.language }),
}
const saved = d.slug
? await request(body, 'PUT', `${api}/articles/slug/${encodeURIComponent(d.slug)}`)
: await request(body, 'POST', `${api}/articles`)
d.slug = saved.slug
d.saved = cur
lastSaved = saved
if (d.language === activeLang.value) savedActive = saved
}
/* leave on the page that was in view — the answer itself carries where
it lives now, so a canonical retitle follows it instead of stranding */
const target = savedActive ?? lastSaved
await router.push(target ? versionHref(identityOf(target), target) : '/')
return
}
if (!title.value.trim()) {
errorMsg.value = t('editor.titleRequired')
return
}
const body = {
title: title.value.trim(),
content: content.value,
language: language.value,
/* a translation joins the article it translates rather than standing alone */
...(original ? { baseSlug } : {}),
tags: parseTags(tagsText.value),
}
const url =
props.mode === 'create'
? `${api}/articles`
: `${api}/articles/slug/${encodeURIComponent(savedSlug)}`
const saved = await request(body, props.mode === 'create' ? 'POST' : 'PUT', url)
// prevent the leave-guard from firing on our own navigation
markSaved()
await router.push(versionHref(identityOf(saved), saved))
} catch (err: any) {
/* A session the API won't take any more. Signing out here would unmount this
very form — and the draft in it — so instead the sign-in popup opens over
the editor: whatever is written stays put, and signing back in replaces the
dead token so the next save goes through. */
if (err?.status === 401 || err?.statusCode === 401) {
errorMsg.value = ''
openSignIn({
notice: err?.data?.message || t('editor.sessionExpired'),
})
return
}
errorMsg.value = err?.data?.message || t('editor.saveFailed')
} finally {
saving.value = false
}
}
/* discard ---------------------------------------------------------- */
/* The destructive step lives behind the same kind of popup as settings and
the cheatsheet, not a browser alert. */
const discardOpen = ref(false)
const discardButtonEl = ref<HTMLButtonElement | null>(null)
const discardMessage = computed(() =>
props.mode === 'create'
? t('editor.discardNew')
: dirtyCount.value > 1
? t('editor.discardVersions', { n: dirtyCount.value })
: t('editor.discardOne', {
title: activeDraft()?.saved.title || savedState.value.title || t('editor.thisArticle'),
})
)
function askDiscard() {
cheatsheet.value = false // popups don't stack in this app
discardOpen.value = true
}
function closeDiscardPrompt() {
discardOpen.value = false
discardButtonEl.value?.focus() // the popup it replaces was removed from the page
}
/* Throw away everything typed since the last save and leave the editor — in
every version, and versions added since the last save come off entirely.
The baseline is what the form opened with, restoring which keeps our own
leave-guard quiet; the way out leads back to the article or the search page. */
async function discard() {
discardOpen.value = false
if (isVersioned.value) {
for (const [lang, d] of Object.entries(drafts.value)) {
if (!d.slug) delete drafts.value[lang] // a version never saved: gone whole
else putDraftFieldsBack(d) // the rest return to their saved text
}
const back = activeDraft() ?? props.article
if (activeDraft()) putDraftFields(activeDraft()) // the fields follow the version in view
errorMsg.value = ''
const target = back
? versionHref(identity, { language: back.language ?? 'en', slug: back.slug })
: '/'
await router.push(target)
return
}
const base = savedState.value
title.value = base.title
tagsText.value = base.tagsText
content.value = base.content
errorMsg.value = ''
await router.push(
props.mode === 'create' ? (original ? versionHref(baseSlug, original) : '/') : `/wiki/${savedSlug}`
)
}
function putDraftFieldsBack(d: VersionDraft) {
// reset a draft's working copy to its saved text (the fields follow if active)
d.title = d.saved.title
d.tagsText = d.saved.tagsText
d.content = d.saved.content
}
/* Tab inserts spaces in the textarea ------------------------------- */
function onTabKeydown(e: KeyboardEvent) {
const el = e.target as HTMLTextAreaElement
if (e.key !== 'Tab') return
e.preventDefault()
const { selectionStart: s, selectionEnd: end } = el
content.value = content.value.slice(0, s) + ' ' + content.value.slice(end)
nextTick(() => {
el.selectionStart = el.selectionEnd = s + 2
})
}
// Ctrl/Cmd+S saves; Esc closes the topmost popup first, then leaves fullscreen
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (addOpen.value) {
addOpen.value = false
return
}
if (discardOpen.value) {
closeDiscardPrompt()
return
}
if (cheatsheet.value) {
cheatsheet.value = false
return
}
if (fullscreen.value) {
void toggleFullscreen()
return
}
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
if (!saving.value) save()
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
</script>
<template>
<form class="editor-page" @submit.prevent="save">
<div class="editor-head">
<h1>{{ mode === 'create' ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
<div class="editor-head-actions">
<button
ref="discardButtonEl"
type="button"
class="btn btn-ghost"
:disabled="!dirty"
:title="t('editor.discardHint')"
@click="askDiscard"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{{ t('editor.discard') }}
</button>
<NuxtLink
v-if="mode === 'edit' && savedSlug"
:to="versionHref(identity, { language: article?.language, slug: savedSlug })"
class="btn btn-ghost"
>
{{ t('common.cancel') }}
</NuxtLink>
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving
? t('editor.saving')
: original
? t('editor.publishTranslation')
: mode === 'create'
? t('editor.publishArticle')
: dirtyCount > 1
? t('editor.saveVersions', { n: dirtyCount })
: t('editor.saveChanges') }}
</button>
</div>
</div>
<div v-if="original" class="translate-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span>
{{ t('editor.translating') }}
<NuxtLink :to="versionHref(baseSlug, original)">{{ original.title }}</NuxtLink>
{{ t('editor.translatingTail') }}
</span>
</div>
<!-- the article's language versions: turn the editor to another language,
or add one — unsaved text waits on its own version's page -->
<div v-if="isVersioned" class="version-bar">
<span class="version-bar-label">{{ t('editor.languages') }}</span>
<button
v-for="d in versionList"
:key="d.language"
type="button"
class="lang-chip"
:class="{ active: d.language === activeLang }"
:lang="d.language"
:aria-pressed="d.language === activeLang"
:title="t('editor.editVersion', { name: languageName(d.language) })"
@click="switchLang(d.language)"
>
{{ languageName(d.language) }}
<span v-if="draftChanged(d)" class="dirty-dot" :title="t('editor.unsavedChanges')" />
</button>
<div v-if="freeLanguages.length" ref="addWrapEl" class="version-add">
<button
type="button"
class="lang-chip version-add-btn"
:aria-expanded="String(addOpen)"
:title="t('editor.addLanguageHint')"
@click="addOpen = !addOpen"
>
{{ t('editor.addLanguage') }}
</button>
<div v-if="addOpen" class="version-dropdown" role="listbox">
<button
v-for="l in freeLanguages"
:key="l.code"
type="button"
class="topic-option"
role="option"
:lang="l.code"
@click="addLanguage(l.code)"
>
<span>{{ l.name }}</span>
<span class="topic-count">{{ l.code.toUpperCase() }}</span>
</button>
</div>
</div>
</div>
<div class="field">
<label for="article-title">{{ t('editor.titleLabel') }}</label>
<input
id="article-title"
v-model="title"
type="text"
class="title-input"
:placeholder="t('editor.titlePlaceholder')"
autofocus
/>
<div v-if="title.trim()" class="slug-preview">
{{ t('editor.urlLabel') }} <code>{{ slugPreview }}</code>
</div>
</div>
<div class="field-row">
<div class="field">
<label for="article-tags">
{{ t('editor.tagsLabel') }} <span class="hint">{{ t('editor.tagsHint') }}</span>
</label>
<input id="article-tags" v-model="tagsText" type="text" :placeholder="t('editor.tagsPlaceholder')" />
</div>
<div v-if="!isVersioned" class="field">
<label for="article-language">
{{ t('editor.languageLabel') }} <span class="hint">{{ t('editor.languageHint') }}</span>
</label>
<select id="article-language" v-model="language">
<option
v-for="l in languages"
:key="l.code"
:value="l.code"
:lang="l.code"
:disabled="l.taken"
:title="l.taken ? t('editor.languageTaken') : undefined"
>
{{ l.name }}{{ l.taken ? ` ${t('editor.alreadyWritten')}` : '' }}
</option>
</select>
</div>
</div>
<div class="field">
<label for="article-content" :lang="isVersioned ? activeLang : undefined">
{{ t('editor.contentLabel') }} <span class="hint">{{ t('editor.contentHint') }}</span>
</label>
<div ref="editorBodyEl" class="editor-body" :class="{ 'is-fullscreen': fullscreen }">
<div class="editor-toolbar">
<button type="button" class="mode-btn" :class="{ active: mode === 'write' }" @click="mode = 'write'">{{ t('editor.modeWrite') }}</button>
<button type="button" class="mode-btn" :class="{ active: mode === 'split' }" @click="mode = 'split'">{{ t('editor.modeSplit') }}</button>
<button type="button" class="mode-btn" :class="{ active: mode === 'preview' }" @click="mode = 'preview'">{{ t('editor.modePreview') }}</button>
<div class="toolbar-actions">
<button
type="button"
class="mode-btn toolbar-icon-btn"
:class="{ active: cheatsheet }"
:title="t('editor.cheatsheetHint')"
:aria-label="t('editor.cheatsheet')"
:aria-pressed="cheatsheet"
@click="cheatsheet = !cheatsheet"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
</button>
<button
type="button"
class="mode-btn toolbar-icon-btn"
:title="fullscreen ? t('editor.exitFullscreenHint') : t('editor.fullscreen')"
:aria-label="fullscreen ? t('editor.exitFullscreen') : t('editor.enterFullscreen')"
:aria-pressed="fullscreen"
@click="toggleFullscreen"
>
<svg v-if="!fullscreen" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
</button>
</div>
</div>
<div class="editor-grid" :class="`mode-${mode}`">
<div class="write-pane">
<textarea
id="article-content"
v-model="content"
class="md-input"
:lang="isVersioned ? activeLang : undefined"
:placeholder="contentPlaceholder"
spellcheck="false"
@keydown="onTabKeydown"
/>
</div>
<div class="preview-pane">
<div class="preview-label">{{ t('editor.livePreview') }}</div>
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" />
</div>
</div>
</div>
</div>
<p v-if="errorMsg" class="form-error">{{ errorMsg }}</p>
<EditorCheatsheet :open="cheatsheet" @close="cheatsheet = false" />
<ConfirmModal
:open="discardOpen"
:title="t('editor.discardConfirm')"
:message="discardMessage"
:confirm-label="t('editor.discard')"
:cancel-label="t('editor.keepEditing')"
@confirm="discard"
@cancel="closeDiscardPrompt"
/>
</form>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
/* The reader's language menu: one control for turning the article to another
language — and for writing one it doesn't exist in yet. Every language the
wiki keeps is listed: the ones this article speaks navigate to that
version, the rest lead straight into the translate editor with the language
already chosen. */
import type { ArticleVersion } from './ArticleCard.vue'
const props = defineProps<{
/** the article the versions belong to — its canonical slug */
identity: string
versions: ArticleVersion[]
/** the version in view and the language it is written in */
currentSlug: string
language: string
/** the slug the translate flow answers to: the canonical document's, or
this version's own when the canonical one is gone */
editorSlug: string
}>()
const route = useRoute()
const { t } = useI18n()
const { open, hide } = useLanguageMenu()
const { open: searchOpen } = useSearch()
const { open: settingsOpen } = useSettings()
const { open: profileOpen } = useProfileMenu()
const { loggedIn } = useAuth()
/* the languages this article exists in, and the ones still left to write —
writing takes an account, so a visitor who isn't signed in only ever sees
the versions there are to read */
const existing = computed(() => props.versions)
const missing = computed(() =>
loggedIn.value
? LANGUAGES.filter((l) => !props.versions.some((v) => (v.language || 'en') === l.code))
: []
)
function toggle() {
open.value = !open.value
}
/* the menu is one of the app's popups: closed by a click outside, by esc, by
navigating, and by search or settings opening over it */
const wrapEl = ref<HTMLElement | null>(null)
function onDocClick(e: MouseEvent) {
if (open.value && !wrapEl.value?.contains(e.target as Node)) hide()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) hide()
}
onMounted(() => {
document.addEventListener('click', onDocClick)
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
window.removeEventListener('keydown', onKeydown)
})
watch(() => route.fullPath, hide)
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
if (search || settings || profile) hide()
})
</script>
<template>
<div ref="wrapEl" class="lang-menu">
<button
type="button"
class="btn btn-ghost btn-sm"
:aria-expanded="String(open)"
:title="loggedIn ? t('langMenu.hintSignedIn') : t('langMenu.hint')"
@click="toggle"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span :lang="language">{{ languageName(language) }}</span>
<svg class="lang-menu-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div v-if="open" class="lang-menu-dropdown" role="menu" :aria-label="t('langMenu.aria')">
<template v-if="existing.length">
<div class="lang-menu-section">{{ t('langMenu.readIn') }}</div>
<NuxtLink
v-for="v in existing"
:key="v.slug"
:to="versionHref(identity, v)"
class="lang-menu-item"
:lang="v.language"
role="menuitem"
:aria-current="v.slug === currentSlug ? 'true' : undefined"
@click="hide"
>
<span class="lmi-name">{{ languageName(v.language) }}</span>
<span class="lmi-sub">{{ v.title }}</span>
<svg
v-if="v.slug === currentSlug"
class="lmi-check"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</NuxtLink>
</template>
<template v-if="missing.length">
<div class="lang-menu-section" :class="{ 'lang-menu-gap': existing.length }">
{{ t('langMenu.writeIn') }}
</div>
<NuxtLink
v-for="l in missing"
:key="l.code"
:to="{ path: '/new', query: { translate: editorSlug, lang: l.code } }"
class="lang-menu-item lang-menu-write"
:lang="l.code"
role="menuitem"
:title="t('langMenu.writeArticleIn', { name: l.name })"
@click="hide"
>
<span class="lmi-name">{{ l.name }}</span>
<svg class="lmi-pencil" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</NuxtLink>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
/* One page of the open book. Its left edge is a spine — the title, set
vertically, with the × that closes the page — and the article itself
scrolls on its own inside the page. A newer page slides over this one and
leaves only the spine showing, the way a stack of read pages does; click
anywhere on the page — or its spine — to turn back to it.
Everything but the spine of a page that isn't in view is `inert`: it can
still be seen while you slide the stack sideways, but it doesn't take
focus or clicks — the pane's own click handler catches those instead, so
one click turns to the page before its links do anything. */
const props = defineProps<{
slug: string
index: number
active: boolean
}>()
const emit = defineEmits<{
activate: []
close: []
remove: []
}>()
const { get, unreadable } = useArticleLibrary()
const { t, locale } = useI18n()
const { th } = useAppLocale()
const article = computed(() => get(props.slug))
const failed = computed(() => props.slug in unreadable.value)
const title = computed(() => article.value?.title ?? props.slug)
const language = computed(() => articleLanguage(article.value ?? {}))
const languageLabel = computed(() => languageName(language.value))
/* every language this article exists in, the one in view included */
const versions = computed(() => article.value?.versions ?? [])
/* the article behind this document, and the slug its editor answers to:
the identity when the canonical version is still around, this document's
own slug when only translations of it remain */
const identity = computed(() => identityOf(article.value ?? { slug: props.slug }))
const editorSlug = computed(() =>
versions.value.some((v) => v.slug === identity.value) ? identity.value : props.slug
)
const updated = computed(() => {
const d = article.value?.updatedAt ? new Date(article.value.updatedAt) : null
if (!d || Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(locale.value, { dateStyle: 'medium' })
})
function turnToThisPage() {
if (!props.active) emit('activate')
}
/* a tag read at the foot of the article: “probability” → “Probability” */
function topicLabel(tag: string) {
const t = tag.trim()
return t.charAt(0).toUpperCase() + t.slice(1)
}
</script>
<template>
<section
class="book-pane"
:class="{ active }"
:data-slug="slug"
:data-index="index"
:style="{ '--i': index }"
:aria-label="t('pane.aria', { title })"
@click="turnToThisPage"
>
<div
class="pane-spine"
role="button"
tabindex="0"
:aria-current="active ? 'page' : undefined"
:aria-disabled="active"
:title="t('pane.spineHint', { title })"
@click.stop="turnToThisPage"
@keydown.enter.prevent="turnToThisPage"
>
<button
type="button"
class="spine-close"
:aria-label="t('common.closeTab')"
@click.stop="emit('close')"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<span class="spine-title">{{ title }}</span>
</div>
<div class="pane-body" :inert="active ? null : ''">
<div class="pane-scroll">
<div v-if="failed && !article" class="empty">
<div class="glyph">!</div>
<h3>{{ t('pane.readFailTitle') }}</h3>
<p v-html="th('pane.readFailText', { path: `/wiki/${slug}` })"></p>
</div>
<div v-else-if="!article" class="pane-skeleton">
<div class="skeleton" style="height: 36px; width: 52%; margin-bottom: 14px" />
<div class="skeleton" style="height: 18px; width: 78%; margin-bottom: 36px; border-radius: 8px" />
<div class="skeleton" style="height: 220px" />
</div>
<article v-else class="article-page">
<header class="article-head">
<div class="article-eyebrow">
<span v-if="updated">{{ t('pane.lastEdited', { date: updated }) }}</span>
</div>
<div class="article-title-row">
<h1 class="article-title" :lang="language">{{ article.title }}</h1>
<div v-if="active" class="article-actions">
<ArticleActionsMenu
:identity="identity"
:versions="versions"
:current-slug="slug"
:language="language"
:editor-slug="editorSlug"
@remove="emit('remove')"
/>
</div>
</div>
</header>
<div class="article-body">
<MarkdownView :content="article.content ?? ''" :namespace="slug" intercept-links />
</div>
<footer v-if="article.tags?.length" class="article-topics">
<span class="article-topics-label">{{ t('pane.topicsLabel') }}</span>
<NuxtLink
v-for="t in article.tags"
:key="t"
:to="{ path: '/articles/', query: { tag: t } }"
class="topic-link"
>
{{ topicLabel(t) }}
</NuxtLink>
</footer>
</article>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
/* Reading Mathew is open to everyone; writing it takes an account. The editor
pages wrap their form in this, so a visitor who follows “New article” without
one lands somewhere that says what is missing and offers the way in, instead
of a form that quietly refuses to save. Nothing is fetched or mounted behind
the gate until there is a session. */
const { loggedIn } = useAuth()
const { show } = useSignInPopup()
const { t } = useI18n()
const props = defineProps<{
heading?: string
text?: string
}>()
/* the defaults live here rather than in withDefaults so they follow the
interface language as it changes */
const headingText = computed(() => props.heading ?? t('gate.heading'))
const textText = computed(() => props.text ?? t('gate.text'))
</script>
<template>
<slot v-if="loggedIn" />
<section v-else class="auth-gate" aria-labelledby="auth-gate-heading">
<span class="auth-gate-mark" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="15" r="4" />
<path d="m10.8 12.2 8.2-8.2 2 2-2 2 1.5 1.5-2 2-1.5-1.5-1.7 1.7" />
</svg>
</span>
<h1 id="auth-gate-heading" class="auth-gate-title">{{ headingText }}</h1>
<p class="auth-gate-text">{{ textText }}</p>
<div class="auth-gate-actions">
<button type="button" class="btn btn-primary" @click="show({ mode: 'signin' })">
{{ t('auth.signin') }}
</button>
<button type="button" class="btn btn-ghost" @click="show({ mode: 'signup' })">
{{ t('auth.signup') }}
</button>
</div>
<p class="auth-gate-fine">{{ t('gate.fine') }}</p>
</section>
</template>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
/* Both ways in — into an existing account or a new one — in a single popup,
drawn and closed in the same shape as the settings popup: esc, the backdrop,
the ✕, or moving to another page. Whoever opens it chooses a tab and may leave
a line of explanation above the form (see useSignInPopup); the editor uses
that when a save comes back saying the session has gone.
Signing in is enough on its own: the editor pages show their form as soon as
there is a session, so the page behind this popup is already ready when it
closes — whatever was being written is still there. */
const { open, mode, notice, show, hide } = useSignInPopup()
const { hide: hideSearch } = useSearch()
const { hide: hideSettings } = useSettings()
const { hide: hideProfile } = useProfileMenu()
const { signIn, signUp } = useAuth()
const { applyAccount } = useAppLocale()
const { t } = useI18n()
const route = useRoute()
const username = ref('')
const password = ref('')
const errorMsg = ref('')
const busy = ref(false)
const panelEl = ref<HTMLElement | null>(null)
const usernameEl = ref<HTMLInputElement | null>(null)
const isSignUp = computed(() => mode.value === 'signup')
const heading = computed(() => (isSignUp.value ? t('auth.signup') : t('auth.signin')))
const submitLabel = computed(() =>
busy.value ? t('auth.busy') : isSignUp.value ? t('auth.signupSubmit') : t('auth.signin')
)
watch(
open,
async (isOpen) => {
if (!isOpen) return
// opening this shouldn't leave another popup stacked behind it
hideSearch()
hideSettings()
hideProfile()
errorMsg.value = ''
await nextTick()
usernameEl.value?.focus()
},
{ flush: 'post' }
)
/* Switching tabs keeps the username — the same person either way — but not the
password, which is a different secret in each direction. */
function setMode(next: SignInMode) {
if (mode.value === next) return
show({ mode: next })
password.value = ''
errorMsg.value = ''
}
async function submit() {
if (busy.value) return
errorMsg.value = ''
busy.value = true
try {
// the account decides the interface language — let it overrule the device
const user = isSignUp.value ? await signUp(username.value, password.value) : await signIn(username.value, password.value)
applyAccount(user?.locale)
password.value = ''
hide()
} catch (err: any) {
errorMsg.value = err?.data?.message || t('common.apiUnreachable')
} finally {
busy.value = false
}
}
/* --- esc closes, navigation closes --- */
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) hide()
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
watch(
() => route.fullPath,
() => {
if (open.value) hide()
}
)
</script>
<template>
<Teleport to="body">
<Transition name="auth-popup">
<div v-if="open" class="auth-overlay" @click.self="hide()">
<div
ref="panelEl"
class="auth-modal"
role="dialog"
aria-modal="true"
:aria-label="heading"
tabindex="-1"
>
<div class="auth-modal-head">
<h2 class="auth-title">{{ heading }}</h2>
<button
type="button"
class="panel-icon-btn"
:aria-label="t('common.close')"
:title="t('common.close')"
@click="hide()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="auth-modal-body">
<div class="auth-tabs" role="tablist" :aria-label="t('auth.tabsAria')">
<button
type="button"
role="tab"
class="auth-tab"
:class="{ active: !isSignUp }"
:aria-selected="!isSignUp"
@click="setMode('signin')"
>
{{ t('auth.signin') }}
</button>
<button
type="button"
role="tab"
class="auth-tab"
:class="{ active: isSignUp }"
:aria-selected="isSignUp"
@click="setMode('signup')"
>
{{ t('auth.signupTab') }}
</button>
</div>
<p v-if="notice" class="auth-notice">{{ notice }}</p>
<form @submit.prevent="submit">
<div class="field">
<label for="auth-username">{{ t('auth.username') }}</label>
<input
id="auth-username"
ref="usernameEl"
v-model="username"
type="text"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
:placeholder="t('auth.usernamePlaceholder')"
required
/>
</div>
<div class="field">
<label for="auth-password">
{{ t('auth.password') }} <span class="hint">{{ t('auth.passwordHint') }}</span>
</label>
<input
id="auth-password"
v-model="password"
type="password"
:autocomplete="isSignUp ? 'new-password' : 'current-password'"
minlength="8"
required
/>
</div>
<p v-if="errorMsg" class="form-error">{{ errorMsg }}</p>
<button type="submit" class="btn btn-primary auth-submit" :disabled="busy">
{{ submitLabel }}
</button>
</form>
<p class="auth-switch">
<template v-if="isSignUp">
{{ t('auth.haveAccount') }}
<button type="button" @click="setMode('signin')">{{ t('auth.signin') }}</button>
</template>
<template v-else>
{{ t('auth.newToMathew') }}
<button type="button" @click="setMode('signup')">{{ t('auth.signup') }}</button>
</template>
</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/* Yes/no popup, same shape as the settings and cheatsheet modals: teleported
overlay, dismissed by the backdrop or the safe button. Esc is left to the
caller, which is how the editor already orders its own popups. */
const props = defineProps<{
open: boolean
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
}>()
const { t } = useI18n()
/* the defaults live here rather than in withDefaults so they follow the
interface language as it changes */
const titleText = computed(() => props.title ?? t('common.areYouSure'))
const confirmText = computed(() => props.confirmLabel ?? t('common.confirm'))
const cancelText = computed(() => props.cancelLabel ?? t('common.cancel'))
const emit = defineEmits<{ confirm: []; cancel: [] }>()
const cancelButtonEl = ref<HTMLButtonElement | null>(null)
/* land on the safe button when the popup opens, so Enter can never trigger
the destructive action by accident */
watch(
() => props.open,
async (isOpen) => {
if (!isOpen) return
await nextTick()
cancelButtonEl.value?.focus()
},
{ flush: 'post' }
)
</script>
<template>
<Teleport to="body">
<Transition name="confirm-popup">
<div v-if="open" class="confirm-overlay" @click.self="emit('cancel')">
<div class="confirm-modal" role="dialog" aria-modal="true" :aria-label="titleText">
<h2 class="confirm-title">{{ titleText }}</h2>
<p v-if="message" class="confirm-message">{{ message }}</p>
<div class="confirm-actions">
<button
ref="cancelButtonEl"
type="button"
class="btn btn-ghost"
@click="emit('cancel')"
>
{{ cancelText }}
</button>
<button type="button" class="btn btn-danger" @click="emit('confirm')">
{{ confirmText }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
/* Writing-help popup for the editor, opened by the "?" in the toolbar.
Same shape as SettingsModal: teleported overlay, closes on backdrop
click or the × (Esc is handled by ArticleForm, which also keeps it in
front of the fullscreen toggle). */
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
</script>
<template>
<Teleport to="body">
<Transition name="cheatsheet-popup">
<div v-if="open" class="cheatsheet-overlay" @click.self="emit('close')">
<div
class="cheatsheet-modal"
role="dialog"
aria-modal="true"
:aria-label="t('cheatsheet.title')"
>
<div class="cheatsheet-head">
<h2 class="cheatsheet-title">{{ t('cheatsheet.title') }}</h2>
<button
type="button"
class="panel-icon-btn"
:aria-label="t('cheatsheet.closeAria')"
:title="t('common.close')"
@click="emit('close')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="cheatsheet-body">
<section class="cheatsheet-group">
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.links') }}</h3>
<div class="cheat-row">
<code>[[Riemann zeta function]]</code>
<span>{{ t('cheatsheet.linkByTitle') }}</span>
</div>
<div class="cheat-row">
<code>[[Cauchy–Schwarz|the inequality]]</code>
<span>{{ t('cheatsheet.linkOwnWording') }}</span>
</div>
<div class="cheat-row">
<code>[text](https://example.org)</code>
<span>{{ t('cheatsheet.linkWebPage') }}</span>
</div>
<div class="cheat-row">
<code>https://example.org</code>
<span>{{ t('cheatsheet.bareUrl') }}</span>
</div>
</section>
<section class="cheatsheet-group">
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.math') }}</h3>
<div class="cheat-row">
<code>$e^{i\pi}+1=0$</code>
<span>{{ t('cheatsheet.inline') }} — <KatexSpan tex="\,e^{i\pi}+1=0\," /></span>
</div>
<div class="cheat-row">
<code>$$\frac{\pi^2}{6}$$</code>
<span>{{ t('cheatsheet.display') }} — <KatexSpan tex="\,\tfrac{\pi^2}{6}\," /></span>
</div>
<div class="cheat-row">
<code>\R \N \Z \Q \C</code>
<span>{{ t('cheatsheet.numberSets') }} — <KatexSpan tex="\,\R\ \N\ \Z\ \Q\ \C\," /></span>
</div>
<div class="cheat-row">
<code>\sum \int \frac{a}{b} \sqrt{x}</code>
<span>{{ t('cheatsheet.sumsEtc') }}</span>
</div>
</section>
<section class="cheatsheet-group">
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.text') }}</h3>
<div class="cheat-row">
<code>**bold** *italic*</code>
<span v-html="t('cheatsheet.boldItalic')"></span>
</div>
<div class="cheat-row">
<code>~~struck out~~</code>
<span v-html="t('cheatsheet.struckOut')"></span>
</div>
<div class="cheat-row">
<code>`inline code`</code>
<span>{{ t('cheatsheet.shadedCode') }}</span>
</div>
<div class="cheat-row">
<code>```python … ```</code>
<span>{{ t('cheatsheet.fencedBlock') }}</span>
</div>
</section>
<section class="cheatsheet-group">
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.blocks') }}</h3>
<div class="cheat-row">
<code># ## ###</code>
<span>{{ t('cheatsheet.headings') }}</span>
</div>
<div class="cheat-row">
<code>- item</code>
<span>{{ t('cheatsheet.bulletList') }}</span>
</div>
<div class="cheat-row">
<code>1. item</code>
<span>{{ t('cheatsheet.numberedList') }}</span>
</div>
<div class="cheat-row">
<code>&gt; quote</code>
<span>{{ t('cheatsheet.blockquote') }}</span>
</div>
<div class="cheat-row">
<code>| a | b |</code>
<span>{{ t('cheatsheet.tableRows') }}</span>
</div>
<div class="cheat-row">
<code>---</code>
<span>{{ t('cheatsheet.hr') }}</span>
</div>
</section>
<section class="cheatsheet-group">
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.shortcuts') }}</h3>
<div class="cheat-row">
<code>Ctrl / ⌘ + S</code>
<span>{{ t('cheatsheet.saveShortcut') }}</span>
</div>
<div class="cheat-row">
<code>Tab</code>
<span>{{ t('cheatsheet.tabIndent') }}</span>
</div>
<div class="cheat-row">
<code>Esc</code>
<span>{{ t('cheatsheet.escHelp') }}</span>
</div>
</section>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,574 @@
<script setup lang="ts">
/* The link map, Obsidian-style: every article is a node, every link from
one article's text into another is an edge between them. A small
hand-rolled force simulation (pairwise repulsion, springs along the
edges, a gentle pull to the centre) settles the nodes into a shape,
and everything is painted on one canvas: drag the background to pan,
the wheel to zoom, a node to pull it around, and let go of a node
where it was pressed to open that article. */
export interface GraphNodeDto {
slug: string
title: string
tags?: string[]
links: number
}
export interface GraphEdgeDto {
source: string
target: string
}
const props = defineProps<{ nodes: GraphNodeDto[]; edges: GraphEdgeDto[] }>()
const router = useRouter()
const wrapEl = ref<HTMLElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursor = ref<'grab' | 'grabbing' | 'pointer'>('grab')
const hoverTitle = ref('')
const { t } = useI18n()
/* the simulation is plain mutable state — it ticks every frame, and
Vue would only slow it down */
interface SimNode {
slug: string
title: string
deg: number
x: number
y: number
vx: number
vy: number
r: number
fx: number | null
fy: number | null
}
interface SimLink {
a: SimNode
b: SimNode
rest: number
strength: number
bias: number
}
let simNodes: SimNode[] = []
let simLinks: SimLink[] = []
let bySlug = new Map<string, SimNode>()
let neighbours = new Map<string, Set<string>>()
let hubCut = 0 // articles with at least this many links always wear their label
let width = 0
let height = 0
let dpr = 1
/* the view: world point p draws at p * view.k + view.x/y */
const view = { k: 1, x: 0, y: 0 }
let viewTarget: { k: number; x: number; y: number } | null = null
const ALPHA_MIN = 0.002
const ALPHA_DECAY = 0.021
const VEL_DECAY = 0.55
const REPULSE = 1100
const GRAVITY = 0.055
const MIN_K = 0.15
const MAX_K = 5
let alpha = 1
let alphaTarget = 0
let raf = 0
let hoverNode: SimNode | null = null
let dragNode: SimNode | null = null
let panning = false
let movedFar = false
let lastPt = { x: 0, y: 0 }
let downPt = { x: 0, y: 0 }
/* canvas colours follow the site theme, including accent switches */
const colors = {
edge: '#888',
accent: '#4f5fe8',
accent2: '#0ea5c9',
halo: '#ffffff',
label: '#5b6478',
text: '#1b2130',
}
function readColors() {
const cs = getComputedStyle(document.documentElement)
const at = (name: string, fallback: string) =>
cs.getPropertyValue(name).trim() || fallback
colors.edge = at('--border-strong', colors.edge)
colors.accent = at('--accent', colors.accent)
colors.accent2 = at('--accent-2', colors.accent2)
colors.halo = at('--bg', colors.halo)
colors.label = at('--muted', colors.label)
colors.text = at('--text', colors.text)
}
const toWorldX = (px: number) => (px - view.x) / view.k
const toWorldY = (py: number) => (py - view.y) / view.k
function build() {
bySlug = new Map()
neighbours = new Map()
simNodes = props.nodes.map((n, i) => {
/* a golden-angle spiral start: no two nodes on top of each other,
and the repulsion unfolds it into a disc from there */
const angle = i * 2.399963229728653
const radius = 26 * Math.sqrt(i + 1)
const node: SimNode = {
slug: n.slug,
title: n.title,
deg: Math.max(n.links ?? 0, 0),
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
vx: 0,
vy: 0,
r: 4 + Math.sqrt(Math.max(n.links ?? 0, 0)) * 2.4,
fx: null,
fy: null,
}
bySlug.set(n.slug, node)
neighbours.set(n.slug, new Set())
return node
})
simLinks = []
for (const e of props.edges) {
const a = bySlug.get(e.source)
const b = bySlug.get(e.target)
if (!a || !b) continue
neighbours.get(a.slug)?.add(b.slug)
neighbours.get(b.slug)?.add(a.slug)
simLinks.push({
a,
b,
rest: 58 + a.r + b.r,
/* d3's rule: an edge pulls hardest when both ends are lonely,
and hardly at all when either is a hub */
strength: 1 / Math.max(Math.min(a.deg, b.deg), 1),
bias: a.deg / Math.max(a.deg + b.deg, 1),
})
}
/* small graphs wear every label; bigger ones only the well-linked
hubs — until the zoom gets close enough to read the rest */
if (simNodes.length <= 24) hubCut = 0
else {
const degs = simNodes.map((n) => n.deg).sort((x, y) => y - x)
hubCut = Math.max(2, degs[11] ?? 2)
}
}
function tick() {
alpha += (alphaTarget - alpha) * ALPHA_DECAY
for (let i = 0; i < simNodes.length; i++) {
const a = simNodes[i]!
for (let j = i + 1; j < simNodes.length; j++) {
const b = simNodes[j]!
let dx = a.x - b.x
let dy = a.y - b.y
let d2 = dx * dx + dy * dy
if (d2 === 0) {
dx = Math.random() - 0.5
dy = Math.random() - 0.5
d2 = dx * dx + dy * dy
}
const w = (REPULSE * alpha) / Math.max(d2, 40)
a.vx += dx * w
a.vy += dy * w
b.vx -= dx * w
b.vy -= dy * w
}
}
for (const l of simLinks) {
const dx = l.b.x - l.a.x
const dy = l.b.y - l.a.y
const d = Math.max(Math.hypot(dx, dy), 0.01)
const f = ((d - l.rest) / d) * alpha * l.strength
const fx = dx * f
const fy = dy * f
l.b.vx -= fx * l.bias
l.b.vy -= fy * l.bias
l.a.vx += fx * (1 - l.bias)
l.a.vy += fy * (1 - l.bias)
}
for (const n of simNodes) {
n.vx -= n.x * GRAVITY * alpha
n.vy -= n.y * GRAVITY * alpha
if (n.fx !== null) {
n.x = n.fx
n.vx = 0
} else {
n.vx *= VEL_DECAY
n.x += n.vx
}
if (n.fy !== null) {
n.y = n.fy
n.vy = 0
} else {
n.vy *= VEL_DECAY
n.y += n.vy
}
}
}
function draw() {
const ctx = canvasEl.value?.getContext('2d')
if (!ctx || width === 0) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
const sx = (wx: number) => wx * view.k + view.x
const sy = (wy: number) => wy * view.k + view.y
const focus = hoverNode ?? dragNode
const lit = focus ? new Set([focus.slug, ...(neighbours.get(focus.slug) ?? [])]) : null
/* edges first: under the focus all others recede into the background */
ctx.lineCap = 'round'
for (const l of simLinks) {
const active = !!focus && (l.a === focus || l.b === focus)
if (!active) {
ctx.globalAlpha = focus ? 0.05 : 0.5
ctx.strokeStyle = colors.edge
ctx.lineWidth = 1
} else {
ctx.globalAlpha = 0.9
ctx.strokeStyle = colors.accent
ctx.lineWidth = 1.5
}
ctx.beginPath()
ctx.moveTo(sx(l.a.x), sy(l.a.y))
ctx.lineTo(sx(l.b.x), sy(l.b.y))
ctx.stroke()
}
const paintNode = (n: SimNode) => {
const x = sx(n.x)
const y = sy(n.y)
if (x < -30 || y < -30 || x > width + 30 || y > height + 30) return
const r = Math.max(n.r * view.k, 2.5)
const dimmed = !!focus && !lit?.has(n.slug)
const isFocus = n === focus
ctx.globalAlpha = dimmed ? 0.16 : 1
ctx.fillStyle = isFocus ? colors.accent2 : colors.accent
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = dimmed ? 0.1 : 1
ctx.lineWidth = 1.5
ctx.strokeStyle = colors.halo
ctx.stroke()
if (isFocus) {
ctx.globalAlpha = 0.35
ctx.strokeStyle = colors.accent
ctx.beginPath()
ctx.arc(x, y, r + 4.5, 0, Math.PI * 2)
ctx.stroke()
}
}
/* the nodes in a glow sit above the receded ones */
for (const n of simNodes) if (!lit?.has(n.slug)) paintNode(n)
if (focus && lit) {
for (const n of simNodes) if (lit.has(n.slug) && n !== focus) paintNode(n)
paintNode(focus)
}
/* labels in screen space so they stay the same size at every zoom */
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
for (const n of simNodes) {
const isLit = !!focus && lit?.has(n.slug)
if (!isLit && n.deg < hubCut && view.k < 1.35) continue
const x = sx(n.x)
const y = sy(n.y)
if (x < -60 || y < -30 || x > width + 60 || y > height + 20) continue
const r = Math.max(n.r * view.k, 2.5)
ctx.globalAlpha = focus && !isLit ? 0.25 : 1
ctx.font = `500 ${n === focus ? 13 : 11.5}px 'Computer Modern', Georgia, serif`
ctx.lineWidth = 3.5
ctx.lineJoin = 'round'
ctx.strokeStyle = colors.halo
ctx.strokeText(n.title, x, y + r + 4)
ctx.fillStyle = n === focus ? colors.text : colors.label
ctx.fillText(n.title, x, y + r + 4)
}
ctx.globalAlpha = 1
}
function frame() {
if (viewTarget) {
view.k += (viewTarget.k - view.k) * 0.18
view.x += (viewTarget.x - view.x) * 0.18
view.y += (viewTarget.y - view.y) * 0.18
const settled =
Math.abs(viewTarget.k - view.k) < 0.001 &&
Math.abs(viewTarget.x - view.x) < 0.5 &&
Math.abs(viewTarget.y - view.y) < 0.5
if (settled) viewTarget = null
}
if (alpha > ALPHA_MIN || alphaTarget > 0 || dragNode) tick()
draw()
raf = requestAnimationFrame(frame)
}
/* the view fits the whole graph, labels given room, capped so a lone
dot doesn't fill the screen */
function fitView() {
if (!simNodes.length || !width || !height) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of simNodes) {
minX = Math.min(minX, n.x - n.r)
minY = Math.min(minY, n.y - n.r)
maxX = Math.max(maxX, n.x + n.r)
maxY = Math.max(maxY, n.y + n.r)
}
const padX = 90
const padTop = 40
const padBottom = 72
const bw = Math.max(maxX - minX, 40)
const bh = Math.max(maxY - minY, 40)
const k = Math.min(
(width - padX * 2) / bw,
(height - padTop - padBottom) / bh,
1.8
)
viewTarget = {
k,
x: width / 2 - ((minX + maxX) / 2) * k,
y: (padTop + height) / 2 - ((minY + maxY) / 2) * k,
}
}
function zoomAt(px: number, py: number, factor: number) {
const k = Math.min(Math.max(view.k * factor, MIN_K), MAX_K)
/* keep the world point under (px, py) anchored — the offsets are
computed against the old scale, so k goes in last */
view.x = px - ((px - view.x) / view.k) * k
view.y = py - ((py - view.y) / view.k) * k
view.k = k
viewTarget = null
}
function zoomBy(factor: number) {
zoomAt(width / 2, height / 2, factor)
}
function reheat() {
alpha = 1
alphaTarget = 0
}
function localPoint(e: PointerEvent | WheelEvent) {
const rect = canvasEl.value!.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function pick(px: number, py: number): SimNode | null {
for (let i = simNodes.length - 1; i >= 0; i--) {
const n = simNodes[i]!
const r = Math.max(n.r * view.k, 2.5) + 3
const dx = px - (n.x * view.k + view.x)
const dy = py - (n.y * view.k + view.y)
if (dx * dx + dy * dy <= r * r) return n
}
return null
}
function onPointerDown(e: PointerEvent) {
const p = localPoint(e)
canvasEl.value?.setPointerCapture(e.pointerId)
lastPt = downPt = p
movedFar = false
const hit = pick(p.x, p.y)
if (hit) {
dragNode = hit
hit.fx = toWorldX(p.x)
hit.fy = toWorldY(p.y)
alphaTarget = 0.3
alpha = Math.max(alpha, 0.12)
cursor.value = 'grabbing'
} else {
panning = true
cursor.value = 'grabbing'
}
}
function onPointerMove(e: PointerEvent) {
const p = localPoint(e)
if (dragNode) {
dragNode.fx = toWorldX(p.x)
dragNode.fy = toWorldY(p.y)
} else if (panning) {
view.x += p.x - lastPt.x
view.y += p.y - lastPt.y
viewTarget = null
} else {
hoverNode = pick(p.x, p.y)
hoverTitle.value = hoverNode?.title ?? ''
cursor.value = hoverNode ? 'pointer' : 'grab'
return
}
if (Math.hypot(p.x - downPt.x, p.y - downPt.y) > 4) movedFar = true
lastPt = p
}
function onPointerUp(e: PointerEvent) {
const p = localPoint(e)
if (dragNode) {
const opened = dragNode
dragNode.fx = null
dragNode.fy = null
dragNode = null
alphaTarget = 0
/* pressed and released without a drag: open the article, the way
Obsidian jumps to the note behind the node */
if (!movedFar) {
e.preventDefault()
router.push(`/wiki/${opened.slug}`)
return
}
}
panning = false
const still = pick(p.x, p.y)
hoverNode = still
hoverTitle.value = still?.title ?? ''
cursor.value = still ? 'pointer' : 'grab'
}
function onWheel(e: WheelEvent) {
e.preventDefault()
const p = localPoint(e)
let dy = e.deltaY
if (e.deltaMode === 1) dy *= 16
else if (e.deltaMode === 2) dy *= 100
/* pinch-to-zoom arrives as ctrl+wheel — calm the large deltas down */
const step = e.ctrlKey ? 0.004 : 0.0016
zoomAt(p.x, p.y, Math.exp(-dy * step))
}
function onDoubleClick() {
fitView()
}
function clearHover() {
if (dragNode || panning) return
hoverNode = null
hoverTitle.value = ''
cursor.value = 'grab'
}
function onPointerCancel() {
/* the browser took the gesture over — release everything, never open */
if (dragNode) {
dragNode.fx = null
dragNode.fy = null
dragNode = null
}
alphaTarget = 0
panning = false
clearHover()
}
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
onMounted(() => {
readColors()
const wrap = wrapEl.value!
const canvas = canvasEl.value!
const resize = () => {
const rect = wrap.getBoundingClientRect()
width = Math.max(rect.width, 1)
height = Math.max(rect.height, 1)
dpr = window.devicePixelRatio || 1
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
}
resize()
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(wrap)
themeObserver = new MutationObserver(readColors)
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
build()
/* a few silent ticks so the first painted frame is already a shape */
for (let i = 0; i < 40; i++) tick()
fitView()
raf = requestAnimationFrame(frame)
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
resizeObserver?.disconnect()
themeObserver?.disconnect()
})
/* a fresh fetch (a new article, a new link) rebuilds the map */
watch(
() => [props.nodes, props.edges],
() => {
if (!import.meta.client) return
build()
alpha = 1
alphaTarget = 0
fitView()
}
)
</script>
<template>
<div ref="wrapEl" class="graph-canvas-wrap">
<canvas
ref="canvasEl"
class="graph-canvas"
:style="{ cursor }"
:title="hoverTitle"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
@wheel="onWheel"
@dblclick="onDoubleClick"
@pointerleave="clearHover"
/>
<div class="graph-toolbar">
<button type="button" class="panel-icon-btn" :title="t('graph.fit')" :aria-label="t('graph.fit')" @click="fitView">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('graph.zoomIn')" :aria-label="t('graph.zoomIn')" @click="zoomBy(1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('graph.zoomOut')" :aria-label="t('graph.zoomOut')" @click="zoomBy(1 / 1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('graph.relayout')" :aria-label="t('graph.relayout')" @click="reheat">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
<path d="M21 3v5h-5" />
</svg>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
/** Renders a short inline LaTeX snippet with KaTeX. */
import katex from 'katex'
const props = defineProps<{ tex: string }>()
const html = computed(() =>
katex.renderToString(props.tex, { throwOnError: false, displayMode: false })
)
</script>
<template>
<span v-html="html" />
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
/**
* Renders markdown (with LaTeX) into styled prose HTML.
* Used on article pages and in the editor's live preview pane.
*/
const props = defineProps<{
content: string
/** Intercept clicks on internal links and use client-side routing. */
interceptLinks?: boolean
/**
* Prefix for the heading ids this article is given. Several articles are
* open at once in the reader, so their sections are namespaced by slug to
* keep two articles' "Proof" headings from sharing one id.
*/
namespace?: string
}>()
const rendered = computed(() => renderMarkdown(props.content, props.namespace))
const router = useRouter()
function onClick(e: MouseEvent) {
if (!props.interceptLinks) return
const target = e.target as HTMLElement
const anchor = target.closest?.('a')
if (!anchor) return
const href = anchor.getAttribute('href') || ''
if (href.startsWith('/')) {
e.preventDefault()
router.push(href)
}
}
</script>
<template>
<div class="prose" v-html="rendered.html" @click="onClick" />
</template>

View File

@@ -0,0 +1,358 @@
<script setup lang="ts">
/* Centered profile popup, opened by the account row in the sidebar (and closed
with esc, a backdrop click, or navigation — the same shape as the settings
popup). It holds the account itself: the profile picture, the username and
the password, and the way out. */
const { open, hide } = useProfileMenu()
const { hide: hideSearch } = useSearch()
const { hide: hideSettings } = useSettings()
const { hide: hideSignIn, show: openSignIn } = useSignInPopup()
const { user, signOut, updateUsername, changePassword, setAvatar } = useAuth()
const { t } = useI18n()
const route = useRoute()
const panelEl = ref<HTMLElement | null>(null)
/* the fallback the sidebar wears too: an initial in a circle */
const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperCase())
/* --- the picture ---
The chosen file is shrunk in the browser before it is sent: the avatar is
small everywhere, so there is no reason to store it any bigger. */
const AVATAR_SIZE = 256
const avatarBusy = ref(false)
const avatarError = ref('')
const fileInputEl = ref<HTMLInputElement | null>(null)
/* Reads the file, crops the centre to a square, and scales it down to
AVATAR_SIZE on a canvas. The white fill makes transparent PNGs flatten to
white rather than black. */
async function fileToAvatarDataUrl(file: File): Promise<string> {
const raw = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error(t('profile.readFileFailed')))
reader.readAsDataURL(file)
})
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error(t('profile.notReadable')))
img.src = raw
})
const side = Math.min(img.naturalWidth, img.naturalHeight)
const canvas = document.createElement('canvas')
canvas.width = AVATAR_SIZE
canvas.height = AVATAR_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error(t('profile.drawFailed'))
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, AVATAR_SIZE, AVATAR_SIZE)
ctx.drawImage(
img,
(img.naturalWidth - side) / 2,
(img.naturalHeight - side) / 2,
side,
side,
0,
0,
AVATAR_SIZE,
AVATAR_SIZE
)
return canvas.toDataURL('image/jpeg', 0.86)
}
async function onAvatarPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = '' // so the same file can be chosen again later
if (!file) return
avatarError.value = ''
if (!file.type.startsWith('image/')) {
avatarError.value = t('profile.notAnImage')
return
}
avatarBusy.value = true
try {
const dataUrl = await fileToAvatarDataUrl(file)
await setAvatar(dataUrl)
} catch (err: any) {
// a failure of the browser's own (reading, drawing) says why in its
// message; a failure of the request goes through describeFailure
avatarError.value =
err?.statusCode != null
? describeFailure(err) || t('profile.uploadFailed')
: err?.message || t('profile.readFailed')
} finally {
avatarBusy.value = false
}
}
async function removeAvatar() {
avatarError.value = ''
avatarBusy.value = true
try {
await setAvatar('')
} catch (err: any) {
avatarError.value = describeFailure(err) || t('profile.removeFailed')
} finally {
avatarBusy.value = false
}
}
/* --- the account --- */
const username = ref('')
const savingName = ref(false)
const nameError = ref('')
const nameSaved = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const savingPassword = ref(false)
const passwordError = ref('')
const passwordSaved = ref(false)
/* A 401 means the session held is dead: drop it and hand the person the
sign-in popup over what just failed. Anything else is only an error line —
'' when the popup has already been swept away with the session. */
function describeFailure(err: any): string {
if (err?.statusCode === 401 || err?.response?.status === 401) {
signOut()
openSignIn({ notice: t('auth.expiredProfile') })
return ''
}
return err?.data?.message || t('common.apiUnreachable')
}
async function saveUsername() {
if (savingName.value) return
nameError.value = ''
nameSaved.value = false
const trimmed = username.value.trim().toLowerCase()
if (!trimmed) {
nameError.value = t('profile.nameRequired')
return
}
savingName.value = true
try {
await updateUsername(trimmed)
nameSaved.value = true
} catch (err: any) {
nameError.value = describeFailure(err) || t('profile.nameFailed')
} finally {
savingName.value = false
}
}
async function savePassword() {
if (savingPassword.value) return
passwordError.value = ''
passwordSaved.value = false
savingPassword.value = true
try {
await changePassword(currentPassword.value, newPassword.value)
currentPassword.value = ''
newPassword.value = ''
passwordSaved.value = true
} catch (err: any) {
passwordError.value = describeFailure(err) || t('profile.passwordFailed')
} finally {
savingPassword.value = false
}
}
function leave() {
signOut()
hide()
}
/* sync the form from the account whenever the popup opens */
watch(
open,
async (isOpen) => {
if (!isOpen) return
// opening this shouldn't leave another popup stacked behind it
hideSearch()
hideSettings()
hideSignIn()
username.value = user.value?.username ?? ''
currentPassword.value = ''
newPassword.value = ''
avatarBusy.value = false
avatarError.value = ''
nameError.value = ''
nameSaved.value = false
passwordError.value = ''
passwordSaved.value = false
await nextTick()
panelEl.value?.focus()
},
{ flush: 'post' }
)
/* --- esc closes, navigation closes --- */
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) hide()
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
watch(
() => route.fullPath,
() => {
if (open.value) hide()
}
)
</script>
<template>
<Teleport to="body">
<Transition name="profile-popup">
<div v-if="open && user" class="profile-overlay" @click.self="hide()">
<div
ref="panelEl"
class="profile-modal"
role="dialog"
aria-modal="true"
:aria-label="t('profile.title')"
tabindex="-1"
>
<div class="profile-modal-head">
<h2 class="profile-title">{{ t('profile.title') }}</h2>
<button
type="button"
class="panel-icon-btn"
:aria-label="t('profile.closeAria')"
:title="t('common.close')"
@click="hide()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="profile-modal-body">
<!-- the picture -->
<section class="profile-group" aria-labelledby="profile-picture">
<div class="profile-group-head">
<span id="profile-picture" class="profile-group-title">{{ t('profile.picture') }}</span>
<span class="profile-group-hint">{{ t('profile.pictureHint') }}</span>
</div>
<div class="profile-avatar-row">
<span class="profile-avatar" aria-hidden="true">
<img v-if="user.avatar" :src="user.avatar" alt="" />
<template v-else>{{ initials }}</template>
</span>
<div class="profile-avatar-actions">
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="avatarBusy"
@click="fileInputEl?.click()"
>
{{ avatarBusy ? t('profile.uploading') : user.avatar ? t('profile.changePicture') : t('profile.choosePicture') }}
</button>
<button
v-if="user.avatar"
type="button"
class="btn btn-ghost btn-sm"
:disabled="avatarBusy"
@click="removeAvatar()"
>
{{ t('profile.remove') }}
</button>
<input
ref="fileInputEl"
type="file"
accept="image/png, image/jpeg, image/webp, image/gif"
class="profile-file-input"
aria-hidden="true"
tabindex="-1"
@change="onAvatarPicked($event)"
/>
</div>
</div>
<p v-if="avatarError" class="form-error">{{ avatarError }}</p>
</section>
<!-- the account itself -->
<section class="profile-group" aria-labelledby="profile-username">
<div class="profile-group-head">
<span id="profile-username" class="profile-group-title">{{ t('profile.username') }}</span>
<span class="profile-group-hint">{{ t('profile.usernameHint') }}</span>
</div>
<form class="profile-form" @submit.prevent="saveUsername">
<input
v-model="username"
type="text"
:aria-label="t('profile.username')"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
minlength="3"
maxlength="30"
@input="nameSaved = false"
/>
<button
type="submit"
class="btn btn-primary btn-sm"
:disabled="savingName || !username.trim() || username.trim().toLowerCase() === user.username"
>
{{ savingName ? t('profile.saving') : t('profile.save') }}
</button>
</form>
<p v-if="nameError" class="form-error">{{ nameError }}</p>
<p v-else-if="nameSaved" class="profile-saved">{{ t('profile.nameSaved') }}</p>
</section>
<section class="profile-group" aria-labelledby="profile-password">
<div class="profile-group-head">
<span id="profile-password" class="profile-group-title">{{ t('profile.password') }}</span>
<span class="profile-group-hint">{{ t('profile.passwordHint') }}</span>
</div>
<form class="profile-form profile-form-password" @submit.prevent="savePassword">
<input
v-model="currentPassword"
type="password"
:aria-label="t('profile.currentPassword')"
autocomplete="current-password"
:placeholder="t('profile.currentPassword')"
required
/>
<input
v-model="newPassword"
type="password"
:aria-label="t('profile.newPassword')"
autocomplete="new-password"
:placeholder="t('profile.newPassword')"
minlength="8"
required
/>
<button type="submit" class="btn btn-primary btn-sm" :disabled="savingPassword">
{{ savingPassword ? t('profile.saving') : t('profile.changePassword') }}
</button>
</form>
<p v-if="passwordError" class="form-error">{{ passwordError }}</p>
<p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p>
</section>
<section class="profile-group">
<button type="button" class="btn btn-danger btn-sm profile-signout" @click="leave()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
{{ t('common.signOut') }}
</button>
</section>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
const props = defineProps<{ exclude?: string }>()
const { viewed } = useRecentlyViewed()
const { t } = useI18n()
/* the list only exists in localStorage, so render it only after mount to
keep the client's first render identical to the server's */
const mounted = ref(false)
onMounted(() => (mounted.value = true))
const others = computed(() =>
mounted.value ? viewed.value.filter((v) => v.slug !== props.exclude) : []
)
</script>
<template>
<nav v-if="others.length" class="recently-viewed" :aria-label="t('recent.aria')">
<div class="rv-title">{{ t('recent.title') }}</div>
<ul>
<li v-for="v in others" :key="v.slug">
<NuxtLink :to="`/wiki/${v.slug}`">{{ v.title }}</NuxtLink>
</li>
</ul>
</nav>
</template>

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import type { ArticleMeta } from './ArticleCard.vue'
/* Centered search popup: the sidebar search bar (or Ctrl/⌘+K, or "/"
on pages without their own search box) opens it, then it live-searches
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { th } = useAppLocale()
const { open, show, hide, toggle } = useSearch()
const { hide: hideSettings } = useSettings()
const { hide: hideProfile } = useProfileMenu()
const { viewed } = useRecentlyViewed()
const MAX_RESULTS = 8
const q = ref('')
const results = ref<ArticleMeta[]>([])
const loading = ref(false)
const failed = ref(false)
const activeIndex = ref(0)
const trimmed = computed(() => q.value.trim())
/* --- focus: the field takes focus on open, focus returns on close --- */
const inputEl = ref<HTMLInputElement | null>(null)
let previouslyFocused: HTMLElement | null = null
watch(open, async (isOpen) => {
if (isOpen) {
previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
inputEl.value?.focus()
} else {
previouslyFocused?.focus()
}
})
/* --- live search: debounce typing, discard stale responses --- */
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let requestSeq = 0
watch(trimmed, () => {
clearTimeout(debounceTimer)
activeIndex.value = 0
if (!trimmed.value) {
results.value = []
loading.value = false
failed.value = false
return
}
loading.value = true
debounceTimer = setTimeout(runSearch, 180)
})
async function runSearch() {
const seq = ++requestSeq
try {
const res = await $fetch<ArticleMeta[]>(`${api}/articles`, {
query: { q: trimmed.value },
})
if (seq !== requestSeq) return // a newer search already won
results.value = res.slice(0, MAX_RESULTS)
failed.value = false
} catch {
if (seq !== requestSeq) return
failed.value = true
} finally {
if (seq === requestSeq) loading.value = false
}
}
/* --- keyboard: global shortcuts, plus arrows + enter inside the field --- */
function onGlobalKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
hideSettings()
hideProfile()
toggle()
return
}
if (e.key === 'Escape' && open.value) {
hide()
return
}
/* "/" also opens the popup — except on the home page, where the key
focuses the hero's inline search, and while typing in a field */
if (e.key === '/' && !open.value && route.path !== '/') {
const t = e.target
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
e.preventDefault()
hideSettings()
hideProfile()
show()
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onGlobalKeydown)
clearTimeout(debounceTimer)
})
/* where a result leads: the language address of the version it shows */
function articleHref(a: ArticleMeta): string {
return versionHref(identityOf(a), { language: articleLanguage(a), slug: a.slug })
}
function openArticle(article: ArticleMeta) {
hide()
void router.push(articleHref(article))
}
function openAllResults() {
hide()
void router.push({ path: '/', query: { q: trimmed.value } })
}
function onFieldKeydown(e: KeyboardEvent) {
const n = results.value.length
if (e.key === 'ArrowDown' && n) {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % n
} else if (e.key === 'ArrowUp' && n) {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + n) % n
} else if (e.key === 'Enter') {
e.preventDefault()
const chosen = results.value[activeIndex.value] ?? results.value[0]
if (chosen) openArticle(chosen)
else if (trimmed.value) openAllResults()
}
}
/* keep the highlighted row visible while arrowing through the list */
watch(activeIndex, async () => {
await nextTick()
document
.querySelector('.search-modal .search-result.active')
?.scrollIntoView({ block: 'nearest' })
})
/* navigating away (clicking any link in the popup) closes it */
watch(() => route.fullPath, () => {
if (open.value) hide()
})
/* split text into plain / matched parts so results can highlight the term */
interface Part {
text: string
hit: boolean
}
function highlight(text: string): Part[] {
const term = trimmed.value.toLowerCase()
if (!term) return [{ text, hit: false }]
const parts: Part[] = []
const lower = text.toLowerCase()
let from = 0
for (;;) {
const idx = lower.indexOf(term, from)
if (idx === -1) break
if (idx > from) parts.push({ text: text.slice(from, idx), hit: false })
parts.push({ text: text.slice(idx, idx + term.length), hit: true })
from = idx + term.length
}
if (from < text.length) parts.push({ text: text.slice(from), hit: false })
return parts
}
</script>
<template>
<Teleport to="body">
<Transition name="search-popup">
<div v-if="open" class="search-overlay" @click.self="hide()">
<div class="search-modal" role="dialog" aria-modal="true" :aria-label="t('common.searchArticles')">
<div class="search-modal-field">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" />
</svg>
<input
ref="inputEl"
v-model="q"
class="search-modal-input"
type="text"
:placeholder="t('search.placeholder')"
:aria-label="t('common.searchArticles')"
autocomplete="off"
@keydown="onFieldKeydown"
/>
<span v-if="loading" class="search-spinner" aria-hidden="true" />
<button
v-else-if="q"
type="button"
class="panel-icon-btn"
:aria-label="t('search.clear')"
@click="q = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="search-modal-body">
<div v-if="failed" class="banner">
<span class="glyph">!</span>
<span>{{ t('search.apiDown') }}</span>
</div>
<ul v-else-if="results.length" class="search-results" role="listbox">
<li v-for="(a, i) in results" :key="a._id" role="option" :aria-selected="i === activeIndex">
<NuxtLink
:to="articleHref(a)"
class="search-result"
:class="{ active: i === activeIndex }"
@mouseenter="activeIndex = i"
@click.prevent="openArticle(a)"
>
<span class="sr-title" :lang="articleLanguage(a)">
<template v-for="(p, pi) in highlight(a.title)" :key="pi">
<mark v-if="p.hit">{{ p.text }}</mark>
<template v-else>{{ p.text }}</template>
</template>
</span>
<span class="sr-meta">
<span>{{ articleHref(a) }}</span>
<span v-if="a.tags?.length" class="tags">
<span v-for="t in a.tags.slice(0, 3)" :key="t" class="tag">{{ t }}</span>
</span>
<span
v-if="articleLanguage(a) !== 'en'"
class="lang-badge"
:title="t('common.writtenIn', { lang: languageName(articleLanguage(a)) })"
>{{ languageName(articleLanguage(a)) }}</span
>
</span>
</NuxtLink>
</li>
</ul>
<div v-else-if="loading">
<div v-for="i in 3" :key="i" class="search-skeleton" />
</div>
<!-- idle state: quick jumps to recently viewed articles -->
<div v-else-if="!trimmed && viewed.length" class="search-quick">
<div class="search-quick-title">{{ t('recent.title') }}</div>
<ul class="search-results">
<li v-for="v in viewed.slice(0, 6)" :key="v.slug">
<NuxtLink :to="`/wiki/${v.slug}`" class="search-result">
<span class="sr-title">{{ v.title }}</span>
</NuxtLink>
</li>
</ul>
</div>
<div v-else-if="trimmed" class="search-none" v-html="th('search.noMatch', { q: trimmed })"></div>
<div v-else class="search-none">{{ t('search.idle') }}</div>
</div>
<footer class="search-modal-foot">
<span class="foot-hints">
<span><kbd class="kbd-hint">↑</kbd><kbd class="kbd-hint">↓</kbd> {{ t('search.navigate') }}</span>
<span><kbd class="kbd-hint">↵</kbd> {{ t('search.open') }}</span>
<span><kbd class="kbd-hint">esc</kbd> {{ t('search.close') }}</span>
</span>
<a
v-if="trimmed"
class="foot-all"
href="#"
@click.prevent="openAllResults"
>
{{ t('search.allResults', { q: trimmed }) }}
</a>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
/* Centered settings popup, opened by the sidebar cog (and closed with esc,
a backdrop click, or navigation). It holds the preferences — the light /
dark theme switch, the accent colour, and the interface language — which
drive the data-theme and data-accent attributes that the pre-paint script
in nuxt.config.ts restores from localStorage. The language lives in the
mathew-locale cookie and, with an account behind the session, on the
account itself, so it follows the person across devices. */
const { open, hide } = useSettings()
const { hide: hideSearch } = useSearch()
const { hide: hideSignIn } = useSignInPopup()
const { hide: hideProfile } = useProfileMenu()
const { loggedIn, updateLocale } = useAuth()
const { t, locale } = useI18n()
const { choose } = useAppLocale()
const route = useRoute()
const theme = ref<'light' | 'dark'>('light')
const accent = ref('violet')
const language = ref('en')
const panelEl = ref<HTMLElement | null>(null)
const themes = [
{ value: 'light', labelKey: 'themeLight' },
{ value: 'dark', labelKey: 'themeDark' },
] as const
/* swatch previews use the light-mode pair; the real tokens come from the
[data-accent] blocks in main.scss (violet = default, no attribute) */
const accents = [
{ value: 'violet', labelKey: 'accentViolet', from: '#4f5fe8', to: '#0ea5c9' },
{ value: 'purple', labelKey: 'accentPurple', from: '#7c3aed', to: '#c026d3' },
{ value: 'ocean', labelKey: 'accentOcean', from: '#2563eb', to: '#06b6d4' },
{ value: 'teal', labelKey: 'accentTeal', from: '#0d9488', to: '#10b981' },
{ value: 'forest', labelKey: 'accentForest', from: '#16a34a', to: '#84cc16' },
{ value: 'amber', labelKey: 'accentAmber', from: '#d97706', to: '#f97316' },
{ value: 'rose', labelKey: 'accentRose', from: '#e11d48', to: '#ec4899' },
{ value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' },
] as const
/* read the live theme, accent + language whenever the popup opens so the
switches are in sync */
watch(
open,
async (isOpen) => {
if (!isOpen) return
// opening settings shouldn't leave another popup stacked behind it
hideSearch()
hideSignIn()
hideProfile()
theme.value =
(document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light'
accent.value = document.documentElement.getAttribute('data-accent') || 'violet'
language.value = locale.value
await nextTick()
panelEl.value?.focus()
},
{ flush: 'post' }
)
function setTheme(value: 'light' | 'dark') {
theme.value = value
document.documentElement.setAttribute('data-theme', value)
try {
localStorage.setItem('mathew-theme', value)
} catch {
/* storage unavailable */
}
}
function setAccent(value: string) {
accent.value = value
if (value === 'violet') document.documentElement.removeAttribute('data-accent')
else document.documentElement.setAttribute('data-accent', value)
try {
localStorage.setItem('mathew-accent', value)
} catch {
/* storage unavailable */
}
}
/* Turn the interface language. The device's own choice (the cookie setLocale
keeps) lands at once; with an account it is saved there too, so the next
device says the same hello. An account that won't take it — a session gone
quiet — doesn't undo the switch made here. */
async function setLanguage(code: string) {
if (language.value === code) return
language.value = code
await choose(code)
if (loggedIn.value) {
try {
await updateLocale(code)
} catch {
/* the device-level switch stands on its own */
}
}
}
/* --- esc closes, navigation closes --- */
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) hide()
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
watch(
() => route.fullPath,
() => {
if (open.value) hide()
}
)
</script>
<template>
<Teleport to="body">
<Transition name="settings-popup">
<div v-if="open" class="settings-overlay" @click.self="hide()">
<div
ref="panelEl"
class="settings-modal"
role="dialog"
aria-modal="true"
:aria-label="t('settings.title')"
tabindex="-1"
>
<div class="settings-modal-head">
<h2 class="settings-title">{{ t('settings.title') }}</h2>
<button
type="button"
class="panel-icon-btn"
:aria-label="t('settings.closeAria')"
:title="t('common.close')"
@click="hide()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="settings-modal-body">
<section class="settings-group" aria-labelledby="settings-appearance">
<div class="settings-group-head">
<span id="settings-appearance" class="settings-group-title">{{ t('settings.appearance') }}</span>
<span class="settings-group-hint">{{ t('settings.appearanceHint') }}</span>
</div>
<div
class="theme-switch"
role="radiogroup"
:aria-label="t('settings.themeAria')"
>
<button
v-for="opt in themes"
:key="opt.value"
type="button"
role="radio"
class="theme-option"
:class="{ active: theme === opt.value }"
:aria-checked="theme === opt.value"
@click="setTheme(opt.value)"
>
<svg v-if="opt.value === 'light'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
<span>{{ t(`settings.${opt.labelKey}`) }}</span>
</button>
</div>
</section>
<section class="settings-group" aria-labelledby="settings-accent">
<div class="settings-group-head">
<span id="settings-accent" class="settings-group-title">{{ t('settings.accent') }}</span>
<span class="settings-group-hint">{{ t('settings.accentHint') }}</span>
</div>
<div class="accent-swatches" role="radiogroup" :aria-label="t('settings.accent')">
<button
v-for="a in accents"
:key="a.value"
type="button"
role="radio"
class="accent-swatch"
:class="{ active: accent === a.value }"
:aria-checked="accent === a.value"
:aria-label="t('settings.accentName', { name: t(`settings.${a.labelKey}`) })"
:title="t('settings.accentName', { name: t(`settings.${a.labelKey}`) })"
:style="{ '--swatch': a.from, '--swatch-2': a.to }"
@click="setAccent(a.value)"
>
<svg v-if="accent === a.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
</button>
</div>
</section>
<section class="settings-group" aria-labelledby="settings-language">
<div class="settings-group-head">
<span id="settings-language" class="settings-group-title">{{ t('settings.language') }}</span>
<span class="settings-group-hint">{{ t('settings.languageHint') }}</span>
</div>
<div class="theme-switch" role="radiogroup" :aria-label="t('settings.languageAria')">
<button
v-for="l in APP_LANGUAGES"
:key="l.code"
type="button"
role="radio"
class="theme-option"
:class="{ active: language === l.code }"
:aria-checked="language === l.code"
:lang="l.code"
@click="setLanguage(l.code)"
>
<span>{{ l.name }}</span>
</button>
</div>
</section>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { TocEntry } from '~/utils/markdown'
const props = defineProps<{ entries: TocEntry[] }>()
const { t } = useI18n()
const navEl = ref<HTMLElement | null>(null)
const activeId = ref<string>('')
let observer: IntersectionObserver | null = null
/* The article in view scrolls inside its own page of the book, so the TOC
follows — and moves — that page's scroller rather than the window. */
function ownScroller(): HTMLElement | null {
return navEl.value?.closest<HTMLElement>('.pane-scroll') ?? null
}
function observe() {
observer?.disconnect()
if (!import.meta.client || !props.entries.length) return
const headings = props.entries
.map((e) => document.getElementById(e.id))
.filter((el): el is HTMLElement => !!el)
if (!headings.length) return
observer = new IntersectionObserver(
(records) => {
const visible = records
.filter((r) => r.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visible.length) activeId.value = visible[0]!.target.id
},
{ root: ownScroller(), rootMargin: '-48px 0px -65% 0px', threshold: 0 }
)
headings.forEach((h) => observer!.observe(h))
}
onMounted(() => nextTick(observe))
watch(() => props.entries, () => nextTick(observe))
onBeforeUnmount(() => observer?.disconnect())
function goTo(id: string) {
activeId.value = id
const heading = document.getElementById(id)
if (!heading) return
const scroller = ownScroller()
if (!scroller) {
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
/* scroll the page itself — scrollIntoView would also shove the stack of
open pages sideways */
const top =
heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 18
scroller.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
}
</script>
<template>
<nav v-if="entries.length" ref="navEl" class="toc" :aria-label="t('toc.aria')">
<div class="toc-title">{{ t('toc.title') }}</div>
<ol>
<li v-for="entry in entries" :key="entry.id" :class="`level-${entry.level}`">
<a
href="#"
:class="{ active: activeId === entry.id }"
@click.prevent="goTo(entry.id)"
>
{{ entry.text }}
</a>
</li>
</ol>
</nav>
</template>