More things

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

View File

@@ -2,8 +2,9 @@
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
const { show: openSearch } = useSearch()
const { show: openSettings } = useSettings()
const { tabs, lastSlug } = useArticleTabs()
const { tabs, lastIdentity } = useArticleTabs()
const { user, isAdmin, signOut } = useAuth()
const { pending: waitingRequests, refresh: refreshPending } = usePendingRequests()
const { show: openSignIn } = useSignInPopup()
const { show: openProfile } = useProfileMenu()
const { t } = useI18n()
@@ -15,21 +16,43 @@ const router = useRouter()
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)
(the graph, the list, the editor, a missing article) and every article you
have open comes back into view, stacked as you left it. In view only when the
route names one of the open pages: a missing article reads like the reader
but holds none of them, so the button still turns back from there. */
const inReader = computed(() => tabAt(tabs.value, 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]
tabs.value.find((t) => t.identity === lastIdentity.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 */
/* How many requests are waiting is an admin's business: ask once the session
is known to be one — and when a session stops being one, the door goes blank
with the links — and again on every navigation, so the number on the door is
no older than the page under it. While an admin decides at the desk, the
desk itself keeps the count exact (see usePendingRequests). */
watch(
isAdmin,
() => {
if (import.meta.client) void refreshPending()
},
{ immediate: true }
)
watch(
() => route.path,
() => {
if (import.meta.client && isAdmin.value) void refreshPending()
}
)
/* the shortcut hint beside the search bar — refined on the client to the
device's chord ("/" opens the popup too, just not while a field holds
the caret) */
const kbdHint = ref('/')
onMounted(() => {
kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K'
@@ -100,6 +123,7 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
<NuxtLink
v-if="isAdmin"
to="/admin"
end
class="nav-link"
:title="t('sidebar.adminHint')"
>
@@ -109,6 +133,20 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
</svg>
<span class="hide-collapsed">{{ t('sidebar.admin') }}</span>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin/proposals"
class="nav-link sidebar-requests"
:title="waitingRequests ? t('sidebar.requestsWaiting', waitingRequests) : t('sidebar.requestsHint')"
>
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="m9 15 2 2 4-4" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.requests') }}</span>
<span v-if="waitingRequests" class="requests-count">{{ waitingRequests }}</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" />

View File

@@ -24,6 +24,9 @@ const emit = defineEmits<{
const route = useRoute()
const { t } = useI18n()
/* deleting is an admin's call: a member's changes to an article go through
proposals, and dropping the page outright is not among what they may do */
const { isAdmin } = useAuth()
const { open, hide } = useArticleActionsMenu()
const { open: langMenuOpen } = useLanguageMenu()
const { open: searchOpen } = useSearch()
@@ -106,22 +109,24 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
:editor-slug="editorSlug"
/>
<div class="kebab-sep" />
<template v-if="isAdmin">
<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>
<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>
</template>
</div>
</div>
</template>

View File

@@ -4,8 +4,10 @@
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.
truth — every page is a plain /wiki/<lang>/<slug> URL — and useArticleTabs
keeps the stack of pages, one page per article however many languages the
article speaks: turning one to another language turns this page over, it
does not add one to the row.
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
@@ -31,7 +33,7 @@ const { open: actionsMenuOpen } = useArticleActionsMenu()
const { collapsed } = useSidebar()
const activeTab = computed(() => tabAt(tabs.value, route.path))
const activeSlug = computed(() => activeTab.value?.slug ?? null)
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
const trackEl = ref<HTMLElement | null>(null)
@@ -44,8 +46,8 @@ function liesFlat() {
/** 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)
const identity = activeIdentity.value
return identity === null ? -1 : tabs.value.findIndex((t) => t.identity === identity)
})
/**
@@ -57,10 +59,10 @@ const openIndex = computed(() => {
async function alignToActive(behavior: ScrollBehavior = 'smooth') {
await nextTick()
const track = trackEl.value
const slug = activeSlug.value
if (!track || !slug || !liesFlat()) return
const identity = activeIdentity.value
if (!track || !identity || !liesFlat()) return
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`)
const page = track.querySelector<HTMLElement>(`[data-tab="${CSS.escape(identity)}"]`)
const i = Number(page?.dataset.index)
if (!page || !Number.isFinite(i)) return
@@ -77,10 +79,10 @@ async function alignToActive(behavior: ScrollBehavior = 'smooth') {
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[]>([])
/** The identities of the pages whose spines are in the dock. */
const waitingPages = ref<string[]>([])
/** Those pages, in the order they were opened. */
const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug)))
const waiting = computed(() => tabs.value.filter((t) => waitingPages.value.includes(t.identity)))
/**
* Fill the dock with the pages beyond the one in view whose spines have no
@@ -91,22 +93,24 @@ function measureDock() {
const track = trackEl.value
const open = openIndex.value
if (!track || open === -1 || !liesFlat()) {
waitingSlugs.value = []
waitingPages.value = []
return
}
const edge = track.getBoundingClientRect().right
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
const held = new Set(waitingSlugs.value)
const held = new Set(waitingPages.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 identity = page.dataset.tab
if (!identity || !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)
if (left > edge - 6) held.add(identity)
else if (left < edge - spine - 10) held.delete(identity)
}
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug)
waitingPages.value = tabs.value
.filter((t, i) => i > open && held.has(t.identity))
.map((t) => t.identity)
}
/* the row moves on scroll, so the dock is measured once a frame while it does */
@@ -121,7 +125,7 @@ function watchDock() {
onBeforeUnmount(() => cancelAnimationFrame(measuring))
/* turning to an article slides the book round to it */
watch(activeSlug, () => void alignToActive('smooth'))
watch(activeIdentity, () => 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))
@@ -162,18 +166,21 @@ watch(
{ 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))
function turnTo(identity: string) {
const tab = tabs.value.find((t) => t.identity === identity)
if (tab && identity !== activeIdentity.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
/* the page's × deletes the very version it holds — the document its address
asks for, which for a translation is its own — and closes the page */
async function removeArticle(identity: string) {
const tab = tabs.value.find((t) => t.identity === identity)
if (!tab) return
if (!window.confirm(t('book.deleteConfirm', { title: tab.title }))) return
try {
await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' })
forget(slug)
closeTab(slug)
await $fetch(`${api}/articles/slug/${encodeURIComponent(tab.slug)}`, { method: 'DELETE' })
forget(tab.slug)
closeTab(identity)
} catch {
window.alert(t('book.deleteFailed'))
}
@@ -185,7 +192,7 @@ 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
const current = tabAt(tabs.value, route.path)?.identity
if (current) closeTab(current)
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
@@ -202,18 +209,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
>
<div
v-for="tab in tabs"
:key="tab.slug"
:key="tab.identity"
class="book-chip"
:class="{ active: tab.slug === activeSlug }"
:class="{ active: tab.identity === activeIdentity }"
role="tab"
tabindex="0"
:aria-selected="tab.slug === activeSlug"
:aria-selected="tab.identity === activeIdentity"
:title="t('book.chipHint', { title: tab.title })"
@click="turnTo(tab.slug)"
@keydown.enter.prevent="turnTo(tab.slug)"
@click="turnTo(tab.identity)"
@keydown.enter.prevent="turnTo(tab.identity)"
>
<span class="chip-title">{{ tab.title }}</span>
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
<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>
@@ -224,28 +231,29 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
<ArticlePane
v-for="(tab, i) in tabs"
:key="tab.slug"
:key="tab.identity"
:identity="tab.identity"
:slug="tab.slug"
:index="i"
:active="tab.slug === activeSlug"
@activate="turnTo(tab.slug)"
@close="closeTab(tab.slug)"
@remove="removeArticle(tab.slug)"
:active="tab.identity === activeIdentity"
@activate="turnTo(tab.identity)"
@close="closeTab(tab.identity)"
@remove="removeArticle(tab.identity)"
/>
</div>
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
<div
v-for="tab in waiting"
:key="tab.slug"
:key="tab.identity"
class="dock-spine"
role="button"
tabindex="0"
:title="t('book.dockHint', { title: tab.title })"
@click="turnTo(tab.slug)"
@keydown.enter.prevent="turnTo(tab.slug)"
@click="turnTo(tab.identity)"
@keydown.enter.prevent="turnTo(tab.identity)"
>
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
<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>

View File

@@ -35,9 +35,7 @@ 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 href = computed(() => versionHref(identityOf(props.article), language.value))
const updated = computed(() => {
const d = props.article.updatedAt ? new Date(props.article.updatedAt) : null

View File

@@ -16,11 +16,22 @@ 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()
the session has gone while this draft was open. A member's save is not a
write at all — neither a change to a page nor a page the wiki does not have
yet: the API keeps either as a proposal for an admin (models/EditProposal.js
on the backend); only an admin's goes straight onto the page. */
const { authHeaders, isAdmin } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t } = useI18n()
/* saving without the admin role: every save is a request an admin answers, and
the editor says so rather than pretending to publish */
const memberWrite = computed(() => !isAdmin.value)
/* which request the editor is holding: a change to an existing page, or a new
one — the words it says the member differ, and a new page has nothing to view
until an admin approves it */
const creating = computed(() => props.mode === 'create')
const original = props.translateFrom ?? null
/* the version group the new document joins, shared by every translation */
const baseSlug = original ? original.baseSlug || original.slug : ''
@@ -238,16 +249,20 @@ function slugifyText(text: string): string {
.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 */
/* the address the article in hand lives at — its identity with the language of
the version in view in front. The title plays no part: a translation's
address is the article's identity whatever it is titled, and the canonical
version's identity *is* its slug, so guessing from the title would send a
retitle off to a new address (and onto a twin article's, when a name was
taken and the slug carries a -2). Only an article the wiki has not been
given yet can be previewed from its title. */
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}`
const lang = activeDraft()?.language ?? props.article?.language
return versionHref(identity, lang)
}
if (original) return `/wiki/${language.value}/${baseSlug}`
return `/wiki/${slugifyText(title.value) || '…'}`
if (original) return versionHref(baseSlug, language.value)
return versionHref(slugifyText(title.value) || '…', language.value)
})
const savedSlug = props.article?.slug ?? ''
@@ -362,6 +377,41 @@ onBeforeUnmount(() => {
/* submit ----------------------------------------------------------- */
const saving = ref(false)
const errorMsg = ref('')
/* a member's save that the API kept as a proposal: a popup confirms the request
and dismisses the editor with it — the sent draft is not left on screen to be
submitted twice. The next edit of the same thing revises the waiting request
rather than filing a second one, which is what `sentProposalId` carries. */
const sentOpen = ref(false)
/* a filed create answers with the request's id: the same draft saved again
revises that request, even once its title — and so the address it asks for —
has moved on since */
const sentProposalId = ref('')
/* where leaving a sent request leads: the very version that was sent, read on
the reader's side of the wiki — the text an admin has not touched yet.
Nothing to read for a page the wiki has not been given: a create lands on
the translation it joins, or on the search page. */
const sentHref = computed(() => {
if (isVersioned.value) {
const d = activeDraft()
if (!d?.slug && !savedSlug) return ''
return versionHref(identity, d?.language ?? props.article?.language)
}
return savedSlug ? versionHref(savedSlug, props.article?.language) : ''
})
function openSentPopup() {
cheatsheet.value = false // popups don't stack in this app
sentOpen.value = true
}
/* the popup is the editor's last word on a filed save: dismissing it — button,
esc or backdrop — is what leaves, taking the reader back to what was sent */
async function closeSentPopup() {
sentOpen.value = false
const target = sentHref.value || (original ? versionHref(baseSlug, original.language) : '')
await router.push(target || '/')
}
function parseTags(text: string): string[] {
return text
@@ -370,13 +420,23 @@ function parseTags(text: string): string[] {
.filter(Boolean)
}
/* the answer as `$fetch` gives it, status included: a 202 means the API filed
the change as a proposal rather than writing it, and the request it filed
comes back along with it so this draft can be sent to the same one again */
async function request(body: Record<string, unknown>, method: 'POST' | 'PUT', url: string) {
return $fetch<ArticleMeta>(url, { method, body, headers: authHeaders() })
const res = await $fetch.raw<ArticleMeta & { proposal?: { _id?: string } }>(url, {
method,
body,
headers: authHeaders(),
})
return { article: res._data, status: res.status, proposalId: res._data?.proposal?._id ?? '' }
}
async function save() {
errorMsg.value = ''
if (saving.value) return
/* nothing to send while the "request sent" popup is up: this draft already
is with the admins, whatever Ctrl+S might otherwise re-submit */
if (saving.value || sentOpen.value) return
saving.value = true
try {
if (isVersioned.value) {
@@ -384,7 +444,9 @@ async function save() {
brand-new ones join the article through its identity */
const pendingDrafts = versionList.value.filter(draftChanged)
if (!pendingDrafts.length) {
await router.push(slugPreview.value)
/* nothing to send: leave for the page this version is actually read at
(the identity and the language say where — no guessing from titles) */
await router.push(versionHref(identity, activeLang.value))
return
}
const missing = pendingDrafts.find((d) => !draftCurrent(d).title.trim())
@@ -395,6 +457,7 @@ async function save() {
}
let savedActive: ArticleMeta | null = null
let lastSaved: ArticleMeta | null = null
let proposalSent = false
for (const d of pendingDrafts) {
const cur = draftCurrent(d)
const body = {
@@ -404,18 +467,33 @@ async function save() {
/* a version written here joins the article it belongs to */
...(d.slug ? {} : { baseSlug: identity, language: d.language }),
}
const saved = d.slug
/* an existing version answers with the write — or, for a member, with
the 202 of a proposal filed; a brand-new one is always published */
const res = d.slug
? await request(body, 'PUT', `${api}/articles/slug/${encodeURIComponent(d.slug)}`)
: await request(body, 'POST', `${api}/articles`)
d.slug = saved.slug
if (res.status === 202) {
proposalSent = true
d.saved = cur // the draft is filed — with the admins now, not dirty here
continue
}
const saved = res.article
// a retitle moved the document; the answer carries where it lives now
d.slug = saved!.slug
d.saved = cur
lastSaved = saved
if (d.language === activeLang.value) savedActive = saved
}
/* a member's edits are with the admins now: the popup says so where a
published save would have navigated, and closing it ends the editor */
if (proposalSent) {
openSentPopup()
return
}
/* 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) : '/')
await router.push(target ? versionHref(identityOf(target), target.language) : '/')
return
}
@@ -430,15 +508,27 @@ async function save() {
/* a translation joins the article it translates rather than standing alone */
...(original ? { baseSlug } : {}),
tags: parseTags(tagsText.value),
/* a create the API already filed: saving the draft again revises that
request, whichever address it asked for when it was first sent */
...(creating.value && sentProposalId.value ? { proposalId: sentProposalId.value } : {}),
}
const url = creating.value
? `${api}/articles`
: `${api}/articles/slug/${encodeURIComponent(savedSlug)}`
const { article: saved, status, proposalId } = await request(
body,
creating.value ? 'POST' : 'PUT',
url
)
if (status === 202) {
markSaved() // the draft is filed — the leave guard stays quiet on the way out
if (proposalId) sentProposalId.value = proposalId
openSentPopup()
return
}
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))
await router.push(versionHref(identityOf(saved!), saved!.language))
} 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
@@ -497,9 +587,7 @@ async function discard() {
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 })
: '/'
const target = back ? versionHref(identity, back.language) : '/'
await router.push(target)
return
}
@@ -509,7 +597,11 @@ async function discard() {
content.value = base.content
errorMsg.value = ''
await router.push(
props.mode === 'create' ? (original ? versionHref(baseSlug, original) : '/') : `/wiki/${savedSlug}`
props.mode === 'create'
? original
? versionHref(baseSlug, original.language)
: '/'
: versionHref(savedSlug, props.article?.language)
)
}
@@ -535,6 +627,10 @@ function onTabKeydown(e: KeyboardEvent) {
// Ctrl/Cmd+S saves; Esc closes the topmost popup first, then leaves fullscreen
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (sentOpen.value) {
void closeSentPopup() // dismisses the popup and leaves the editor
return
}
if (addOpen.value) {
addOpen.value = false
return
@@ -564,7 +660,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
<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>
<h1>{{ creating ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
<div class="editor-head-actions">
<button
ref="discardButtonEl"
@@ -581,8 +677,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
{{ t('editor.discard') }}
</button>
<NuxtLink
v-if="mode === 'edit' && savedSlug"
:to="versionHref(identity, { language: article?.language, slug: savedSlug })"
v-if="props.mode === 'edit' && savedSlug"
:to="versionHref(identity, activeLang)"
class="btn btn-ghost"
>
{{ t('common.cancel') }}
@@ -590,13 +686,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
<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') }}
: memberWrite
? t('editor.submitProposal')
: original
? t('editor.publishTranslation')
: creating
? t('editor.publishArticle')
: dirtyCount > 1
? t('editor.saveVersions', { n: dirtyCount })
: t('editor.saveChanges') }}
</button>
</div>
</div>
@@ -609,11 +707,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
</svg>
<span>
{{ t('editor.translating') }}
<NuxtLink :to="versionHref(baseSlug, original)">{{ original.title }}</NuxtLink>
<NuxtLink :to="versionHref(baseSlug, original.language)">{{ original.title }}</NuxtLink>
{{ t('editor.translatingTail') }}
</span>
</div>
<!-- a member writes nothing onto the wiki themselves, a page nor a new one:
the editor says so up front, and once a save has filed a request the
popup confirms it — closing that popup is what leaves the editor, so
the button cannot be pressed twice on an already-sent draft -->
<div v-if="memberWrite" class="banner banner-info">
<span class="glyph" aria-hidden="true">✎</span>
<span>{{ creating ? t('editor.proposalNoticeCreate') : t('editor.proposalNotice') }}</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">
@@ -782,5 +889,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
@confirm="discard"
@cancel="closeDiscardPrompt"
/>
<ProposalSentModal :open="sentOpen" :creating="creating" @close="closeSentPopup" />
</form>
</template>

View File

@@ -91,7 +91,7 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
<NuxtLink
v-for="v in existing"
:key="v.slug"
:to="versionHref(identity, v)"
:to="versionHref(identity, v.language)"
class="lang-menu-item"
:lang="v.language"
role="menuitem"

View File

@@ -1,15 +1,20 @@
<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.
/* One page of the open book: one article, in whichever language it is turned
to. 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. Turning the article to another language changes the text on
this page and nothing else: it is the same page of the book, read again.
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<{
/** the article this page holds — its canonical slug */
identity: string
/** the version in view, as the library files it */
slug: string
index: number
active: boolean
@@ -32,12 +37,20 @@ 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 }))
/* the slug the editor answers to: the identity when the canonical version is
still around, this document's own slug when only translations of it remain */
const editorSlug = computed(() =>
versions.value.some((v) => v.slug === identity.value) ? identity.value : props.slug
versions.value.some((v) => v.slug === props.identity) ? props.identity : props.slug
)
/* turned to another language, the page begins again at the top of the text
it now holds — the article is the same, its words are not */
const scrollEl = ref<HTMLElement | null>(null)
watch(
() => props.slug,
(to, from) => {
if (from) scrollEl.value?.scrollTo({ top: 0 })
}
)
const updated = computed(() => {
@@ -61,7 +74,7 @@ function topicLabel(tag: string) {
<section
class="book-pane"
:class="{ active }"
:data-slug="slug"
:data-tab="identity"
:data-index="index"
:style="{ '--i': index }"
:aria-label="t('pane.aria', { title })"
@@ -91,7 +104,7 @@ function topicLabel(tag: string) {
</div>
<div class="pane-body" :inert="active ? null : ''">
<div class="pane-scroll">
<div ref="scrollEl" class="pane-scroll">
<div v-if="failed && !article" class="empty">
<div class="glyph">!</div>
<h3>{{ t('pane.readFailTitle') }}</h3>

View File

@@ -1,14 +1,16 @@
<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. */
popup). It holds the account itself: the profile picture, the username, the
password, the interface language the account reads the wiki in, 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 { user, signOut, updateUsername, changePassword, setAvatar, updateLocale } = useAuth()
const { t, locale } = useI18n()
const { choose } = useAppLocale()
const route = useRoute()
const panelEl = ref<HTMLElement | null>(null)
@@ -166,6 +168,24 @@ function leave() {
hide()
}
/* --- the interface language ---
Turn the language Mathew speaks in. The device's own choice (the cookie
setLocale keeps) lands at once, and the account saves it 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. */
const language = ref('en')
async function setLanguage(code: string) {
if (language.value === code) return
language.value = code
await choose(code)
try {
await updateLocale(code)
} catch {
/* the device-level switch stands on its own */
}
}
/* sync the form from the account whenever the popup opens */
watch(
open,
@@ -178,6 +198,7 @@ watch(
username.value = user.value?.username ?? ''
currentPassword.value = ''
newPassword.value = ''
language.value = locale.value
avatarBusy.value = false
avatarError.value = ''
nameError.value = ''
@@ -340,6 +361,30 @@ watch(
<p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p>
</section>
<!-- the interface language -->
<section class="profile-group" aria-labelledby="profile-language">
<div class="profile-group-head">
<span id="profile-language" class="profile-group-title">{{ t('profile.language') }}</span>
<span class="profile-group-hint">{{ t('profile.languageHint') }}</span>
</div>
<div class="theme-switch" role="radiogroup" :aria-label="t('profile.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>
<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">

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
/* One request on the dashboard's list: who wants what, which parts of the page
it touches, and — while it waits — the buttons that answer it. Opening the
details lays the change out as a pair of pages: how the article read when the
request was sent, and how it would read with the change applied. A request
for a page the wiki does not have yet is laid out the same way against the
address it asks for, which is empty until somebody approves it. */
export interface ProposalArticle {
id: string
slug: string
identity: string
title: string
language: string
content: string
tags: string[]
}
export interface AdminProposal {
id: string
/** a change to a page, or a request for one that does not exist yet */
kind: 'edit' | 'create'
status: 'pending' | 'approved' | 'rejected'
createdAt: string
decidedAt: string | null
/** what a creation asks the page to live at, and which one it joins */
slug: string | null
baseSlug: string | null
proposed: { title: string; content: string; tags: string[]; language: string }
base: { title: string; content: string; tags: string[] }
proposer: { username: string; avatar: string }
decidedByUsername: string | null
article: ProposalArticle | null
}
const props = defineProps<{
proposal: AdminProposal
expanded: boolean
/** another row's decision is in flight: the buttons wait */
busy: boolean
}>()
const emit = defineEmits<{
toggle: []
decide: [action: 'approve' | 'reject']
}>()
const { t, locale } = useI18n()
/* "12 Mar 2026", in the interface language and read in UTC — the server and
the browser may run on different clocks, and the first render has to say
the same day on both. */
function onDate(iso: string | null): string {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(locale.value, {
timeZone: 'UTC',
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
const statusLabel = computed(() => {
if (props.proposal.status === 'approved') return t('admin.statusApproved')
if (props.proposal.status === 'rejected') return t('admin.statusRejected')
return t('admin.statusPending')
})
/* asking for a page the wiki does not have yet, rather than changing one */
const creating = computed(() => props.proposal.kind === 'create')
/* who is said to want what, in the tense the request's answer leaves behind */
const summaryLead = computed(() => {
const waiting = props.proposal.status === 'pending'
if (creating.value) return t(waiting ? 'admin.wantsToCreate' : 'admin.wantedToCreate')
return t(waiting ? 'admin.wantsToChange' : 'admin.wantedToChange')
})
const articleHref = computed(() => {
const a = props.proposal.article
return a ? versionHref(a.identity, a.language) : ''
})
/* where a creation would live: the address it asks for, in the shape a reader
follows — the language it is written in, over the article it names (a fresh
one names itself) */
const requestedPath = computed(() => {
const p = props.proposal
if (!p.slug) return ''
return versionHref(p.baseSlug || p.slug, p.proposed.language)
})
/* which parts of the page the request touches — enough to judge the row
without opening it; the text of the change waits in the details */
const changedFields = computed(() => {
const p = props.proposal
if (creating.value) return t('admin.newArticle')
const fields: string[] = []
if (p.base.title !== p.proposed.title) fields.push(t('admin.fieldTitle'))
if (p.base.content !== p.proposed.content) fields.push(t('admin.fieldContent'))
if (p.base.tags.join('\u0000') !== p.proposed.tags.join('\u0000')) fields.push(t('admin.fieldTags'))
return fields.join(' · ') || t('admin.fieldNone')
})
/* the article moved after the request was sent: applying the change would
overwrite whatever was written since, so the row warns about it. A request
for a new page cannot be overtaken — and once it is granted, the article it
brought about agrees with it by definition. */
const stale = computed(() => {
const p = props.proposal
if (creating.value || !p.article) return false
return p.article.title !== p.base.title || p.article.content !== p.base.content
})
</script>
<template>
<div class="proposal-row">
<span class="account-avatar" aria-hidden="true">
<img v-if="proposal.proposer.avatar" :src="proposal.proposer.avatar" alt="" />
<template v-else>{{ proposal.proposer.username.charAt(0).toUpperCase() }}</template>
</span>
<span class="proposal-summary">
<strong class="proposal-proposer">{{ proposal.proposer.username }}</strong>
{{ summaryLead }}
<!-- a granted creation has an article to point at; a waiting one has the
title it asked for and no page to walk to yet -->
<NuxtLink v-if="proposal.article" :to="articleHref" class="proposal-article-link">
{{ proposal.article.title }}
</NuxtLink>
<strong v-else-if="creating" class="proposal-new">{{ proposal.proposed.title }}</strong>
<em v-else>{{ t('admin.articleGone') }}</em>
<span v-if="creating" class="proposal-kind">{{ t('admin.newArticle') }}</span>
</span>
<span class="proposal-status" :class="`status-${proposal.status}`">{{ statusLabel }}</span>
<span class="proposal-date">{{ onDate(proposal.createdAt) }}</span>
<span v-if="proposal.status !== 'pending'" class="proposal-decided">
{{
t('admin.decidedBy', {
action: proposal.status === 'approved' ? t('admin.actionApproved') : t('admin.actionRejected'),
username: proposal.decidedByUsername ?? '—',
date: onDate(proposal.decidedAt),
})
}}
</span>
<span class="proposal-actions">
<button
type="button"
class="btn btn-ghost btn-sm proposal-details-btn"
:aria-expanded="String(expanded)"
@click="emit('toggle')"
>
{{ expanded ? t('admin.hideDetails') : t('admin.details') }}
</button>
<template v-if="proposal.status === 'pending'">
<button
type="button"
class="btn btn-danger btn-sm"
:disabled="busy"
@click="emit('decide', 'reject')"
>
{{ t('admin.reject') }}
</button>
<button
type="button"
class="btn btn-approve btn-sm"
:disabled="busy"
@click="emit('decide', 'approve')"
>
{{ t('admin.approve') }}
</button>
</template>
</span>
</div>
<div v-if="expanded" class="proposal-diff">
<div class="proposal-diff-col">
<h4 class="diff-heading is-before">
{{ creating ? t('admin.asNewPage') : t('admin.asSent') }}
</h4>
<!-- a creation is asked for an address the wiki has nothing on: the
reviewer reads where the page would go instead of a page that is
not there -->
<template v-if="creating">
<p class="diff-title"><code>{{ requestedPath }}</code></p>
<div class="diff-body">
<p class="diff-absent">{{ t('admin.nothingHereYet') }}</p>
</div>
</template>
<template v-else>
<p class="diff-title"><del>{{ proposal.base.title }}</del></p>
<p v-if="proposal.base.tags.length" class="diff-tags">
<span v-for="tag in proposal.base.tags" :key="tag" class="diff-tag">{{ tag }}</span>
</p>
<div class="diff-body">
<MarkdownView
:content="proposal.base.content || '*…*'"
:namespace="`proposal-${proposal.id}-base`"
/>
</div>
</template>
</div>
<div class="proposal-diff-col">
<h4 class="diff-heading is-after">{{ t('admin.asProposed') }}</h4>
<p class="diff-title" :lang="proposal.proposed.language">
<strong>{{ proposal.proposed.title }}</strong>
</p>
<p v-if="proposal.proposed.tags.length" class="diff-tags">
<span v-for="tag in proposal.proposed.tags" :key="tag" class="diff-tag">{{ tag }}</span>
</p>
<div class="diff-body">
<MarkdownView
:content="proposal.proposed.content || '*…*'"
:namespace="`proposal-${proposal.id}-proposed`"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/* The member's "request sent" confirmation, in ConfirmModal's shape: teleported
overlay, dismissed by the backdrop or its one button (Esc is left to the
caller, which orders its popups). It says the save went to the admins as a
proposal — and dismissing it is what walks out of the editor, so a sent
draft never sits there waiting to be submitted a second time. */
const props = defineProps<{
open: boolean
/* a request for a page the wiki does not have yet says different words */
creating?: boolean
}>()
const { t } = useI18n()
/* the wording lives here rather than in withDefaults static defaults so it
follows the interface language as it changes */
const titleText = computed(() => t('editor.proposalSentTitle'))
const messageText = computed(() =>
props.creating ? t('editor.proposalSentCreate') : t('editor.proposalSent')
)
const doneText = computed(() => t('common.done'))
const emit = defineEmits<{ close: [] }>()
const doneButtonEl = ref<HTMLButtonElement | null>(null)
/* land on the one button when the popup opens, so Enter always means
"seen it" — the way out of the editor */
watch(
() => props.open,
async (isOpen) => {
if (!isOpen) return
await nextTick()
doneButtonEl.value?.focus()
},
{ flush: 'post' }
)
</script>
<template>
<Teleport to="body">
<Transition name="confirm-popup">
<div v-if="open" class="confirm-overlay" @click.self="emit('close')">
<div class="confirm-modal" role="dialog" aria-modal="true" :aria-label="titleText">
<div class="sent-check" aria-hidden="true">✓</div>
<h2 class="confirm-title">{{ titleText }}</h2>
<p class="confirm-message">{{ messageText }}</p>
<div class="confirm-actions">
<button
ref="doneButtonEl"
type="button"
class="btn btn-primary"
@click="emit('close')"
>
{{ doneText }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -19,7 +19,7 @@ const others = computed(() =>
<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>
<NuxtLink :to="viewedHref(v)">{{ v.title }}</NuxtLink>
</li>
</ul>
</nav>

View File

@@ -1,14 +1,14 @@
<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
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
or "/" anywhere but a text field) open 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 { th, locale } = useAppLocale()
const { open, show, hide, toggle } = useSearch()
const { hide: hideSettings } = useSettings()
const { hide: hideProfile } = useProfileMenu()
@@ -59,11 +59,16 @@ watch(trimmed, () => {
async function runSearch() {
const seq = ++requestSeq
try {
const res = await $fetch<ArticleMeta[]>(`${api}/articles`, {
query: { q: trimmed.value },
/* the popup never shows more than MAX_RESULTS, so that is what it asks
the shelf for rather than every match it has; `prefer` asks each answer
to stand with the reader's own language when the article speaks it, and
`preferFirst` ranks the articles that truly speak it above the ones
shown only through the fallback */
const page = await $fetch<{ articles: ArticleMeta[] }>(`${api}/articles`, {
query: { q: trimmed.value, limit: MAX_RESULTS, prefer: locale.value, preferFirst: '1' },
})
if (seq !== requestSeq) return // a newer search already won
results.value = res.slice(0, MAX_RESULTS)
results.value = page.articles
failed.value = false
} catch {
if (seq !== requestSeq) return
@@ -86,9 +91,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
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 !== '/') {
/* "/" also opens the popup anywhere — except while typing in a field */
if (e.key === '/' && !open.value) {
const t = e.target
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
e.preventDefault()
@@ -105,7 +109,7 @@ onBeforeUnmount(() => {
/* 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 })
return versionHref(identityOf(a), articleLanguage(a))
}
function openArticle(article: ArticleMeta) {
@@ -250,7 +254,7 @@ function highlight(text: string): Part[] {
<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">
<NuxtLink :to="viewedHref(v)" class="search-result">
<span class="sr-title">{{ v.title }}</span>
</NuxtLink>
</li>

View File

@@ -1,23 +1,19 @@
<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. */
dark theme switch and the accent colour — which drive the data-theme and
data-accent attributes that the pre-paint script in nuxt.config.ts
restores from localStorage. The interface language lives in the profile
popup instead, since 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 { t } = useI18n()
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 = [
@@ -38,7 +34,7 @@ const accents = [
{ value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' },
] as const
/* read the live theme, accent + language whenever the popup opens so the
/* read the live theme + accent whenever the popup opens so the
switches are in sync */
watch(
open,
@@ -51,7 +47,6 @@ watch(
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()
},
@@ -79,23 +74,6 @@ function setAccent(value: string) {
}
}
/* 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()
@@ -198,29 +176,6 @@ watch(
</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>