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

@@ -1,3 +1,32 @@
@use 'mixins' as *;
// ---------- The dashboard's two tabs: the requests and the accounts ----------
// The same segmented control the sign-in popup and the settings switch wear
// (the mixins in _mixins.scss); the panels they open are the two sections.
.admin-tabs { @include segmented; margin-bottom: 22px; }
.admin-tab {
@include segmented-tab(7px 14px, 0.84rem);
display: inline-flex;
align-items: center;
gap: 7px;
}
// how many requests are still waiting, so the pile is visible even while the
// admin is reading the accounts — the tally's shape from the sidebar's count
.admin-tab-count {
min-width: 18px;
height: 18px;
padding: 0 5px;
display: inline-grid;
place-items: center;
border-radius: 9px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: 0.7rem;
font-weight: 700;
}
// ---------- Admin dashboard: the list of accounts ----------
// One row per account, read left to right: who, what they may do, when they
// arrived, and the one thing an admin can change about them.

View File

@@ -3,11 +3,68 @@
// ---------- Articles index ----------
.topic-tally { font-size: 0.85rem; color: var(--muted); }
.topic-dropdown {
@include dropdown-panel;
left: 0;
right: 0;
max-height: 280px;
// ---------- Folders: the shelf filed by topic ----------
.folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.folder-card {
display: flex;
align-items: center;
gap: 12px;
padding: 15px 18px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: inherit;
box-shadow: var(--shadow-sm);
transition: transform 0.18s ease, border-color 0.18s, box-shadow 0.18s;
svg { flex: none; color: var(--accent); opacity: 0.85; transition: opacity 0.15s; }
&:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
box-shadow: var(--shadow-lg);
svg { opacity: 1; }
}
&:focus-visible { box-shadow: var(--ring); }
}
.folder-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
// the facet tally lowercases every topic; folders wear their names capitalized
text-transform: capitalize;
}
.folder-count { flex: none; margin-left: auto; }
// the folder grid's loading row: a shorter skeleton than an article card's
.folder-skeleton { height: 60px; border-radius: 14px; @include shimmer; }
// closing a folder: the quiet way back onto the topics
.back-link {
display: inline-flex;
align-items: center;
gap: 7px;
margin-bottom: 14px;
color: var(--muted);
font-size: 0.85rem;
font-weight: 650;
text-decoration: none;
transition: color 0.15s;
&:hover { color: var(--accent); }
}
.topic-option {
@@ -18,45 +75,6 @@
&:hover, &.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
}
.topic-dropdown-none {
padding: 8px 12px;
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.topic-search-wrap {
position: relative;
margin-bottom: 14px;
svg {
position: absolute;
left: 14px;
top: 50%;
translate: 0 -50%;
color: var(--faint);
pointer-events: none;
}
}
.topic-search {
width: 100%;
height: 40px;
padding: 0 14px 0 40px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
font-size: 0.9rem;
transition: border-color 0.15s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
}
.topic-count {
padding: 1px 7px;
border-radius: 999px;

View File

@@ -7,6 +7,16 @@
gap: 20px;
}
// the shelf's last line: reaching it brings the next batch of cards in, and
// while that batch is on its way it says so
.shelf-sentinel {
display: grid;
place-items: center;
padding-top: 24px;
color: var(--faint);
font-size: 0.85rem;
}
.article-card {
display: flex;
flex-direction: column;

View File

@@ -90,6 +90,11 @@
// editor body
.editor-body {
// One height for the writing field and the preview side by side: each holds
// its text in a box of this height and scrolls it inside, so a long article
// never makes the editor taller than the field the writer is looking at.
--editor-pane-h: 480px;
border: 1px solid var(--border);
border-radius: 15px;
background: var(--bg-elev);
@@ -141,6 +146,7 @@
.editor-grid {
display: grid;
grid-template-columns: 1fr 1fr;
height: var(--editor-pane-h);
&.mode-write {
grid-template-columns: 1fr;
@@ -158,11 +164,11 @@
textarea.md-input {
display: block;
width: 100%;
min-height: 480px;
height: 100%;
padding: 22px;
border: 0;
background: transparent;
resize: vertical;
resize: none;
font-size: 0.9rem;
line-height: 1.75;
tab-size: 2;
@@ -176,13 +182,24 @@ textarea.md-input {
border-left: 1px solid var(--border);
padding: 22px 26px;
min-width: 0;
overflow-y: auto;
// and reaching the end of a long preview doesn't start scrolling the page
overscroll-behavior: contain;
overflow-wrap: break-word;
}
.preview-label { @include eyebrow; margin-bottom: 14px; }
@media (max-width: 900px) {
.editor-grid { grid-template-columns: 1fr; }
.editor-grid {
grid-template-columns: 1fr;
height: auto;
}
// stacked, the two panes keep the height the split gives them — each still
// scrolls its own text rather than stretching the editor
.write-pane,
.preview-pane { height: var(--editor-pane-h); }
.preview-pane { border-left: 0 !important; border-top: 1px solid var(--border); }
}
@@ -265,6 +282,21 @@ textarea.md-input {
@include popup-anim('confirm-popup', '.confirm-modal', 0.18s, transform 0.18s ease, 10px);
// sent-request popup — the discard popup's shape in a success temper: it
// confirms a proposal filed with the admins, and closing it ends the edit
.sent-check {
display: grid;
place-items: center;
width: 46px;
height: 46px;
margin-bottom: 14px;
border-radius: 50%;
background: color-mix(in srgb, var(--success) 14%, transparent);
color: var(--success);
font-size: 1.3rem;
font-weight: 800;
}
// fullscreen editor
.editor-body.is-fullscreen {
position: fixed;
@@ -278,9 +310,10 @@ textarea.md-input {
.editor-toolbar { flex: 0 0 auto; }
.editor-grid { flex: 1; min-height: 0; }
.editor-grid { flex: 1; height: auto; min-height: 0; }
.write-pane, .preview-pane { min-height: 0; overflow-y: auto; }
// the screen decides how tall a pane is here, not --editor-pane-h
.write-pane, .preview-pane { height: auto; min-height: 0; overflow-y: auto; }
textarea.md-input { height: 100%; min-height: 0; resize: none; }
}

View File

@@ -8,10 +8,24 @@
inset: -120px -20% auto -20%;
height: 480px;
pointer-events: none;
background:
radial-gradient(38% 60% at 30% 30%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 70%),
radial-gradient(34% 55% at 72% 22%, color-mix(in srgb, var(--accent-2) 14%, transparent), transparent 70%);
filter: blur(30px);
}
}
// the index page hangs its hero off the middle of the reading surface: only
// `/` carries `.is-home`, so the stretch below stays here and the other pages
// that share `.home-main` (the shelf, the two admin desks) are untouched.
// `.app-main` already owns the viewport's height (flex: 1 beside the sidebar
// on desktop, under the top bar on mobile); the container grows into it and
// centres the page's block in what's left.
.app-main:has(.home-main.is-home) {
display: flex;
flex-direction: column;
.container {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
}
}
@@ -88,6 +102,18 @@
&:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
}
// the hero box opens the search popup (the Ctrl/⌘+K one) instead of taking
// text, so it is a button wearing the search bar: the label sits where typed
// text would, in the placeholder's colour
.search-input.search-trigger {
font: inherit;
text-align: left;
cursor: pointer;
color: var(--faint);
&:hover { border-color: var(--accent); }
}
.search-kbd {
position: absolute;
right: 16px;

View File

@@ -183,6 +183,15 @@
min-height: 39px; // 8+8 padding + 14.4px x 1.6 label line
}
// the waiting count is not carried off with the labels: pinned to the
// icon's corner, it stays a dot on the rail
.sidebar-requests .requests-count {
position: absolute;
top: 2px;
right: 2px;
margin-left: 0;
}
.sidebar-nav .btn {
min-height: 35px; // 7+7 padding + 13.12px x 1.6 label line
}
@@ -211,13 +220,14 @@
html.sidebar-collapsed .hide-collapsed { display: none; transition: none; }
}
// ---------- "Open" articles: the count beside the button that shows them ----------
// ---------- Sidebar tallies: articles open, requests waiting ----------
.sidebar-open.is-current {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.tabs-count {
.tabs-count,
.requests-count {
margin-left: auto;
min-width: 18px;
height: 18px;
@@ -232,6 +242,10 @@
font-variant-numeric: tabular-nums;
}
// the waiting pile is the very reason to open the desk, so its number stays
// on the collapsed rail — the link is the frame it rides the corner of
.sidebar-requests { position: relative; }
.logo {
display: flex;
align-items: center;

View File

@@ -3,11 +3,13 @@
// ---------- Profile popup ----------
// The account row in the sidebar opens it; it draws and closes in the same
// shape as the settings popup. Holds the profile picture (a data URL kept on
// the account), the username, the password, and the way out.
// the account), the username, the password, the interface language, and the
// way out. Cut wider than the settings sheet so the language's five choices
// fit in one row, and taller so the sections need no scrolling.
.profile-overlay { @include overlay(120); }
.profile-modal {
@include sheet(460px, min(620px, calc(100vh - 110px)));
@include sheet(540px, min(720px, calc(100vh - 110px)));
&:focus, &:focus-visible { outline: none; }
}

View File

@@ -0,0 +1,214 @@
// ---------- Admin dashboard: the edit requests members have sent in ----------
// One entry per request: who wants what, which parts of the page it touches,
// the reviewers' buttons while it waits — and, opened up, the change itself
// laid out as two pages side by side.
.proposal-list {
display: grid;
gap: 10px;
margin: 0 0 4px;
padding: 0;
list-style: none;
}
.proposal-entry {
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
// a waiting request is the accent one, so the pile reads at a glance
&.is-pending { border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); }
// decided entries recede: the record stays, the call is already made
&.is-approved,
&.is-rejected { background: color-mix(in srgb, var(--surface-2) 45%, var(--surface)); }
}
.proposal-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.proposal-summary {
flex: 1;
min-width: 0;
font-size: 0.9rem;
color: var(--muted);
.proposal-proposer { color: var(--text); font-weight: 650; }
.proposal-article-link {
color: var(--accent);
font-weight: 650;
text-decoration: none;
&:hover { text-decoration: underline; text-underline-offset: 2px; }
}
// a requested page has no address to follow yet: its title is written as
// plain text, not as a link to something that is not there
.proposal-new { color: var(--text); font-weight: 650; }
}
// the mark on a request for a page the wiki does not have: dashed rather than
// filled, because the page is only a promise until somebody approves it
.proposal-kind {
flex: none;
padding: 2px 9px;
border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border));
border-radius: 999px;
color: var(--accent);
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
.proposal-status {
flex-shrink: 0;
padding: 3px 11px;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
&.status-pending {
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
}
&.status-approved {
background: color-mix(in srgb, var(--success) 14%, transparent);
color: var(--success);
}
&.status-rejected {
background: color-mix(in srgb, var(--danger) 12%, transparent);
color: var(--danger);
}
}
.proposal-date { flex-shrink: 0; font-size: 0.78rem; color: var(--faint); }
.proposal-decided { flex-shrink: 0; font-size: 0.76rem; font-style: italic; color: var(--faint); }
.proposal-actions {
margin-left: auto;
flex: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
// applying a proposal is the yes of the pair — solid, the way the wiki's
// other primary asks are; refusing stays the quiet ghost beside it
.btn-approve {
background: var(--success);
color: #fff;
box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--success) 65%, transparent);
&:hover { filter: brightness(1.08); }
}
// the page moved after the request was sent — approving overwrites that
.proposal-stale { font-size: 0.78rem; font-style: italic; color: var(--danger); }
.proposal-diff {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
}
.proposal-diff-col { min-width: 0; }
.diff-heading {
margin: 0 0 10px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
&.is-before { color: var(--faint); }
&.is-after { color: var(--success); }
}
.diff-title {
margin: 0 0 8px;
font-size: 1rem;
color: var(--text);
del { color: var(--faint); text-decoration-color: color-mix(in srgb, var(--danger) 60%, transparent); }
}
.diff-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0 0 12px;
}
.diff-tag {
padding: 2px 9px;
border-radius: 7px;
background: var(--surface-2);
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
}
// the two pages: tall articles scroll inside their frame rather than
// stretching the dashboard out without end
.diff-body {
max-height: 420px;
overflow-y: auto;
padding: 2px 16px 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-elev);
}
// the half of a creation's diff where a page would go: the frame the other
// side fills with text, holding only the words that say it is not written yet
.diff-absent {
margin: 16px 0;
font-size: 0.85rem;
font-style: italic;
color: var(--faint);
}
// the fold over the decided requests: a heading you open, nothing louder
.proposal-history-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 20px 0 12px;
padding: 4px 2px;
border: none;
background: none;
color: var(--muted);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: -0.01em;
cursor: pointer;
&:hover { color: var(--text); }
svg { transition: transform 0.18s ease; }
svg.turned { transform: rotate(90deg); }
}
// the request row's skeleton, in the shape of one entry
.proposal-skeleton { height: 74px; }
@media (max-width: 720px) {
.proposal-diff { grid-template-columns: 1fr; }
.proposal-actions { margin-left: 0; }
}

View File

@@ -32,8 +32,9 @@
.settings-group-hint { font-size: 0.8rem; color: var(--muted); }
// segmented light / dark switch
.theme-switch { @include segmented; }
// segmented control: the settings' light / dark switch and the profile's
// language row; wraps so a narrow screen never clips the last choice
.theme-switch { @include segmented; flex-wrap: wrap; }
.theme-option {
display: inline-flex;

View File

@@ -16,6 +16,14 @@
h3 { margin: 0 0 6px; color: var(--text); font-size: 1.15rem; }
p { margin: 0 auto 22px; max-width: 42ch; font-size: 0.93rem; }
// two ways forward from a dead end, side by side
.empty-actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
}
}
.banner {
@@ -37,6 +45,32 @@
border-radius: 5px;
font-size: 0.85em;
}
// a rule the same size and shape, in another temper: green once a change
// has landed somewhere, blue for what the page wants you to know first
&.banner-success {
border-color: color-mix(in srgb, var(--success) 35%, var(--border));
background: color-mix(in srgb, var(--success) 7%, var(--surface));
.glyph { color: var(--success); }
}
&.banner-info {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
.glyph { color: var(--accent); }
}
.banner-link {
color: var(--accent);
font-weight: 650;
text-decoration: underline;
text-underline-offset: 2px;
white-space: nowrap;
&:hover { color: var(--accent-strong); }
}
}
.skeleton { border-radius: 16px; height: 172px; @include shimmer; }

View File

@@ -19,6 +19,7 @@
--accent-strong: #3d4dd6;
--accent-2: #0ea5c9;
--danger: #d1344b;
--success: #189a5c;
--code-bg: #f4f6fa;
--shadow-sm: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
--shadow-lg: 0 4px 12px rgb(16 24 40 / 8%), 0 16px 40px -12px rgb(16 24 40 / 18%);
@@ -58,6 +59,7 @@
--accent-strong: #99a5ff;
--accent-2: #38d3ee;
--danger: #f47085;
--success: #3fce8b;
--code-bg: #0c0f16;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
--shadow-lg: 0 4px 16px rgb(0 0 0 / 40%), 0 24px 48px -16px rgb(0 0 0 / 55%);

View File

@@ -20,6 +20,7 @@
@use 'langs'; // badges, chips, version & language menus
@use 'states'; // empty, banner, skeleton
@use 'admin'; // the accounts dashboard
@use 'proposals'; // the dashboard's edit requests from members
@use 'article'; // article page, ⋮ menu, TOC, recently viewed
@use 'prose'; // rendered markdown, highlight.js, KaTeX
@use 'editor'; // editor, cheatsheet, confirm, fullscreen

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>

View File

@@ -24,7 +24,12 @@ function escapeHtml(text: string): string {
}
export function useAppLocale() {
const { locale, setLocale, t } = useI18n()
/* The global composer, not `useI18n()`: that one insists on a component
instance, and this composable also runs from the auth plugin, which has
none — a plugin runs in the Nuxt app's context, never in a `setup`. It is
the same composer `useI18n()` hands to a component anyway, since no part of
the wiki keeps messages of its own. */
const { locale, setLocale, t } = useNuxtApp().$i18n
/* Translate a message that is drawn with v-html. Every interpolated value
is escaped — tags, slugs and usernames wander into these — while the

View File

@@ -44,17 +44,22 @@ export function useArticleLibrary() {
}
function put(key: string, article: ReaderArticle) {
/* the one address a version answers to, whatever asked for it: the short
/wiki/<slug> form fetches by document and the versioned form by
(identity, language), and both are the same page of the shelf */
const version = versionKey(identityOf(article), articleLanguage(article))
delete unreadable.value[key]
delete unreadable.value[versionKey(identityOf(article), article.language ?? 'en')]
delete unreadable.value[version]
shelf.value[key] = article
/* a version also answers to its document's own slug, where it was fetched
from and where the panes keep it */
from and where the panes keep it, and to the version it is */
shelf.value[article.slug] = article
shelf.value[version] = article
}
function forget(slug: string) {
const article = shelf.value[slug]
if (article) delete shelf.value[versionKey(identityOf(article), article.language ?? 'en')]
if (article) delete shelf.value[versionKey(identityOf(article), articleLanguage(article))]
delete shelf.value[slug]
delete unreadable.value[slug]
}

View File

@@ -1,37 +1,38 @@
export interface ArticleTab {
/** the article this page is of — its canonical slug, and the page's key */
identity: string
/** the language this page is turned to */
lang: string
/** the version in view, as the library files it: its stored slug */
slug: string
/** and the document behind it — which a retitle keeps, so the article's page
* follows it to its new address instead of a second page opening beside it */
docId?: string
title: string
/** the language version this page holds */
lang?: string
/** the article this document is a version of — its canonical slug */
identity?: string
}
const MAX_TABS = 10
/** The address a page of the book was read from: the article's own URL for
* the canonical version, /wiki/<lang>/<slug> for the others. */
export function tabHref(tab: Pick<ArticleTab, 'slug' | 'lang' | 'identity'>): string {
const identity = tab.identity ?? tab.slug
return versionHref(identity, { language: tab.lang, slug: tab.slug })
/** The address a page of the book was read from: the version it is turned to. */
export function tabHref(tab: Pick<ArticleTab, 'identity' | 'lang'>): string {
return versionHref(tab.identity, tab.lang)
}
/** Which page of the stack an address points at, if any. */
/** Which page of the stack an address points at, if any. An address that
* names no language points at the article's page however it is turned. */
export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null {
const loc = parseReaderPath(path)
if (!loc) return null
const exact = tabs.find((t) => {
const identity = t.identity ?? t.slug
if (loc.lang) return identity === loc.identity && (t.lang ?? 'en') === loc.lang
return identity === loc.identity && t.slug === loc.identity
})
/* an address naming a document directly (older /slug-fr form) still finds it */
return exact ?? tabs.find((t) => t.slug === loc.identity) ?? null
/* one article is one page of the book, in whichever language that page is
turned to — so the address names its page by the article alone */
return tabs.find((t) => t.identity === loc.identity) ?? null
}
/**
* The session stack of articles opened in the reader — the pages of the
* book, in the order they were opened, newest at the right. The page
* book, in the order they were opened, newest at the right. One article is one
* page however many languages it exists in, so turning an article to another
* language turns that page over instead of adding another to the row. The page
* currently in view is simply wherever the route points; the stack lives in
* shared state and survives every client navigation (reset on reload), so the
* open pages are still there when you come back to the reading surface.
@@ -43,26 +44,56 @@ export function useArticleTabs() {
/* the last page seen outside the reading surface, so closing the last tab
returns there instead of always going home */
const origin = useState<string>('article-tabs:origin', () => '')
/* the page last in view, so the stack can be found again from any page that
hides the reading surface — the editor, a new article, the graph */
const lastSlug = useState<string>('article-tabs:last', () => '')
/* the article last in view, so the stack can be found again from any page
that hides the reading surface — the editor, a new article, the graph */
const lastIdentity = useState<string>('article-tabs:last', () => '')
function ensureTab(
slug: string,
title: string,
meta?: { lang?: string; identity?: string }
) {
const existing = tabs.value.find((t) => t.slug === slug)
if (existing) {
existing.title = title // follow the tab label after a rename
if (meta?.lang) existing.lang = meta.lang
if (meta?.identity) existing.identity = meta.identity
/**
* Lay one version on the book: a fresh page at the right of the row, or —
* when the article already has a page open — that same page turned to this
* language. Reading an article in French is not opening a second article,
* so the page keeps its place in the row and only its text changes.
*/
function openVersion(article: {
_id?: string
slug: string
title: string
language?: string
baseSlug?: string | null
}) {
const identity = identityOf(article)
const lang = articleLanguage(article)
/* the article's own page, or — failing that — the page holding this very
document, which is how a retitle is recognised: the article moved to a
new address and its page follows it rather than doubling up */
const page = tabs.value.find(
(t) => t.identity === identity || (!!article._id && t.docId === article._id)
)
if (page) {
/* the page follows the version it holds: the article's identity, the
document its language asks for, and the title that version carries (a
save that retitled an article moves it and relabels the page; a
language turn relabels it again and takes it to another document) */
page.identity = identity
page.docId = article._id
page.slug = article.slug
page.lang = lang
page.title = article.title
return
}
const stack = [...tabs.value, { slug, title, lang: meta?.lang, identity: meta?.identity }]
const stack = [
...tabs.value,
{
identity,
lang,
slug: article.slug,
docId: article._id,
title: article.title,
},
]
if (stack.length > MAX_TABS) {
/* don't stack forever — retire the oldest tab that isn't in view */
const drop = stack.findIndex((t) => t.slug !== slug)
/* don't stack forever — retire the oldest page that isn't this one */
const drop = stack.findIndex((t) => t.identity !== identity)
if (drop !== -1) stack.splice(drop, 1)
}
tabs.value = stack
@@ -72,17 +103,17 @@ export function useArticleTabs() {
stack and the rest settle; closing the page in view turns back to the
page before it — or returns to the last page outside the book once the
last page is gone. */
function closeTab(slug: string) {
const at = tabs.value.findIndex((t) => t.slug === slug)
const isActive = tabAt(tabs.value, route.path)?.slug === slug
tabs.value = tabs.value.filter((t) => t.slug !== slug)
function closeTab(identity: string) {
const at = tabs.value.findIndex((t) => t.identity === identity)
const isActive = tabAt(tabs.value, route.path)?.identity === identity
tabs.value = tabs.value.filter((t) => t.identity !== identity)
if (isActive) {
const back = tabs.value[Math.max(0, Math.min(at - 1, tabs.value.length - 1))]
void router.push(back ? tabHref(back) : origin.value || '/')
}
}
return { tabs, origin, lastSlug, ensureTab, closeTab }
return { tabs, origin, lastIdentity, openVersion, closeTab }
}
/**
@@ -94,14 +125,15 @@ export function useArticleTabs() {
*/
export function useArticleTabsSync() {
const route = useRoute()
const { tabs, lastSlug, origin } = useArticleTabs()
const { tabs, lastIdentity, origin } = useArticleTabs()
watch(
() => route.fullPath,
(to) => {
/* filed under the tab's own slug, so a language version is found again
as the version it was, not merely as the article */
if (isReaderPath(to)) lastSlug.value = tabAt(tabs.value, to)?.slug ?? wikiSlugFromPath(to) ?? ''
/* filed under the article's identity, so a version turned to another
language is still found as the same page of the book */
if (isReaderPath(to))
lastIdentity.value = tabAt(tabs.value, to)?.identity ?? wikiSlugFromPath(to) ?? ''
else if (!isReadingSurface(to)) origin.value = to
},
{ immediate: true }

View File

@@ -0,0 +1,39 @@
/* How many edit requests wait for an admin. The sidebar wears this number on
the door to the review desk, so the waiting pile shows without opening it.
Shared state rather than a ref: the sidebar draws the count, and the desk —
which already holds every request — lays the honest number on it each time
it loads or a request is decided. */
export function usePendingRequests() {
const api = `${useRuntimeConfig().public.apiBase}/api`
const { isAdmin, authHeaders } = useAuth()
const pending = useState<number>('proposals:pending', () => 0)
/* Ask the API how many wait. Only an admin is answered, so nobody else
carries a count — and a request that never got through (the backend is
restarting) leaves the last number standing rather than emptying the door. */
async function refresh() {
if (!isAdmin.value) {
pending.value = 0
return
}
try {
const { pending: waiting } = await $fetch<{ pending: number }>(
`${api}/admin/proposals/count`,
{ headers: authHeaders() }
)
pending.value = waiting
} catch {
/* keep what the door already said */
}
}
/* The desk counts its own loaded pile — same fetch that brought the requests
answers the door too, so approving or refusing keeps the number in step
without asking the API twice. */
function report(count: number) {
pending.value = count
}
return { pending, refresh, report }
}

View File

@@ -1,12 +1,27 @@
export interface ViewedArticle {
/** the article read, by its identity — the canonical version's slug. An
* entry written before versions had addresses of their own may hold a
* document's own slug, which the reader still resolves. */
slug: string
title: string
/** the language it was last read in, so a jump returns to that version.
* Absent from entries written when one address stood for the whole article */
lang?: string
viewedAt: number
}
const STORAGE_KEY = 'mathew-recently-viewed'
const MAX_ENTRIES = 8
/** where a jump leads: the version the article was last read in, or — for an
* entry that recorded no language — the short address, which the reader turns
* onto whichever version the slug speaks */
export function viewedHref(viewed: Pick<ViewedArticle, 'slug' | 'lang'>): string {
return viewed.lang
? versionHref(viewed.slug, viewed.lang)
: `/wiki/${encodeURIComponent(viewed.slug)}`
}
let loaded = false
function readStored(): ViewedArticle[] {
@@ -36,14 +51,25 @@ export function useRecentlyViewed() {
if (stored.length) viewed.value = stored
})
function record(article: { slug: string; title: string }) {
function record(article: {
slug: string
title: string
baseSlug?: string | null
language?: string
}) {
/* filed by the article's identity and turned to the language it was read
in: an article read in two languages is still one article remembered
once, at the version it was last opened at */
const entry = {
slug: identityOf(article),
lang: articleLanguage(article),
title: article.title,
}
/* merge with what's already saved so recording before the list has
loaded (e.g. deep-linking straight to an article) can't clobber it */
const base = viewed.value.length ? viewed.value : readStored()
viewed.value = [
{ ...article, viewedAt: Date.now() },
...base.filter((v) => v.slug !== article.slug),
].slice(0, MAX_ENTRIES)
const fresh = { ...entry, viewedAt: Date.now() }
viewed.value = [fresh, ...base.filter((v) => v.slug !== entry.slug)].slice(0, MAX_ENTRIES)
loaded = true
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(viewed.value))

View File

@@ -1,15 +1,27 @@
/* Before a reader route draws anything — /wiki/<slug> for an article,
/wiki/<lang>/<slug> for one of its language versions — put the document it
names on the library's shelf. On the server that is what lets the page in
view be written out in the first HTML; on the client it is what the stack of
open pages grows from, and the title the page is filed under. A page that is
already on the shelf comes straight off it and is re-read in the background
— so an edit made elsewhere still shows up when you turn to it. */
/* Before a reader route draws anything — /wiki/<lang>/<slug> for one language
version of an article, /wiki/<slug> for whichever version a bare slug speaks
— put the document it names on the library's shelf. On the server that is
what lets the page in view be written out in the first HTML; on the client it
is what the stack of open pages grows from, and the title the page is filed
under. A page that is already on the shelf comes straight off it and is
re-read in the background — so an edit made elsewhere still shows up when you
turn to it.
/** How a document is filed in the tab stack: which article, which language. */
function tabMeta(article: { slug: string; language?: string; baseSlug?: string | null }) {
return { lang: article.language ?? 'en', identity: article.baseSlug || article.slug }
}
This is also the one place that knows where a version lives: an article's
identity is the canonical version's slug, and a version's address is that
identity with its own language in front. So a short /wiki/<slug> address — a
wiki link, an old bookmark, a graph node — and a versioned one written with a
translation's stored slug are both turned onto /wiki/<lang>/<identity> before
anything renders. An address naming no language is turned onto the reader's
own version when the article speaks it — English as the wiki's common tongue
failing that — so opening an article opens it in the language you read the
wiki in, and only then in whatever version the slug itself speaks. An address
that does name a language is honoured as asked: that is how the version
switcher keeps its promise. The address being left behind never reaches the
history: the navigation to it is abandoned, not committed, so Back still
leads out of the reader to the page the link was pressed on. The address that
stays put is the one naming nothing: the route says for itself that the wiki
has no such article, and offers to write it. */
export default defineNuxtRouteMiddleware(async (to) => {
const loc = parseReaderPath(to.path)
@@ -18,19 +30,34 @@ export default defineNuxtRouteMiddleware(async (to) => {
/* taken before any await, while the app context is certainly at hand */
const library = useArticleLibrary()
const tabs = useArticleTabs()
const { locale } = useAppLocale()
const onShelf = loc.lang
? library.getVersion(loc.identity, loc.lang)
: library.get(loc.identity)
if (onShelf) {
tabs.ensureTab(onShelf.slug, onShelf.title, tabMeta(onShelf))
library.revalidate(loc.identity, loc.lang ?? undefined)
return
const article =
onShelf ??
(loc.lang
? await library.loadVersion(loc.identity, loc.lang)
: await library.load(loc.identity))
// nothing at this address: the route speaks its own emptiness
if (!article) return
/* every version answers to one address, whatever form it was asked with;
a short address picks the reader's version among the ones the article
speaks, English failing that, before its own document speaks for it */
const identity = identityOf(article)
let lang = articleLanguage(article)
if (!loc.lang) {
const spoken = (article.versions ?? []).map((v) => v.language)
lang = preferredLanguage(spoken, locale.value) ?? lang
}
if (loc.lang !== lang || loc.identity !== identity)
return navigateTo(versionHref(identity, lang), { redirectCode: 302 })
const article = loc.lang
? await library.loadVersion(loc.identity, loc.lang)
: await library.load(loc.identity)
if (article) tabs.ensureTab(article.slug, article.title, tabMeta(article))
/* a page already open turns to this version instead of another being added
beside it: the article keeps its place in the book */
tabs.openVersion(article)
if (onShelf) library.revalidate(identity, lang)
})

View File

@@ -2,7 +2,8 @@
/* The wiki's back office: every account it holds, in one list, together with
what each of them may do. The sidebar offers the door to an admin alone;
whoever else reaches this address is turned away before anything is asked —
a member sees the same refusal whether or not the list would have answered. */
a member sees the same refusal whether or not the list would have answered.
The changes members propose are kept on their own page beside this one. */
import type { AuthRole } from '~/composables/useAuth'
@@ -182,9 +183,10 @@ useSeoMeta({
<template v-else>
<section class="hero">
<h1 v-html="t('admin.hero')"></h1>
<p class="sub">{{ t('admin.sub') }}</p>
</section>
<!-- Every account the wiki holds and what each may do. The requests
members send live on their own page, reached from the sidebar. -->
<section class="section">
<div v-if="failure" class="banner">
<span class="glyph">!</span>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
/* The changes members want, kept on their own page: every request that waits
for whoever holds the role to apply it or refuse it, and the record of the
ones already answered. A member's save does not write — it arrives here.
The sidebar offers the door to an admin alone; whoever else reaches this
address is turned away before anything is asked — a member sees the same
refusal whether or not the pile would have answered. */
import type { AdminProposal } from '~/components/ProposalRow.vue'
/* matches the home page's transition so the two surfaces slide past
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const { loggedIn, isAdmin, authHeaders, signOut } = useAuth()
const { report: reportPending } = usePendingRequests()
const { show: openSignIn } = useSignInPopup()
const { t } = useI18n()
/* What the last request could not do, in the words to show for it. '' when
there is nothing to report: a dead session is signed out of instead of
explained, the same way the account page handles one. Shared state rather
than a ref, so the first draw in the browser agrees with the one the server
already wrote out. */
const failure = useState('requests:failure', () => '')
function describeFailure(err: any): string {
const code = err?.statusCode ?? err?.response?.status
if (code === 401) {
signOut()
if (import.meta.client) openSignIn({ notice: t('admin.sessionExpired') })
return ''
}
return err?.data?.message || t('common.apiUnreachable')
}
/* --- edit requests ------------------------------------------------------
A member's save does not write: it waits here for whoever holds the role to
apply it or refuse it. Decided requests stay as the record of the call. Only
an admin's session is answered here, so nothing is asked until the session is
known to be one — signing in over this page brings the pile with it, which is
what the watch below is for. */
const {
data: proposals,
status: proposalsStatus,
refresh: refreshProposals,
} = await useAsyncData(
'admin-proposals',
async () => {
failure.value = ''
if (!isAdmin.value) return []
try {
const { proposals: list } = await $fetch<{ proposals: AdminProposal[] }>(
`${api}/admin/proposals`,
{ headers: authHeaders() }
)
/* The desk holds the whole pile, so it lays the waiting count on the
sidebar's door too — approving or refusing moves it in step, without
the API being asked twice. */
reportPending(list.filter((p) => p.status === 'pending').length)
return list
} catch (err: any) {
failure.value = describeFailure(err)
return []
}
},
{ default: () => [] }
)
watch(isAdmin, (admin) => {
if (admin) void refreshProposals()
})
const proposalsLoading = computed(() => proposalsStatus.value === 'pending')
const pendingProposals = computed(() =>
proposals.value.filter((p) => p.status === 'pending')
)
const decidedProposals = computed(() =>
proposals.value.filter((p) => p.status !== 'pending')
)
/* deciding, one request at a time — while a request is in flight the buttons
wait, so two clicks cannot decide the same request twice */
const decidingId = ref('')
const proposalError = ref('')
const expandedProposal = ref('')
const historyOpen = ref(false)
async function decideProposal(p: AdminProposal, action: 'approve' | 'reject') {
if (decidingId.value) return
proposalError.value = ''
decidingId.value = p.id
try {
await $fetch(`${api}/admin/proposals/${p.id}/${action}`, {
method: 'PUT',
headers: authHeaders(),
})
await refreshProposals()
} catch (err: any) {
proposalError.value =
describeFailure(err) ||
t(action === 'approve' ? 'admin.approveFailed' : 'admin.rejectFailed')
} finally {
decidingId.value = ''
}
}
useSeoMeta({
title: () => t('requests.metaTitle'),
description: () => t('requests.metaDescription'),
})
</script>
<template>
<div class="home-main">
<!-- Nobody but an admin is given the pile: a visitor without an account is
offered the way in, a signed-in member is told the room is not theirs. -->
<AuthGate
v-if="!loggedIn"
:heading="t('requests.gateHeading')"
:text="t('requests.gateText')"
/>
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="requests-no-access">
<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">
<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>
<h1 id="requests-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
<p class="auth-gate-text">
{{ t('requests.forAdminsText') }}
</p>
<div class="auth-gate-actions">
<NuxtLink to="/articles" class="btn btn-ghost">{{ t('admin.backToArticles') }}</NuxtLink>
</div>
</section>
<template v-else>
<section class="hero">
<h1 v-html="t('requests.hero')"></h1>
</section>
<!-- what members want changed: the waiting pile first, then the history
of what was answered. Details open the change as a pair of pages —
how it read when sent, how it would read if applied. -->
<section class="section">
<div v-if="failure" class="banner">
<span class="glyph">!</span>
<span>{{ failure }}</span>
</div>
<template v-else>
<div class="results-bar">
<h2 id="proposals-heading">
<template v-if="proposalsLoading">{{ t('common.loading') }}</template>
<template v-else>{{ t('admin.proposalsHeading') }}</template>
</h2>
<span v-if="!proposalsLoading && pendingProposals.length" class="topic-tally">
{{ t('admin.proposalWaitingTally', pendingProposals.length) }}
</span>
</div>
<p v-if="proposalError" class="form-error">{{ proposalError }}</p>
<div v-if="proposalsLoading" class="proposal-list">
<div v-for="i in 2" :key="i" class="skeleton proposal-skeleton" />
</div>
<div v-else-if="!proposals.length" class="empty">
<div class="glyph">✎</div>
<h3>{{ t('admin.proposalsEmptyTitle') }}</h3>
<p>{{ t('admin.proposalsEmptyText') }}</p>
</div>
<template v-else>
<ul v-if="pendingProposals.length" class="proposal-list">
<li v-for="p in pendingProposals" :key="p.id" class="proposal-entry is-pending">
<ProposalRow
:proposal="p"
:expanded="expandedProposal === p.id"
:busy="!!decidingId"
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
@decide="decideProposal(p, $event)"
/>
</li>
</ul>
<template v-if="decidedProposals.length">
<button
type="button"
class="proposal-history-toggle"
:aria-expanded="String(historyOpen)"
@click="historyOpen = !historyOpen"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:class="{ turned: historyOpen }"
>
<path d="m9 18 6-6-6-6" />
</svg>
{{ t('admin.decidedHeading', decidedProposals.length) }}
</button>
<ul v-if="historyOpen" class="proposal-list">
<li v-for="p in decidedProposals" :key="p.id" class="proposal-entry" :class="`is-${p.status}`">
<ProposalRow
:proposal="p"
:expanded="expandedProposal === p.id"
:busy="false"
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
@decide="decideProposal(p, $event)"
/>
</li>
</ul>
</template>
</template>
</template>
</section>
</template>
</div>
</template>

View File

@@ -5,44 +5,103 @@ import type { ArticleMeta } from '~/components/ArticleCard.vue'
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
/* One batch of a folder's shelf at a time: how many cards the first draw
brings, and how far ahead of the row beneath the grid the next batch is
asked for. */
const PAGE_SIZE = 24
const REACH = 600
interface ShelfResponse {
articles: ArticleMeta[]
/* how many articles answer the filter in force, not just this batch */
total: number
hasMore: boolean
}
interface ShelfFacets {
total: number
topics: { name: string; count: number }[]
languages: { code: string; count: number }[]
}
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { t, locale } = useI18n()
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
`${api}/articles`,
{ default: () => [] }
)
/* topics are the tags spread across the shelf — each one keeps the
articles that wear it; the same tag written two ways counts as one,
and an article counts once however many languages it wears the tag in */
const topics = computed(() => {
const counts = new Map<string, number>()
for (const article of articles.value ?? []) {
const seen = new Set<string>()
for (const doc of [article, ...(article.versions ?? [])]) {
for (const t of doc.tags ?? []) {
const name = t.trim()
if (!name) continue
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
}
}
return [...counts.entries()]
.sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b))
.map(([name, count]) => ({ name, count }))
})
/* picking a topic filters the article list below — the address bar keeps
the tag so the filtered view can still be shared */
const tag = ref(String(route.query.tag ?? ''))
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
/* The shelf is filed by topic: the page shows one folder per topic, and a
folder opens onto the articles wearing that tag. The tag in the address is
which folder stands open — while the address carries none, the folders
themselves answer. */
const inFolder = computed(() => !!tag.value)
/* The filters are the API's to apply now that a folder's shelf arrives a
batch at a time: the same query is what the first batch and the ones after
it ask for. `prefer` is not a filter — it never decides which articles
answer, only which language version of each stands on its card, the
reader's own when the article speaks it (English failing that). */
function shelfQuery(offset: number) {
const query: Record<string, string | number> = { limit: PAGE_SIZE, offset }
if (tag.value) query.tag = tag.value
if (lang.value) query.lang = lang.value
query.prefer = locale.value
return query
}
/* How the shelf is made up — its topics, its languages, how many articles it
holds — is counted over every article rather than over the batches on
screen, so those counts stay true while the cards are still arriving. The
folders themselves are drawn from this tally. */
const { data: facets, status: facetsStatus, error: facetsError } = await useFetch<ShelfFacets>(
`${api}/articles/facets`,
{ default: () => ({ total: 0, topics: [], languages: [] }) }
)
/* An open folder's first batch comes down with the route, so its cards are in
the HTML the server sends. A changed filter asks for a new first batch. */
const { data: shelf, status, error } = await useFetch<ShelfResponse>(`${api}/articles`, {
query: computed(() => shelfQuery(0)),
default: () => ({ articles: [], total: 0, hasMore: false }),
})
/* The batches after the first, as the reader reaches the bottom of what is
already on the paper. */
const laterBatches = ref<ArticleMeta[]>([])
/* what those batches brought over, twins included — how far down the API's
shelf we have read, which is where the next batch starts */
let readBeyond = 0
const loadingMore = ref(false)
/* an answer with nothing left in it: the shelf is empty from here on */
const exhausted = ref(false)
const articles = computed(() => [...(shelf.value?.articles ?? []), ...laterBatches.value])
/* how many articles the open folder answers to, and how many the whole
shelf holds whatever the folder says */
const matches = computed(() => shelf.value?.total ?? 0)
const shelfSize = computed(() => facets.value?.total || matches.value)
const hasMore = computed(() => !exhausted.value && (shelf.value?.hasMore ?? false))
/* the folders are real links and the Back key is a reader too: whatever the
address says is what the refs have to answer to */
watch(
() => route.query,
(q) => {
tag.value = String(q.tag ?? '')
lang.value = String(q.lang ?? '').trim().toLowerCase()
}
)
/* picking a folder opens it — the address bar keeps the tag so the open
folder can still be shared; closing it back to the folders rewrites the
address without one. Either way what was read for the old view is no use */
watch([tag, lang], ([tv, lv]) => {
laterBatches.value = []
readBeyond = 0
exhausted.value = false
const query: Record<string, string> = {}
if (tv) query.tag = tv
if (lv) query.lang = lv
@@ -51,92 +110,96 @@ watch([tag, lang], ([tv, lv]) => {
router.replace({ query })
})
/* every language the shelf is written in, with counts — an article counts
in each language it exists in, and only once per language */
function languagesOf(article: ArticleMeta): string[] {
const codes = new Set<string>([articleLanguage(article)])
for (const v of article.versions ?? []) codes.add(v.language || 'en')
return [...codes]
/* every topic the shelf is filed under, with how many articles it holds, and
every language it is written in — both counted per article, not per
version */
const topics = computed(() => facets.value?.topics ?? [])
const languages = computed(() =>
[...(facets.value?.languages ?? [])]
.map((l) => ({ ...l, name: languageName(l.code) }))
.sort((a, b) => a.name.localeCompare(b.name))
)
/* a folder's address: the tag it opens for, keeping a language filter the
reader already holds so it rides along into the folder */
function folderHref(topic: string) {
const query: Record<string, string> = { tag: topic }
if (lang.value) query.lang = lang.value
return { query }
}
const languages = computed(() => {
const counts = new Map<string, number>()
for (const article of articles.value ?? []) {
for (const code of languagesOf(article)) counts.set(code, (counts.get(code) ?? 0) + 1)
}
return [...counts.entries()]
.sort(([a], [b]) => languageName(a).localeCompare(languageName(b)))
.map(([code, count]) => ({ code, name: languageName(code), count }))
})
/* ---------- an open folder's shelf, a batch at a time ---------- */
const filteredArticles = computed(() => {
const list = (articles.value ?? []).filter((a) => {
if (
tag.value &&
!([a, ...(a.versions ?? [])] as ArticleMeta[]).some((d) =>
(d.tags ?? []).some((t) => t.trim().toLowerCase() === tag.value.toLowerCase())
)
)
return false
if (lang.value && !languagesOf(a).includes(lang.value)) return false
return true
})
/* in a language view each article is shown as the version written —
or rather translated — in that language */
if (!lang.value) return list
return list.map((a) => {
const v = (a.versions ?? []).find((x) => (x.language || 'en') === lang.value)
return v ? { ...a, ...v, versions: a.versions } : a
})
})
/* Does this browser say when the row under the grid arrives, or does the
shelf have to be emptied out in one go? */
const observesScroll = typeof IntersectionObserver === 'function'
/* the search box doubles as a topic compass: the shelf's topics drop
down while you search, and picking one filters the list below */
const topicQuery = ref('')
const dropdownOpen = ref(false)
const activeIndex = ref(-1)
const searchWrap = ref<HTMLElement | null>(null)
const sentinel = ref<HTMLElement | null>(null)
let scrollWatch: IntersectionObserver | null = null
const visibleTopics = computed(() => {
const needle = topicQuery.value.trim().toLowerCase()
if (!needle) return topics.value
return topics.value.filter((t) => t.name.toLowerCase().includes(needle))
})
watch(topicQuery, () => (activeIndex.value = -1))
function pickTopic(topic: { name: string }) {
tag.value = topic.name
topicQuery.value = ''
dropdownOpen.value = false
/* how far down the API's shelf the batches read so far go */
function nextOffset() {
return (shelf.value?.articles.length ?? 0) + readBeyond
}
function onInputKeydown(e: KeyboardEvent) {
if (!dropdownOpen.value) {
if (e.key === 'ArrowDown') dropdownOpen.value = true
return
}
if (e.key === 'Escape') dropdownOpen.value = false
else if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = Math.min(activeIndex.value + 1, visibleTopics.value.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
const topic = visibleTopics.value[activeIndex.value] ?? visibleTopics.value[0]
if (topic) pickTopic(topic)
function sentinelInReach() {
const el = sentinel.value
return !!el && el.getBoundingClientRect().top <= window.innerHeight + REACH
}
async function loadMore() {
/* only an open folder pages in: the folder grid arrives whole from the
facet tally, and a closed folder has no shelf to read on */
if (!inFolder.value || loadingMore.value || status.value === 'pending' || !hasMore.value) return
loadingMore.value = true
try {
/* Pull while the row is in reach — with new cards in place before each
round, too: a shelf short enough to fit on one screen never scrolls, so
it has to fill itself out. */
do {
const batch = await $fetch<ShelfResponse>(`${api}/articles`, {
query: shelfQuery(nextOffset()),
})
readBeyond += batch.articles.length
if (!batch.articles.length || !batch.hasMore) exhausted.value = true
/* an article edited between two batches can be handed out twice: the
folder shows each card once */
const held = new Set(articles.value.map((a) => a._id))
laterBatches.value.push(...batch.articles.filter((a) => !held.has(a._id)))
await nextTick()
} while (hasMore.value && (!observesScroll || sentinelInReach()))
} catch {
/* the row waits where it is: reaching it again asks once more */
} finally {
loadingMore.value = false
}
}
/* a click anywhere outside the box closes the dropdown */
function onDocClick(e: MouseEvent) {
if (dropdownOpen.value && !searchWrap.value?.contains(e.target as Node))
dropdownOpen.value = false
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
onMounted(() => {
if (!observesScroll) return void loadMore()
scrollWatch = new IntersectionObserver(
(seen) => {
if (seen.some((entry) => entry.isIntersecting)) void loadMore()
},
{ rootMargin: `${REACH}px 0px` }
)
if (sentinel.value) scrollWatch.observe(sentinel.value)
})
onBeforeUnmount(() => scrollWatch?.disconnect())
/* opening a folder is what puts its row under the grid: watch for that row
arriving, so reaching it asks for the rest without a scroll first */
watch(sentinel, (el) => {
if (el) scrollWatch?.observe(el)
})
/* a folder's first batch having landed, a reader still standing at the bottom
of it is given the next */
watch(shelf, async () => {
await nextTick()
void loadMore()
})
useSeoMeta({
title: () => t('articles.metaTitle'),
@@ -154,62 +217,20 @@ useSeoMeta({
</section>
<section class="section">
<div v-if="error" class="banner">
<div v-if="error || facetsError" class="banner">
<span class="glyph">!</span>
<span v-html="t('common.apiDown')"></span>
</div>
<template v-else>
<div class="results-bar">
<h2>
<template v-if="status === 'pending'">{{ t('common.loading') }}</template>
<template v-else-if="tag || lang">
{{ t('articles.countOfTotal', { shown: filteredArticles.length, total: articles.length }) }}
</template>
<template v-else>{{ t('articles.articleCount', articles.length) }}</template>
</h2>
<span v-if="status !== 'pending' && topics.length" class="topic-tally">
{{ t('articles.topicCount', topics.length) }}
</span>
</div>
<div v-if="topics.length" ref="searchWrap" class="topic-search-wrap">
<svg width="15" height="15" 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" />
<!-- an open folder: the articles wearing its topic -->
<template v-else-if="inFolder">
<NuxtLink :to="{ query: {} }" replace class="back-link">
<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="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
<input
v-model="topicQuery"
class="topic-search"
type="text"
role="combobox"
:aria-expanded="String(dropdownOpen)"
aria-controls="topic-dropdown"
aria-autocomplete="list"
:placeholder="t('articles.topicSearchPlaceholder')"
:aria-label="t('articles.topicSearchAria')"
@focus="dropdownOpen = true"
@keydown="onInputKeydown"
/>
<div v-if="dropdownOpen" id="topic-dropdown" class="topic-dropdown" role="listbox">
<button
v-for="(topic, i) in visibleTopics"
:key="topic.name"
type="button"
class="topic-option"
:class="{ active: i === activeIndex }"
role="option"
:aria-selected="i === activeIndex"
@click="pickTopic(topic)"
>
<span>{{ topic.name }}</span>
<span class="topic-count">{{ topic.count }}</span>
</button>
<p v-if="!visibleTopics.length" class="topic-dropdown-none">
{{ t('articles.noTopic', { q: topicQuery.trim() }) }}
</p>
</div>
</div>
{{ t('articles.backToTopics') }}
</NuxtLink>
<div v-if="languages.length > 1" class="lang-row" role="group" :aria-label="t('articles.filterByLanguage')">
<span class="lang-row-label">{{ t('articles.writtenIn') }}</span>
@@ -229,20 +250,11 @@ useSeoMeta({
<div class="results-bar">
<h2>
<template v-if="tag && lang">{{ t('articles.headingTagLang', { tag, lang: languageName(lang) }) }}</template>
<template v-else-if="tag">{{ t('articles.headingTag', { tag }) }}</template>
<template v-else-if="lang">{{ t('articles.headingLang', { lang: languageName(lang) }) }}</template>
<template v-else>{{ t('articles.headingAll') }}</template>
<template v-if="status === 'pending'">{{ t('common.loading') }}</template>
<template v-else-if="lang">{{ t('articles.headingTagLang', { tag, lang: languageName(lang) }) }}</template>
<template v-else>{{ t('articles.headingTag', { tag }) }}</template>
</h2>
<span v-if="tag" class="filter-chip">
{{ t('common.tagChip', { tag }) }}
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
<span v-if="lang" class="filter-chip">
<span v-if="status !== 'pending' && lang" class="filter-chip">
{{ t('common.languageChip', { lang: languageName(lang) }) }}
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
@@ -256,28 +268,66 @@ useSeoMeta({
<div v-for="i in 6" :key="i" class="skeleton" />
</div>
<div v-else-if="filteredArticles.length" class="articles-grid">
<ArticleCard v-for="a in filteredArticles" :key="a._id" :article="a" />
<div v-else-if="articles.length" class="articles-grid">
<ArticleCard v-for="a in articles" :key="a._id" :article="a" />
</div>
<div v-else class="empty">
<div class="glyph">∅</div>
<template v-if="articles.length">
<h3>
{{ tag
? t('articles.emptyNoTopicTitle')
: lang
? t('articles.emptyNoLangTitle', { lang: languageName(lang) })
: t('articles.emptyNoTopicsTitle') }}
</h3>
<p>
{{ tag || lang
? t('articles.emptyFilteredText')
: t('articles.emptyUntaggedText') }}
</p>
<button v-if="tag || lang" type="button" class="btn btn-ghost" @click="tag = ''; lang = ''">
{{ t('articles.emptyShowAll') }}
</button>
<h3>
{{ lang
? t('articles.emptyNoLangTitle', { lang: languageName(lang) })
: t('articles.emptyNoTopicTitle') }}
</h3>
<p>{{ t('articles.emptyFilteredText') }}</p>
<button v-if="lang" type="button" class="btn btn-ghost" @click="lang = ''">
{{ t('articles.emptyShowAll') }}
</button>
</div>
<div ref="sentinel" class="shelf-sentinel" role="status">
<span v-if="loadingMore">{{ t('common.loading') }}</span>
</div>
</template>
<!-- the shelf closed: one folder per topic -->
<template v-else>
<div class="results-bar">
<h2>
<template v-if="facetsStatus === 'pending'">{{ t('common.loading') }}</template>
<template v-else>{{ t('articles.articleCount', shelfSize) }}</template>
</h2>
<span v-if="facetsStatus !== 'pending' && topics.length" class="topic-tally">
{{ t('articles.topicCount', topics.length) }}
</span>
</div>
<div v-if="facetsStatus === 'pending'" class="folder-grid">
<div v-for="i in 8" :key="i" class="skeleton folder-skeleton" />
</div>
<div v-else-if="topics.length" class="folder-grid">
<NuxtLink
v-for="topic in topics"
:key="topic.name"
:to="folderHref(topic.name)"
class="folder-card"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
</svg>
<span class="folder-name">{{ topic.name }}</span>
<span class="topic-count folder-count" :title="t('articles.articleCount', topic.count)">
{{ topic.count }}
</span>
</NuxtLink>
</div>
<div v-else class="empty">
<div class="glyph">∅</div>
<template v-if="shelfSize">
<h3>{{ t('articles.emptyNoTopicsTitle') }}</h3>
<p>{{ t('articles.emptyUntaggedText') }}</p>
</template>
<template v-else>
<h3>{{ t('articles.emptyNoneTitle') }}</h3>

View File

@@ -9,41 +9,12 @@ const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { show: openSearch } = useSearch()
const q = ref(String(route.query.q ?? ''))
const tag = ref(String(route.query.tag ?? ''))
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
const langLabel = computed(() => (lang.value ? languageName(lang.value) : ''))
/* debounce the search text so we don't fetch on every keystroke */
const debouncedQ = ref(q.value)
let debounceTimer: ReturnType<typeof setTimeout> | undefined
watch(q, (v) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => (debouncedQ.value = v.trim()), 250)
})
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
`${api}/articles`,
{ query: { q: debouncedQ, tag, lang }, default: () => [] }
)
/* keep the address bar in sync with the search */
watch([debouncedQ, tag, lang], ([qv, tv, lv]) => {
const query: Record<string, string> = {}
if (qv) query.q = qv
if (tv) query.tag = tv
if (lv) query.lang = lv
const current = route.query
const same
= (current.q ?? '') === (query.q ?? '') && (current.tag ?? '') === (query.tag ?? '')
&& (current.lang ?? '') === (query.lang ?? '')
if (!same) router.replace({ query })
})
function selectTag(t: string) {
tag.value = tag.value === t ? '' : t
}
/* the bar opens the Ctrl/⌘+K popup rather than searching on the page itself,
but it still reads back the query the address carries — the popup's
"all results" link lands here with one */
const askedQuery = computed(() => String(route.query.q ?? ''))
/* fetch one article from the API and open it */
const randomPending = ref(false)
@@ -52,7 +23,14 @@ async function showRandomArticle() {
randomPending.value = true
try {
const article = await $fetch<ArticleMeta>(`${api}/articles/random`)
await router.push(versionHref(identityOf(article), article))
/* the canonical slug is the article's address, and the middleware there
turns it onto the reader's language; an orphaned translation answers to
no such address, so that one still goes straight to its own version */
await router.push(
article.baseSlug
? versionHref(identityOf(article), articleLanguage(article))
: `/wiki/${encodeURIComponent(article.slug)}`
)
} catch {
// no articles yet or API unreachable — the banner below already covers API issues
} finally {
@@ -60,38 +38,14 @@ async function showRandomArticle() {
}
}
/* "/" focuses the search box */
const searchEl = ref<HTMLInputElement | null>(null)
function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
if (e.key === '/' && !typing) {
e.preventDefault()
searchEl.value?.focus()
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
useSeoMeta({
title: () => t('home.metaTitle'),
description: () => t('home.metaDescription'),
})
/* which empty-shelf line to say, depending on the filters in force */
const emptyKey = computed(() =>
tag.value && lang.value
? 'home.emptyTagLang'
: tag.value
? 'home.emptyTag'
: lang.value
? 'home.emptyLang'
: 'home.emptyPlain'
)
</script>
<template>
<div class="home-main">
<div class="home-main is-home">
<section class="hero">
<h1 v-html="t('home.hero')"></h1>
@@ -100,14 +54,14 @@ const emptyKey = computed(() =>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" />
</svg>
<input
ref="searchEl"
v-model="q"
class="search-input"
type="text"
:placeholder="t('home.searchPlaceholder')"
<button
type="button"
class="search-input search-trigger"
:aria-label="t('common.searchArticles')"
/>
@click="openSearch()"
>
{{ askedQuery || t('home.searchPlaceholder') }}
</button>
<span class="search-kbd">/</span>
</div>
@@ -123,52 +77,5 @@ const emptyKey = computed(() =>
</button>
</div>
</section>
<section class="section">
<div class="results-bar">
<h2>
<template v-if="debouncedQ || tag || lang">
{{ t('home.results', articles.length) }}
</template>
<template v-else>{{ t('home.recentlyUpdated') }}</template>
</h2>
<span v-if="tag" class="filter-chip">
{{ t('common.tagChip', { tag }) }}
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
<span v-if="lang" class="filter-chip">
{{ t('common.languageChip', { lang: langLabel }) }}
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
</div>
<div v-if="error" class="banner">
<span class="glyph">!</span>
<span v-html="t('common.apiDown')"></span>
</div>
<div v-else-if="status === 'pending'" class="articles-grid">
<div v-for="i in 6" :key="i" class="skeleton" />
</div>
<div v-else-if="articles.length" class="articles-grid">
<ArticleCard v-for="a in articles" :key="a._id" :article="a" />
</div>
<div v-else class="empty">
<div class="glyph">∅</div>
<h3>{{ t('home.emptyTitle') }}</h3>
<p>{{ t(emptyKey, { tag, lang: langLabel }) }}</p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
</div>
</section>
</div>
</template>

View File

@@ -20,7 +20,7 @@ const { record } = useRecentlyViewed()
watch(
article,
(a) => {
if (import.meta.client && a?.slug && a?.title) record({ slug: a.slug, title: a.title })
if (import.meta.client && a?.slug && a?.title) record(a)
},
{ immediate: true }
)
@@ -34,6 +34,14 @@ useSeoMeta({
const missingStatus = computed(
() => (article.value ? 0 : unreadable.value[versionKey(identity.value, lang.value)] ?? 0)
)
/* this version was never laid on the book, so "back" leads to whatever else is
open: the last of the pages still in the stack, if there are any. */
const { tabs } = useArticleTabs()
const backHref = computed(() => {
const last = tabs.value[tabs.value.length - 1]
return last ? tabHref(last) : null
})
</script>
<template>
@@ -44,7 +52,10 @@ const missingStatus = computed(
<div class="glyph">∄</div>
<h3>{{ t('reader.noVersionTitle', { lang: languageName(lang) }) }}</h3>
<p v-html="th('reader.noVersionText', { path: `/wiki/${lang}/${identity}` })"></p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
<div class="empty-actions">
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
<NuxtLink v-if="backHref" :to="backHref" class="btn btn-ghost">{{ t('reader.backToArticles') }}</NuxtLink>
</div>
</template>
<template v-else>
<div class="glyph">!</div>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
/* The article route is a thin host for the book that ArticleBookStack draws.
The open-article middleware has already laid the article on the library's
shelf — that is what the page in view is made of, and what the stack of
open pages is filed under — so all that is left to do here is speak for the
URL: the title, and the visitor's "recently viewed" line.
When nothing lives at the address, this is what says so, over the top of
whatever else is open. */
/* The short address — /wiki/<slug>, the shape a wiki link to [[Bayes Theorem]]
writes and an old bookmark holds — names no language. The open-article
middleware reads which document the slug speaks and turns the address onto
that version's own, /wiki/en/bayes-theorem, so this page is what is left of
it: the words said when the wiki has nothing at the slug, or the API has no
answer about it — an address worth writing, over the top of whatever else is
open. */
definePageMeta({ middleware: 'open-article' })
const route = useRoute()
@@ -14,34 +14,33 @@ const slug = computed(() => String(route.params.slug))
const { t } = useI18n()
const { th } = useAppLocale()
const { get, unreadable } = useArticleLibrary()
const article = computed(() => get(slug.value))
const { record } = useRecentlyViewed()
watch(
article,
(a) => {
if (import.meta.client && a?.slug && a?.title) record({ slug: a.slug, title: a.title })
},
{ immediate: true }
)
useSeoMeta({
title: () => article.value?.title ?? t('card.kickerArticle'),
})
/* what the shelf could not bring in, the API said why */
const missingStatus = computed(() => (article.value ? 0 : unreadable.value[slug.value] ?? 0))
const missingStatus = computed(() => (get(slug.value) ? 0 : unreadable.value[slug.value] ?? 0))
useSeoMeta({ title: () => t('card.kickerArticle') })
/* a missing address was never laid on the book, so "back" leads to whatever
else is open: the last of the pages still in the stack, if there are any. */
const { tabs } = useArticleTabs()
const backHref = computed(() => {
const last = tabs.value[tabs.value.length - 1]
return last ? tabHref(last) : null
})
</script>
<template>
<div class="reader-host">
<div v-if="!article" class="reader-note">
<div class="reader-note">
<div class="empty" style="padding-top: 64px">
<template v-if="missingStatus === 404">
<div class="glyph">∄</div>
<h3>{{ t('reader.notFoundTitle') }}</h3>
<p v-html="th('reader.notFoundText', { path: `/wiki/${slug}` })"></p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
<div class="empty-actions">
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
<NuxtLink v-if="backHref" :to="backHref" class="btn btn-ghost">{{ t('reader.backToArticles') }}</NuxtLink>
</div>
</template>
<template v-else>
<div class="glyph">!</div>

View File

@@ -4,6 +4,7 @@
on the server as much as in the browser. So a stored session is checked here,
once, before any page runs. With no cookie in the request it costs nothing. */
export default defineNuxtPlugin(async () => {
const appLocale = useAppLocale()
const { user, restoreSession } = useAuth()
await restoreSession()
@@ -11,5 +12,5 @@ export default defineNuxtPlugin(async () => {
follows the person — so the very first render speaks it. (setLocale keeps
the detection cookie in step, so the next load starts there even before
this runs.) */
if (user.value?.locale) await useAppLocale().applyAccount(user.value.locale)
if (user.value?.locale) await appLocale.applyAccount(user.value.locale)
})

View File

@@ -35,6 +35,22 @@ export function languageName(code: string): string {
return LANGUAGES.find((l) => l.code === code)?.name ?? code.toUpperCase()
}
/**
* Which language version to open an article in when the address asks for
* none: the reader's own language when the article speaks it, else English
* as the wiki's common tongue, else null — nothing to pick, so whatever
* version already stands for the article answers.
*/
export function preferredLanguage(
available: readonly (string | undefined)[],
reader: string
): string | null {
const codes = new Set(available.map((c) => (c ?? '').trim().toLowerCase()))
const wanted = reader.trim().toLowerCase()
if (codes.has(wanted)) return wanted
return codes.has(DEFAULT_LANGUAGE) ? DEFAULT_LANGUAGE : null
}
/** The first language no version of an article has been written in yet. */
export function nextFreeLanguage(taken: readonly string[]): string {
const takenCodes = taken.map((c) => c.trim().toLowerCase())

View File

@@ -3,15 +3,21 @@
An article route is a page of the open book drawn by ArticleBookStack —
pages laid out side by side, older ones parked with their spines showing —
while the article edit page and the new-article page remain plain,
full-height pages. An article lives at /wiki/<slug>; one of its language
versions at /wiki/<lang>/<slug>, e.g. /wiki/fr/bayes-theorem. */
full-height pages. Every language version of an article has an address of
its own — /wiki/<lang>/<identity>, English no different from the rest, so
/wiki/en/bayes-theorem and /wiki/fr/bayes-theorem sit side by side. The one
short form, /wiki/<slug> (what a wiki link to [[Bayes Theorem]] writes, and
what an old bookmark holds), names no language: the open-article middleware
reads which document the slug speaks and turns the address into that
version's own. */
const READER_PATH = /^\/wiki\/([^/]+?)\/?$/
const VERSION_READER_PATH = /^\/wiki\/([a-z]{2})\/([^/]+?)\/?$/
const WIKI_SUB_PATH = /^\/wiki\/[^/]+\/[^/]+/
/** What a reader address names: the article's identity, and which language
* version the address asks for (null for the article's own version). */
* version the address asks for (null for the short form, which asks for
* whichever version the slug itself speaks). */
export interface ReaderLocation {
identity: string
lang: string | null
@@ -37,12 +43,15 @@ export function wikiSlugFromPath(path: string): string | null {
}
/**
* The address of one language version: the canonical document lives at the
* article's own URL, translations at /wiki/<lang>/<identity>.
* The address one language version lives at: /wiki/<lang>/<identity>, the
* article's identity being the canonical version's slug — so every version of
* one article shares the last part of its address and differs only by the
* language in front. Documents carry their own stored slug, which no address
* needs: the wiki finds a version by (identity, language).
*/
export function versionHref(identity: string, version: { language?: string; slug: string }): string {
if (version.slug === identity) return `/wiki/${encodeURIComponent(identity)}`
return `/wiki/${encodeURIComponent(version.language ?? 'en')}/${encodeURIComponent(identity)}`
export function versionHref(identity: string, language?: string): string {
const code = language?.trim().toLowerCase() || DEFAULT_LANGUAGE
return `/wiki/${encodeURIComponent(code)}/${encodeURIComponent(identity)}`
}
/**