First commit

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

34
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
/* The document language and the fallback title follow the interface locale:
the callback form of useHead re-runs them whenever the locale changes. */
const { t } = useI18n()
const { locale } = useAppLocale()
useHead(() => ({
htmlAttrs: { lang: locale.value },
titleTemplate: (title?: string | null) => (title ? `${title} · Mathew` : t('app.title')),
}))
/* keep the book's stack of open pages in step with the route */
useArticleTabsSync()
</script>
<template>
<div class="app-shell">
<NuxtRouteAnnouncer />
<AppHeader />
<main class="app-main">
<div class="container">
<NuxtPage />
</div>
</main>
<!-- after the page: the article route has put its article on the shelf
by the time the book is rendered, so the page in view arrives with
its text already written out -->
<ArticleBookStack />
<SearchModal />
<SettingsModal />
<ProfileModal />
<AuthModal />
</div>
</template>

View File

@@ -0,0 +1,78 @@
// ---------- 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.
.account-list {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.account-entry {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.account-entry-name {
flex: 1;
min-width: 0;
font-size: 0.95rem;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// the marker on the row that is the person looking at the page
.account-entry-you {
margin-left: 6px;
font-size: 0.72rem;
font-weight: 600;
color: var(--faint);
}
.account-entry-since { flex-shrink: 0; font-size: 0.78rem; color: var(--faint); }
// where the ⋮ goes on the row of the admin looking at the page: their own role
// and their own account are the two things they cannot change, so the row says
// it instead of offering a menu that could only fail
.account-entry-note {
margin-left: auto;
flex-shrink: 0;
font-size: 0.76rem;
font-style: italic;
color: var(--faint);
}
.role-pill {
flex-shrink: 0;
padding: 3px 11px;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
// an admin's row is the accent one, so the few of them stand out at a glance
&.is-admin {
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
}
}
// on a wide row the ⋮ sits at the end of the line; once the row wraps (a narrow
// window) it starts a line of its own, still at the end of what came before
.account-entry-kebab { margin-left: auto; flex: none; }
// the article cards' skeleton, in the shape of one row of the list
.skeleton-row { height: 62px; }

View File

@@ -0,0 +1,225 @@
@use 'mixins' as *;
// ---------- Article page ----------
.article-page { padding-block: 40px 80px; }
.article-head { margin-bottom: 8px; }
.article-eyebrow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 0.8rem;
color: var(--faint);
margin-bottom: 10px;
}
// the title and the ⋮ share one line: the title takes what is left and wraps
// its own text, the actions keep their place at the side of it
.article-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.article-title {
flex: 1;
min-width: 0;
margin: 0;
font-size: clamp(1.9rem, 4vw, 2.6rem);
font-weight: 830;
letter-spacing: -0.03em;
line-height: 1.15;
}
.article-actions {
flex: none;
display: flex;
gap: 9px;
padding-top: 10px;
}
// The ⋮ menu: the quiet button that folds several actions into one place. The
// article's title row uses it for Edit, languages and Delete; the admin
// dashboard uses it for the role and the account itself.
.kebab { position: relative; }
.kebab-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
&:hover,
&.open {
border-color: var(--border-strong);
background: var(--surface-2);
color: var(--text);
}
}
.kebab-dropdown {
@include dropdown-panel($z: 30, $overflow: visible);
right: 0;
min-width: 190px;
text-align: left;
// the language menu rides inside as a row of the ⋮ menu — flat, full-width,
// its own list dropping over the rows below
.lang-menu {
width: 100%;
> .btn {
justify-content: flex-start;
width: 100%;
padding: 8px 10px;
border: 0;
border-radius: 8px;
background: none;
color: var(--text);
font-size: 0.85rem;
font-weight: 600;
&:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
}
}
}
.kebab-item {
@include menu-row(8px 10px, 9px);
text-decoration: none;
svg { flex: none; color: var(--faint); transition: color 0.12s; }
// a menu row whose request is still in flight: readable, but no longer a way
// to ask twice
&:disabled {
color: var(--faint);
cursor: default;
background: none;
svg { color: var(--faint); }
}
&:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
svg { color: var(--accent); }
}
}
.kebab-danger:hover {
background: color-mix(in srgb, var(--danger) 12%, transparent);
svg { color: var(--danger); }
}
.kebab-sep { margin: 5px 0; border-top: 1px solid var(--border); }
// Topics line at the foot of an article: “Topics: Probability, Calculus”.
// The comma joins the topics; a break stays tidy after it.
.article-topics {
margin-top: 36px;
padding-top: 14px;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--muted);
}
.article-topics-label { font-weight: 600; margin-right: 0.35em; }
.article-topics .topic-link {
color: var(--accent);
text-decoration: none;
margin-right: 0.5em;
&:not(:last-child)::after { content: ','; }
&:hover { text-decoration: underline; }
}
.article-body {
margin-top: 34px;
border-top: 1px solid var(--border);
padding-top: 8px;
min-width: 0;
}
// TOC
.toc {
position: sticky;
top: 24px;
font-size: 0.855rem;
@media (max-width: 1024px) {
display: none;
}
}
.toc-title { @include eyebrow; margin-bottom: 10px; }
.toc ol {
list-style: none;
margin: 0;
padding: 0;
border-left: 2px solid var(--border);
}
.toc li a {
display: block;
padding: 5px 0 5px 14px;
margin-left: -2px;
border-left: 2px solid transparent;
color: var(--muted);
text-decoration: none;
line-height: 1.4;
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text); }
&.active { color: var(--accent); border-left-color: var(--accent); }
}
.toc li.level-3 a { padding-left: 28px; font-size: 0.8rem; }
// Recently viewed (article sidebar)
.recently-viewed {
margin-top: 26px;
padding-top: 18px;
border-top: 1px solid var(--border);
font-size: 0.855rem;
ul { list-style: none; margin: 0; padding: 0; }
li {
+ li { margin-top: 2px; }
a {
display: block;
padding: 6px 9px;
border-radius: 7px;
color: var(--muted);
text-decoration: none;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.15s, color 0.15s;
&:hover {
background: color-mix(in srgb, var(--accent) 8%, var(--bg-elev));
color: var(--text);
}
}
}
}
.rv-title { @include eyebrow; margin-bottom: 8px; }

View File

@@ -0,0 +1,67 @@
@use 'mixins' as *;
// ---------- Articles index ----------
.topic-tally { font-size: 0.85rem; color: var(--muted); }
.topic-dropdown {
@include dropdown-panel;
left: 0;
right: 0;
max-height: 280px;
}
.topic-option {
@include menu-row(8px 12px, 12px, 0.88rem);
justify-content: space-between;
text-align: left;
&: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;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: 0.72rem;
font-weight: 700;
}

View File

@@ -0,0 +1,119 @@
@use 'mixins' as *;
// ---------- Sign in ----------
// Anyone may read Mathew; writing it takes an account. The gate is what a
// signed-out visitor of the editor finds in the form's place (AuthGate.vue), and
// the popup is the way in from anywhere (AuthModal.vue). The popup sits above the
// fullscreen editor (1000) and its own popups (1100, 1150): a save that comes
// back refusing an expired session has to be answered where the editor already
// fills the screen.
.auth-overlay { @include overlay(1160); }
.auth-modal {
@include sheet(420px);
&:focus, &:focus-visible { outline: none; }
}
.auth-modal-head { @include sheet-head; }
.auth-title { @include sheet-title; }
.auth-modal-body { padding: 18px; }
// the two ways in, side by side
.auth-tabs { @include segmented; margin-bottom: 18px; }
.auth-tab { @include segmented-tab(7px 14px, 0.84rem); }
// why the popup turned up on its own — a session that expired mid-draft
.auth-notice {
margin: -6px 0 16px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
color: var(--muted);
font-size: 0.84rem;
line-height: 1.5;
}
.auth-modal .field input {
width: 100%;
padding: 12px 14px;
border-radius: 11px;
border: 1px solid var(--border);
background: var(--bg-elev);
}
.auth-submit { width: 100%; margin-top: 2px; }
.auth-switch {
margin: 16px 0 0;
text-align: center;
font-size: 0.84rem;
color: var(--muted);
button {
color: var(--accent);
font-weight: 650;
&:hover { text-decoration: underline; }
}
}
@include popup-anim(
'auth-popup',
'.auth-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);
// the editor's stand-in until there is a session
.auth-gate {
max-width: 480px;
margin: clamp(36px, 8vh, 92px) auto;
padding: 34px 30px 30px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
text-align: center;
}
.auth-gate-mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
margin: 0 auto 16px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.auth-gate-title {
margin: 0 0 8px;
font-size: 1.3rem;
letter-spacing: -0.01em;
color: var(--text);
}
.auth-gate-text {
max-width: 38ch;
margin: 0 auto 22px;
color: var(--muted);
font-size: 0.93rem;
line-height: 1.6;
}
.auth-gate-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.auth-gate-fine { margin: 18px 0 0; font-size: 0.8rem; color: var(--faint); }

View File

@@ -0,0 +1,65 @@
// ---------- Base ----------
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
margin: 0;
font-family:
'Computer Modern',
'CMU Serif',
Georgia,
'Times New Roman',
serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
transition: background-color 0.25s ease, color 0.25s ease;
}
::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); }
a { color: var(--accent); }
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
input, textarea, select { font: inherit; color: inherit; }
:focus-visible { outline: none; box-shadow: var(--ring); border-radius: 8px; }
.container {
width: 100%;
max-width: 1160px;
margin-inline: auto;
padding-inline: clamp(16px, 4vw, 32px);
}
.mono,
code,
pre,
textarea.md-input {
font-family: ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code',
Menlo, Consolas, monospace;
}
// ---------- Scrollbars (subtle, webkit) ----------
::-webkit-scrollbar { width: 11px; height: 10px; }
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text) 18%, transparent);
border-radius: 8px;
border: 3px solid transparent;
background-clip: content-box;
&:hover {
background: color-mix(in srgb, var(--text) 30%, transparent);
background-clip: content-box;
}
}

View File

@@ -0,0 +1,300 @@
@use 'mixins' as *;
// ---------- Reader: an open book of article pages ----------
// Every article opened in this session is a page, laid out beside the last
// in a track you move along sideways (ArticleBookStack.vue). Pages are solid
// paper, and each sticks a spine's width further from the left edge than the
// one before it — left: calc(var(--i) * var(--spine-w)) — so turning to a
// newer page slides it over the older ones, which park along the edge with
// nothing but their spines showing: a stack of pages, titled along the edge
// like books on a shelf. A page is as wide as its share of the reading
// surface — minus the spines of every other page, but never more than a
// comfortable measure, and open across the whole desk when it is the only
// page in the book — so on a wide desk the next page waits fully in view
// beside the one you read, not only its spine; on a narrow one the rest wait
// out at the right edge. A spine the desk has no room for is copied to that
// edge — the dock, .book-dock below — so no page is ever more than a click
// away, though its own spine is off the end of the row. Click a spine to turn
// back to that page, the × to take it out of the book. Below 900px the book
// folds to the one page in view and its neighbours become chips along the
// top. Routes stay the source of truth — every page is a plain /wiki/<slug>
// URL, and useArticleTabs keeps the stack. (The --sidebar-w, --spine-w and
// --pane-max-w tokens live in _tokens.scss.)
// the surface the book lies open on
.book-layer {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 70;
display: flex;
flex-direction: column;
background: var(--bg-elev);
box-shadow: -10px 0 26px rgb(5 7 13 / 9%);
}
// nothing lives at this URL: the route host says so over the open book
.reader-note {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 74;
background: var(--bg-elev);
box-shadow: -10px 0 26px rgb(5 7 13 / 4%);
}
// the row of pages, moved along sideways
.book-track {
--pane-w: calc(100% - (var(--panes, 1) - 1) * var(--spine-w));
position: relative;
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
// the desk under the pages: once a page stops short of the reading
// surface, what's left of the desk shows beside it
background: var(--bg);
@include no-scrollbar;
}
// one page: solid paper, parked beside the pages under it
.book-pane {
position: sticky;
left: calc(var(--i) * var(--spine-w));
z-index: calc(var(--i) + 1);
flex: 0 0 var(--pane-w);
// A page takes every inch of the reading surface it can — the one in view
// opens across the whole desk, not just a column of it. Only once so many
// are open that each page's share falls below a comfortable reading width
// does it hold at that width and the book scroll sideways instead.
min-width: min(46rem, 100%);
// ...and on a desk wide enough for it to stretch past a page of paper, it
// holds at --pane-max-w and leaves the rest of the desk bare. The single
// page of a one-page book is exempt below.
max-width: var(--pane-max-w);
display: flex;
background: var(--bg-elev);
// the fore-edge of the paper, against the desk and the next page
border-right: 1px solid var(--border);
// the edge of this page lying on top of the ones beneath it
box-shadow: -10px 0 22px rgb(5 7 13 / 7%);
animation: page-turned-in 0.3s cubic-bezier(0.22, 0.8, 0.36, 1) both;
}
// a book with a single page in it lies open across the whole desk: there is
// no other page waiting for that space, so the page takes all of it
.book-pane:only-child { max-width: none; }
// a page that isn't in view asks to be turned to: the whole of it is
// clickable (clicks on its inert body retarget to the pane itself), not
// just the spine
.book-pane:not(.active) {
cursor: pointer;
.pane-body, .pane-body * { cursor: pointer; }
}
// a new page is laid down at the right and eased into the stack
@keyframes page-turned-in {
from { transform: translateX(56px); opacity: 0; }
}
// the page's fore-edge: its title standing on end, and the × that closes it.
// Once a newer page is turned over this one, the spine is all that's left in
// sight — so it is the handle for turning back. A page whose spine is off the
// edge of the desk has it copied at the edge itself, in the dock below, which
// borrows .spine-title and .spine-close as its own standing type.
.pane-spine {
flex: 0 0 var(--spine-w);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 0 10px;
background: var(--surface);
border-right: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
user-select: none;
transition: color 0.15s, background-color 0.15s;
&:hover { color: var(--text); background: var(--surface-2); }
}
// the page in view has its spine marked, the way a ribbon marks a book
.book-pane.active .pane-spine {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.spine-close {
@include icon-target(15px, 5px);
color: var(--faint);
transition: color 0.15s, background-color 0.15s;
@include close-target;
svg { display: block; }
}
.spine-title {
writing-mode: vertical-rl;
flex: 1;
min-height: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
line-height: 1.35;
letter-spacing: 0.01em;
padding-top: 0;
}
// the page itself: the article, scrolling on its own
.pane-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.pane-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-inline: clamp(16px, 3.5vw, 36px);
// the article takes the page as a single centered column
.article-page { max-width: 78ch; margin-inline: auto; }
}
.pane-skeleton { padding-top: 32px; }
// folded up narrow: the neighbours of the page in view, as chips along the
// top. Wide screens read the spines instead.
.book-chips {
display: none;
flex-shrink: 0;
gap: 8px;
padding: 9px 10px;
overflow-x: auto;
overscroll-behavior: contain;
background: var(--surface);
border-bottom: 1px solid var(--border);
@include no-scrollbar;
}
.book-chip {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
max-width: 46vw;
padding: 5px 7px 5px 11px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
font-size: 0.79rem;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: color 0.15s, background-color 0.15s, border-color 0.15s;
&:hover { color: var(--text); }
&.active {
color: var(--accent);
background: var(--bg-elev);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
}
.chip-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-close { @include icon-target(16px, 5px); color: var(--faint); @include close-target; }
// ---------- The dock: pages waiting beyond the right edge of the desk -----
// A page further along the book than the one in view lies somewhere off the
// right edge of the desk, reachable by dragging the row sideways. While it is
// out there, its spine is kept at the edge itself: a stack one spine wide, in
// the order the pages were opened, each one the standing title and × of the
// page it stands for. Click it and the book turns to that page as though you
// had scrolled to it; the page's own spine comes into view and its copy in the
// dock goes away (ArticleBookStack measures which pages are out there). It
// rides above every page of the book — the pages are numbered one apiece by
// --panes — and, being inside the reading surface, below anything popped over
// it.
.book-dock {
position: absolute;
inset-block: 0;
right: 0;
z-index: calc(var(--panes, 1) + 2);
display: flex;
flex-direction: column;
width: var(--spine-w);
padding: 6px 0;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -10px 0 22px rgb(5 7 13 / 7%);
color: var(--muted);
}
// the waiting spines share the height of the desk, so however many pages are
// out there every one of them keeps a place at the edge, its title cut short
// to fit — the whole title is in the tooltip
.dock-spine {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 4px 0;
cursor: pointer;
user-select: none;
transition: color 0.15s, background-color 0.15s;
+ .dock-spine { border-top: 1px solid var(--border); }
&:hover { color: var(--text); background: var(--surface-2); }
}
@media (max-width: 900px) {
.book-layer, .reader-note { inset: 0; box-shadow: none; }
.book-track { overflow-x: hidden; }
// folded: only the page in view is out, the rest are chips along the top
.book-pane {
position: static;
flex: 1 1 100%;
width: 100%;
min-width: 0;
max-width: 100%;
box-shadow: none;
&:not(.active) { display: none; }
}
.book-chips { display: flex; }
// folded, the book has no edges to stack spines against: the chips along
// the top are every page of it
.book-dock { display: none; }
.pane-scroll { padding-inline: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.book-pane { animation: none; }
}

View File

@@ -0,0 +1,62 @@
// ---------- Buttons ----------
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
border-radius: 10px;
border: 1px solid transparent;
font-size: 0.9rem;
font-weight: 650;
text-decoration: none;
white-space: nowrap;
transition: filter 0.15s, background-color 0.15s, border-color 0.15s, transform 0.1s,
box-shadow 0.15s;
&:active { transform: translateY(1px); }
&[disabled] { opacity: 0.55; pointer-events: none; }
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, var(--accent-2)));
color: #fff;
box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent) 65%, transparent);
&:hover { filter: brightness(1.1); }
}
.btn-ghost {
border-color: var(--border);
background: var(--surface);
color: var(--text);
&:hover { border-color: var(--border-strong); background: var(--surface-2); }
}
.btn-danger {
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
color: var(--danger);
background: transparent;
&:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
}
.btn-sm { padding: 7px 13px; font-size: 0.82rem; border-radius: 8px; }
.icon-btn {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
transition: color 0.15s, border-color 0.15s, transform 0.15s;
&:hover { color: var(--text); border-color: var(--border-strong); }
&:active { transform: scale(0.94); }
}

View File

@@ -0,0 +1,80 @@
// ---------- Article cards ----------
.section { position: relative; padding-bottom: clamp(56px, 9vh, 96px); }
.articles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.article-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 22px;
border-radius: 16px;
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;
&:hover {
transform: translateY(-3px);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
box-shadow: var(--shadow-lg);
}
&:focus-visible { box-shadow: var(--ring); }
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.card-kicker {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
}
.card-title {
margin: 0;
font-size: 1.14rem;
font-weight: 720;
letter-spacing: -0.015em;
line-height: 1.3;
}
.card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 4px;
font-size: 0.78rem;
color: var(--faint);
}
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 11%, transparent);
color: var(--accent);
font-size: 0.75rem;
font-weight: 600;
text-decoration: none;
transition: background-color 0.15s;
}
a.tag:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }

View File

@@ -0,0 +1,295 @@
@use 'mixins' as *;
// ---------- Editor ----------
.editor-page { padding-block: 36px 90px; max-width: 1080px; }
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 24px;
h1 {
margin: 0;
font-size: 1.5rem;
letter-spacing: -0.025em;
font-weight: 800;
}
}
.editor-head-actions { display: flex; gap: 10px; align-items: center; }
.field {
margin-bottom: 18px;
label {
display: block;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 7px;
}
.hint {
text-transform: none;
font-weight: 500;
letter-spacing: 0;
color: var(--faint);
}
input[type='text'],
select {
width: 100%;
padding: 12px 14px;
border-radius: 11px;
border: 1px solid var(--border);
background: var(--bg-elev);
transition: border-color 0.15s, box-shadow 0.2s;
}
// `input:focus` (not just the styled types) so every field input —
// the sign-in popup's password among them — gets the same ring
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
input.title-input {
font-size: 1.25rem;
font-weight: 720;
letter-spacing: -0.02em;
padding: 14px 16px;
}
select { cursor: pointer; }
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.slug-preview {
font-size: 0.8rem;
color: var(--faint);
margin-top: 6px;
code { color: var(--accent); }
}
// editor body
.editor-body {
border: 1px solid var(--border);
border-radius: 15px;
background: var(--bg-elev);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 9px 11px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.mode-btn {
padding: 7px 14px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 620;
color: var(--muted);
transition: background-color 0.15s, color 0.15s;
&:hover { color: var(--text); }
&.active {
background: var(--surface-2);
color: var(--text);
box-shadow: inset 0 0 0 1px var(--border);
}
}
.toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 9px;
flex: 0 0 auto;
}
.editor-grid {
display: grid;
grid-template-columns: 1fr 1fr;
&.mode-write {
grid-template-columns: 1fr;
.preview-pane { display: none; }
}
&.mode-preview {
grid-template-columns: 1fr;
.write-pane { display: none; }
}
}
textarea.md-input {
display: block;
width: 100%;
min-height: 480px;
padding: 22px;
border: 0;
background: transparent;
resize: vertical;
font-size: 0.9rem;
line-height: 1.75;
tab-size: 2;
&:focus { outline: none; }
&::placeholder { color: var(--faint); }
}
.preview-pane {
border-left: 1px solid var(--border);
padding: 22px 26px;
min-width: 0;
overflow-wrap: break-word;
}
.preview-label { @include eyebrow; margin-bottom: 14px; }
@media (max-width: 900px) {
.editor-grid { grid-template-columns: 1fr; }
.preview-pane { border-left: 0 !important; border-top: 1px solid var(--border); }
}
// editor writing-cheatsheet popup — same shape as the settings modal,
// but above the fullscreen editor (z-index 1000)
.cheatsheet-overlay { @include overlay(1100, clamp(40px, 10vh, 110px)); }
.cheatsheet-modal { @include sheet(680px, min(620px, calc(100vh - 90px))); }
.cheatsheet-head { @include sheet-head; }
.cheatsheet-title { @include sheet-title; }
.cheatsheet-body {
@include sheet-body(20px);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px 28px;
align-items: start;
}
.cheatsheet-group-title {
@include eyebrow(0.74rem, 750, 0.08em, var(--accent));
margin: 0 0 10px;
}
.cheat-row {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 0.84rem;
color: var(--muted);
& + & { margin-top: 7px; }
code {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
font-size: 0.78rem;
}
.katex { font-size: 1em; }
}
@include popup-anim('cheatsheet-popup', '.cheatsheet-modal', 0.18s, transform 0.18s ease, 10px);
// discard-changes popup — small sibling of the settings modal, raised above
// the fullscreen editor (z-index 1000)
.confirm-overlay { @include overlay(1150, clamp(56px, 18vh, 200px)); }
.confirm-modal {
width: min(430px, 100%);
border: 1px solid var(--border);
border-radius: 16px;
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
padding: 22px;
}
.confirm-title { margin: 0; font-size: 1.02rem; font-weight: 700; }
.confirm-message {
margin: 8px 0 0;
font-size: 0.88rem;
line-height: 1.55;
color: var(--muted);
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
@include popup-anim('confirm-popup', '.confirm-modal', 0.18s, transform 0.18s ease, 10px);
// fullscreen editor
.editor-body.is-fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
border-radius: 0;
border-left: 0;
border-right: 0;
display: flex;
flex-direction: column;
.editor-toolbar { flex: 0 0 auto; }
.editor-grid { flex: 1; min-height: 0; }
.write-pane, .preview-pane { min-height: 0; overflow-y: auto; }
textarea.md-input { height: 100%; min-height: 0; resize: none; }
}
// below 900px the split stacks vertically — give each pane half the screen
@media (max-width: 900px) {
.editor-body.is-fullscreen .editor-grid:not(.mode-write):not(.mode-preview) {
grid-template-rows: 1fr 1fr;
}
}
.form-error { color: var(--danger); font-size: 0.88rem; margin: 12px 2px 0; }

View File

@@ -0,0 +1,19 @@
// ---------- Fonts: Computer Modern (CMU) ----------
// CMU ships only two weights, so we map the design's intermediate
// weights onto real faces: <=550 → regular, >550 → bold extended.
$cmu-faces: (
('cmunrm', 100 550, normal),
('cmunti', 100 550, italic),
('cmunbx', 551 900, normal),
('cmunbi', 551 900, italic),
);
@each $file, $weight, $style in $cmu-faces {
@font-face {
font-family: 'Computer Modern';
src: url('/fonts/#{$file}.ttf') format('truetype');
font-weight: $weight;
font-style: $style;
font-display: swap;
}
}

View File

@@ -0,0 +1,68 @@
// ---------- Graph view (the link map) ----------
// desktop: the map owns the space beside the sidebar the way the open
// book does — a full surface with its toolbar floating over it.
// (The page transition only fades, so no transform steals the
// fixed positioning.)
.graph-stage {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 1;
background: var(--bg);
}
.graph-canvas-wrap { position: absolute; inset: 0; }
.graph-canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none; // the map owns drags and pinches, not the page
}
.graph-toolbar {
position: absolute;
top: 16px;
right: 18px;
z-index: 2;
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 11px;
background: color-mix(in srgb, var(--bg-elev) 84%, transparent);
backdrop-filter: blur(10px);
box-shadow: var(--shadow-sm);
}
.graph-state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
.banner { width: min(520px, 100%); }
}
// the top bar is in the flow here, so the map folds into a panel of
// the page instead of covering the screen
@media (max-width: 900px) {
.graph-stage { position: static; inset: auto; background: transparent; }
.graph-canvas-wrap {
position: relative;
height: min(66dvh, 560px);
margin: 20px 0 28px;
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: var(--bg-elev);
box-shadow: var(--shadow-sm);
}
.graph-toolbar { top: 10px; right: 10px; }
.graph-state { position: static; display: block; padding: 8px 0 32px; }
}

View File

@@ -0,0 +1,152 @@
// ---------- Hero (home) ----------
.home-main {
position: relative;
&::before {
content: '';
position: absolute;
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);
}
}
.hero {
position: relative;
text-align: center;
padding: clamp(56px, 11vh, 104px) 0 44px;
h1 {
margin: 0;
font-size: clamp(2.4rem, 6vw, 3.7rem);
font-weight: 850;
letter-spacing: -0.035em;
line-height: 1.08;
// the headline's ink drifts between the two accents — the gradient is
// painted twice as wide as the text and slides through it; both ends are
// the accent colour, so the loop has no seam
.gradient {
background: linear-gradient(
92deg,
var(--accent) 10%,
var(--accent-2) 50%,
var(--accent) 90%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: hero-gradient-pan 4.5s linear infinite;
}
}
.sub {
margin: 18px auto 0;
max-width: 56ch;
color: var(--muted);
font-size: clamp(1rem, 1.6vw, 1.13rem);
}
}
@keyframes hero-gradient-pan {
to { background-position-x: 200%; }
}
.search-wrap {
position: relative;
max-width: 620px;
margin: 34px auto 0;
svg {
position: absolute;
left: 19px;
top: 50%;
translate: 0 -50%;
color: var(--faint);
pointer-events: none;
}
}
.search-input {
width: 100%;
height: 58px;
padding: 0 52px 0 52px;
border-radius: 16px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
font-size: 1rem;
transition: border-color 0.15s, box-shadow 0.2s;
&::placeholder { color: var(--faint); }
&:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
}
.search-kbd {
position: absolute;
right: 16px;
top: 50%;
translate: 0 -50%;
font-size: 0.72rem;
font-weight: 600;
color: var(--faint);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 6px;
padding: 2px 7px;
background: var(--surface-2);
pointer-events: none;
}
.hero-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 22px;
flex-wrap: wrap;
}
// ---------- Section / results meta ----------
.results-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin: 10px 0 18px;
h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text);
}
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
button {
color: inherit;
opacity: 0.75;
display: grid;
place-items: center;
&:hover { opacity: 1; }
}
}

View File

@@ -0,0 +1,193 @@
@use 'mixins' as *;
// ---------- Languages ----------
// the round accent badge a language code wears, on its own or beside a globe
@mixin lang-pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent);
font-weight: 700;
white-space: nowrap;
}
.lang-badge { @include lang-pill; font-size: 0.72rem; letter-spacing: 0.02em; }
// the language link in an article's eyebrow — click to see that language only
.article-lang {
color: var(--accent);
font-weight: 600;
text-decoration: none;
border-bottom: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent);
&:hover { border-bottom-style: solid; }
}
// the quiet label that introduces a row of language/version chips
.lang-row-label,
.article-langs-label,
.version-bar-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
margin-right: 2px;
}
// language filter chips on the articles index
.lang-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 2px 0 18px;
}
.lang-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
transition: border-color 0.15s, background-color 0.15s;
&:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
&.active {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
}
// the version switcher of an article in the reader (links, not buttons)
a.lang-chip { text-decoration: none; }
.article-langs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
// the editor's notice while translating an article
.translate-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin-bottom: 18px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, transparent);
color: var(--muted);
font-size: 0.9rem;
svg { color: var(--accent); flex: none; }
a {
color: var(--accent);
font-weight: 650;
text-decoration: none;
&:hover { text-decoration: underline; }
}
}
// the editor's own version bar: the article's languages as chips, with the
// unsaved ones wearing a dot, and a dropdown to add another language
.version-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
.version-add { position: relative; }
.version-dropdown {
@include dropdown-panel;
left: 0;
min-width: 190px;
max-height: 280px;
}
// the card of an article that exists in several languages: the codes ride
// together beside a globe, so one card says the whole story
.card-langs {
@include lang-pill;
gap: 5px;
font-size: 0.66rem;
letter-spacing: 0.04em;
svg { opacity: 0.85; }
}
// the reader's language menu: a button on the article's actions, opening a
// list of every language — the ones to read in, and the ones left to write
.lang-menu { position: relative; }
.lang-menu-caret { opacity: 0.6; }
.lang-menu-dropdown {
@include dropdown-panel($z: 30);
right: 0;
min-width: 220px;
max-height: 340px;
text-align: left;
}
.lang-menu-section { @include eyebrow(0.68rem, 700, 0.06em); padding: 7px 10px 4px; }
.lang-menu-gap { margin-top: 5px; border-top: 1px solid var(--border); padding-top: 9px; }
.lang-menu-item {
@include menu-row(7px 10px, 9px);
text-decoration: none;
&:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.lmi-sub {
margin-left: auto;
max-width: 112px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.72rem;
font-weight: 500;
color: var(--faint);
}
.lmi-check { flex: none; color: var(--accent); }
}
.lang-menu-write {
.lmi-pencil {
margin-left: auto;
flex: none;
color: var(--faint);
transition: color 0.12s;
}
&:hover .lmi-pencil { color: var(--accent); }
}

View File

@@ -0,0 +1,283 @@
@use 'mixins' as *;
// ---------- Layout shell ----------
.app-shell { display: flex; min-height: 100vh; }
.app-main { flex: 1; min-width: 0; }
// ---------- Sidebar (site navigation) ----------
.site-sidebar {
position: sticky;
top: 0;
z-index: 60;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: var(--sidebar-w);
height: 100vh;
padding: 22px 18px;
backdrop-filter: blur(14px);
background: color-mix(in srgb, var(--bg) 78%, transparent);
border-right: 1px solid var(--border);
transition: width 0.22s ease, padding 0.22s ease;
}
.sidebar-nav {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
margin-top: 26px;
.btn { justify-content: center; margin-top: 6px; }
}
.sidebar-footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
margin-top: auto;
// the footer buttons read as nav rows — icon then label, centred, taking the
// sidebar's width so they grow and shrink with it (both classes are grid, which
// would drop the label under the icon, and the label shows on mobile too, so
// the row layout lives outside the desktop block)
.panel-icon-btn,
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.86rem;
font-weight: 550;
white-space: nowrap;
svg { flex-shrink: 0; }
}
}
// The account row: who the wiki is being written as. It reads as one of the
// sidebar's rows — an initial in a circle, the name while the sidebar has room
// for it, and the way out at the end of the line.
.account-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--muted);
font-size: 0.86rem;
font-weight: 550;
white-space: nowrap;
}
.account-avatar { @include avatar(30px, 0.84rem); }
// the row's avatar and name together form the button that opens the profile
// popup; the exit button beside it stays its own
.account-open {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex: 1;
min-width: 0;
border-radius: 9px;
text-align: left;
&:hover .account-avatar { background: color-mix(in srgb, var(--accent) 24%, transparent); }
&:hover .account-name { color: var(--accent); }
}
.account-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.account-exit {
@include icon-target(24px, 7px);
color: var(--faint);
transition: color 0.15s, background-color 0.15s;
&:hover { background: var(--surface-2); color: var(--danger); }
}
@media (max-width: 900px) {
.app-shell { flex-direction: column; }
.site-sidebar {
width: 100%;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
row-gap: 10px;
padding: 12px clamp(16px, 4vw, 32px);
border-right: 0;
border-bottom: 1px solid var(--border);
}
.sidebar-nav {
flex-direction: row;
align-items: center;
margin-top: 0;
.btn { margin-top: 0; }
}
.sidebar-search { width: auto; min-width: 132px; max-width: 240px; }
.sidebar-footer { margin-top: 0; flex-direction: row; align-items: center; }
// the rail is a desktop affordance — the top bar is always expanded
.sidebar-collapse-btn { display: none; }
}
// ---------- Collapsed sidebar: an icon rail (desktop only) ----------
.sidebar-collapse-btn svg { transition: transform 0.22s ease; }
@media (min-width: 901px) {
// desktop: stack above the reading surface (book layer, z 70/74) so the
// book's edge shadow falls behind the nav instead of darkening it
.site-sidebar { z-index: 75; }
// on desktop they fill the sidebar's width instead of their own: the collapsing
// sidebar relayouts them every frame, the rail drops their labels (.hide-collapsed)
// and their fixed heights hold, so the icons ride the centre the whole way
.sidebar-footer .panel-icon-btn,
.sidebar-footer .icon-btn,
.sidebar-footer .account-row {
width: 100%;
}
// the root class also moves the fixed reading surface in step, since
// --sidebar-w feeds the book layer's left inset
html.sidebar-collapsed { --sidebar-w: 68px; }
.site-sidebar.collapsed {
padding-inline: 12px;
.sidebar-collapse-btn svg { transform: rotate(180deg); }
// with the labels gone, center what's left: the icons. The buttons keep
// the height the labels used to give them (font-size x line-height 1.6
// + their vertical padding), so the rail doesn't shrink them
.sidebar-search {
justify-content: center;
padding-inline: 0;
min-height: 40px; // 9+9 padding + 13.76px x 1.6 label line
}
.nav-link {
justify-content: center;
padding-inline: 0;
min-height: 39px; // 8+8 padding + 14.4px x 1.6 label line
}
.sidebar-nav .btn {
min-height: 35px; // 7+7 padding + 13.12px x 1.6 label line
}
// the mark is all that's left of the wordmark, so it rides the rail's
// centre like the icons beside it
.logo { justify-content: center; }
// the way out drops away with the labels, leaving just the avatar centred
// on the rail; the avatar sets the row's height now, so the rail doesn't
// shrink it
.account-row {
min-height: 44px; // 1+1 border + 6+6 padding + 30px avatar
}
}
// collapsing drops the labels at once; expanding waits for the rail to
// open before fading them back in, so they never render squished at
// half width mid-transition
.hide-collapsed { transition: opacity 0.16s ease 0.1s, display 0.26s allow-discrete; }
@starting-style {
.hide-collapsed { opacity: 0; }
}
html.sidebar-collapsed .hide-collapsed { display: none; transition: none; }
}
// ---------- "Open" articles: the count beside the button that shows them ----------
.sidebar-open.is-current {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.tabs-count {
margin-left: auto;
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.68rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.logo {
display: flex;
align-items: center;
gap: 11px;
text-decoration: none;
color: var(--text);
font-weight: 800;
font-size: 1.15rem;
letter-spacing: -0.02em;
white-space: nowrap;
}
// a 40px square — the height the nav rows settle at (the search bar's own
// padding + label line), so the sigma reads as one of the sidebar's buttons
.logo-mark {
width: 40px;
height: 40px;
border-radius: 11px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
font-size: 1.4rem;
font-weight: 700;
box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, transparent);
}
.logo-accent { @include gradient-text; }
.nav-link {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
border-radius: 9px;
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 550;
@include hover-row;
&.router-link-active {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
}
// small icon button used in modal/popup headers
.panel-icon-btn { @include icon-target(30px, 8px); color: var(--muted); @include hover-row; }

View File

@@ -0,0 +1,211 @@
// Shared shapes the rest of the design is cut from. Colours stay CSS custom
// properties throughout — [data-theme]/[data-accent] swap them at runtime.
// the dark backdrop a popup opens onto ($pad-y: how far down the sheet sits)
@mixin overlay($z, $pad-y: clamp(56px, 13vh, 132px)) {
position: fixed;
inset: 0;
z-index: $z;
display: flex;
justify-content: center;
align-items: flex-start;
padding: $pad-y 16px 16px;
background: color-mix(in srgb, #05070d 55%, transparent);
backdrop-filter: blur(3px);
}
// the paper dropped under a backdrop
@mixin sheet($w, $max-h: null) {
display: flex;
flex-direction: column;
width: min($w, 100%);
@if $max-h {
max-height: $max-h;
}
border: 1px solid var(--border);
border-radius: 16px;
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
// a popup's title row: the title, and the × at the end of it
@mixin sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px 13px 18px;
border-bottom: 1px solid var(--border);
}
@mixin sheet-title {
margin: 0;
font-size: 1.02rem;
font-weight: 700;
}
// the scrolling middle of a popup
@mixin sheet-body($pad: 18px) {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: $pad;
scrollbar-width: thin;
}
// the opening animation every popup shares: the backdrop fades, the sheet
// slides in from the edge it hangs nearest ($offset is where it starts)
@mixin popup-anim($name, $sheet, $dur, $sheet-move, $offset) {
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: opacity $dur ease;
#{$sheet} { transition: $sheet-move; }
}
.#{$name}-enter-from,
.#{$name}-leave-to {
opacity: 0;
#{$sheet} { transform: translateY($offset) scale(0.98); }
}
}
// a menu that drops from a button: the popups' paper, on a hinge
// ($overflow: `auto` once it caps at a max-height; `visible` may not clip
// a nested dropdown)
@mixin dropdown-panel($z: 20, $overflow: auto) {
position: absolute;
z-index: $z;
top: calc(100% + 6px);
padding: 6px;
overflow-y: $overflow;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
}
// a row inside a dropdown: full width, icon then label, an accent wash on hover
@mixin menu-row($pad, $gap, $fs: 0.85rem) {
display: flex;
align-items: center;
gap: $gap;
width: 100%;
padding: $pad;
border-radius: 8px;
color: var(--text);
font-size: $fs;
font-weight: 600;
transition: background-color 0.12s;
}
// the small caps label over a section of the interface
@mixin eyebrow($fs: 0.72rem, $fw: 750, $ls: 0.1em, $color: var(--faint)) {
font-size: $fs;
font-weight: $fw;
text-transform: uppercase;
letter-spacing: $ls;
color: $color;
}
// the account's face in a circle: an initial, or a picture laid over it
@mixin avatar($size, $fs) {
display: grid;
place-items: center;
flex-shrink: 0;
width: $size;
height: $size;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: $fs;
font-weight: 800;
line-height: 1;
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
}
// a square target that centres its icon
@mixin icon-target($size, $radius) {
display: grid;
place-items: center;
flex-shrink: 0;
width: $size;
height: $size;
border-radius: $radius;
}
// a quiet row that warms on hover (nav links, icon buttons)
@mixin hover-row {
transition: color 0.15s, background-color 0.15s;
&:hover { color: var(--text); background: var(--surface-2); }
}
// a row of choices styled as one segmented control
@mixin segmented {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
@mixin segmented-tab($pad, $fs) {
padding: $pad;
border-radius: 9px;
background: transparent;
color: var(--muted);
font-size: $fs;
font-weight: 600;
transition: color 0.15s, background-color 0.15s, box-shadow 0.15s;
&:hover { color: var(--text); }
&.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
}
// the loading shimmer a skeleton wears
@mixin shimmer {
background: linear-gradient(
100deg,
var(--surface) 40%,
color-mix(in srgb, var(--surface) 55%, var(--text) 9%) 50%,
var(--surface) 60%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite linear;
}
// paint text with a gradient between the two accents
@mixin gradient-text($angle: 90deg) {
background: linear-gradient($angle, var(--accent), var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
// a row of the book: no gutter along its edge (a book has none)
@mixin no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// the × that closes a page or a chip: faint, and turns danger on hover
@mixin close-target {
&:hover {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
}

View File

@@ -0,0 +1,91 @@
@use 'mixins' as *;
// ---------- 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.
.profile-overlay { @include overlay(120); }
.profile-modal {
@include sheet(460px, min(620px, calc(100vh - 110px)));
&:focus, &:focus-visible { outline: none; }
}
.profile-modal-head { @include sheet-head; }
.profile-title { @include sheet-title; }
.profile-modal-body { @include sheet-body; }
.profile-group + .profile-group {
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--border);
}
.profile-group-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
}
.profile-group-title { font-size: 0.92rem; font-weight: 700; color: var(--text); }
.profile-group-hint { font-size: 0.8rem; color: var(--muted); }
// the big avatar beside its buttons
.profile-avatar-row { display: flex; align-items: center; gap: 18px; }
.profile-avatar { @include avatar(72px, 1.7rem); }
.profile-avatar-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
// the file chooser is driven by the buttons above it, never shown itself
.profile-file-input { display: none; }
.profile-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
input {
flex: 1;
min-width: 0;
padding: 10px 13px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-elev);
font-size: 0.88rem;
transition: border-color 0.15s, box-shadow 0.2s;
&:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
}
// the password pair stack one per line, the button below them
&-password input { flex: 1 1 100%; }
}
.profile-saved {
margin: 8px 0 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
}
.profile-signout { margin-top: 2px; }
@include popup-anim(
'profile-popup',
'.profile-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);

View File

@@ -0,0 +1,167 @@
@use 'mixins' as *;
// ---------- Prose (rendered markdown) ----------
.prose {
font-size: 1.02rem;
line-height: 1.75;
color: var(--text);
overflow-wrap: break-word;
> :first-child { margin-top: 0; }
> :first-child > :first-child, h1:first-child { margin-top: 0.6em; }
h1,
h2,
h3,
h4 {
scroll-margin-top: 90px;
line-height: 1.25;
letter-spacing: -0.02em;
font-weight: 730;
}
h1 { font-size: 1.85rem; margin: 1.4em 0 0.5em; }
h2 {
font-size: 1.42rem;
margin: 2.1em 0 0.55em;
padding-bottom: 0.32em;
border-bottom: 1px solid var(--border);
}
h3 { font-size: 1.17rem; margin: 1.7em 0 0.45em; }
h4 { font-size: 1.02rem; margin: 1.5em 0 0.4em; }
p { margin: 0.95em 0; }
a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
transition: border-color 0.15s, color 0.15s;
&:hover { color: var(--accent-strong); border-bottom-color: currentColor; }
// internal wiki links written as [[Article Title]]
&.wikilink { border-bottom-style: dotted; }
}
strong { font-weight: 730; }
ul, ol { padding-left: 1.45em; margin: 0.9em 0; }
li {
margin: 0.35em 0;
&::marker { color: var(--faint); }
}
blockquote {
margin: 1.4em 0;
padding: 0.75em 1.2em;
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
border-radius: 0 12px 12px 0;
color: var(--muted);
p { margin: 0.2em 0; }
}
hr { border: 0; border-top: 1px solid var(--border); margin: 2.4em 0; }
table {
display: block;
width: max-content;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 1.5em 0;
font-size: 0.94em;
border: 1px solid var(--border);
border-radius: 12px;
}
thead { background: var(--surface-2); }
th, td { padding: 0.58em 0.95em; border: 1px solid var(--border); text-align: left; }
tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface) 55%, transparent); }
img { max-width: 100%; border-radius: 12px; }
kbd {
font-family: inherit;
font-size: 0.8em;
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 6px;
padding: 1px 6px;
background: var(--surface-2);
}
// inline code
code {
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
padding: 0.12em 0.42em;
border-radius: 6px;
font-size: 0.86em;
}
pre {
margin: 1.4em 0;
padding: 17px 20px;
border-radius: 13px;
border: 1px solid var(--border);
background: var(--code-bg);
overflow-x: auto;
line-height: 1.65;
font-size: 0.875rem;
code {
background: none;
border: 0;
padding: 0;
font-size: inherit;
}
}
}
// code tokens (highlight.js)
.hljs-keyword, .hljs-selector-tag, .hljs-doctag { color: var(--tok-key); }
.hljs-string, .hljs-regexp, .hljs-addition { color: var(--tok-str); }
.hljs-comment { color: var(--tok-com); font-style: italic; }
.hljs-number, .hljs-variable, .hljs-template-variable { color: var(--tok-num); }
.hljs-title, .hljs-title.function_, .hljs-section { color: var(--tok-fn); }
.hljs-built_in,
.hljs-type,
.hljs-class .hljs-title,
.hljs-title.class_ {
color: var(--tok-built);
}
.hljs-literal, .hljs-symbol, .hljs-bullet, .hljs-link { color: var(--tok-lit); }
.hljs-attr, .hljs-attribute, .hljs-property, .hljs-meta { color: var(--tok-meta); }
.hljs-deletion { color: var(--danger); }
// ---------- KaTeX ----------
.katex { font-size: 1.06em; }
.prose .katex-display {
margin: 1.35em 0;
padding: 0.3em 0.2em;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.prose .katex-error { color: var(--danger); font-family: inherit; }

View File

@@ -0,0 +1,182 @@
@use 'mixins' as *;
// ---------- Search (sidebar bar + centered popup) ----------
.sidebar-search {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--faint);
font-size: 0.86rem;
font-weight: 550;
text-align: left;
box-shadow: var(--shadow-sm);
transition: border-color 0.15s, color 0.15s;
&:hover { border-color: var(--border-strong); color: var(--muted); }
svg { flex-shrink: 0; }
}
.sidebar-search-label {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.kbd-hint {
font-family: inherit;
font-size: 0.68rem;
font-weight: 600;
color: var(--faint);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 5px;
padding: 0 5px;
background: var(--surface-2);
white-space: nowrap;
}
.search-overlay { @include overlay(120); }
.search-modal { @include sheet(620px, min(560px, calc(100vh - 110px))); }
.search-modal-field {
display: flex;
align-items: center;
gap: 11px;
padding: 13px 16px;
border-bottom: 1px solid var(--border);
svg { flex-shrink: 0; color: var(--faint); }
}
.search-modal-input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font-size: 1.02rem;
&:focus, &:focus-visible { outline: none; box-shadow: none; }
&::placeholder { color: var(--faint); }
}
.search-spinner {
flex-shrink: 0;
width: 15px;
height: 15px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-top-color: var(--accent);
animation: search-spin 0.7s linear infinite;
}
@keyframes search-spin {
to { transform: rotate(1turn); }
}
.search-modal-body {
@include sheet-body(8px);
.banner { margin: 8px 8px 10px; }
}
.search-results { list-style: none; margin: 0; padding: 0; }
.search-result {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
border-radius: 11px;
text-decoration: none;
color: inherit;
transition: background-color 0.12s;
&.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
}
.sr-title {
font-weight: 650;
font-size: 0.94rem;
line-height: 1.35;
mark {
background: color-mix(in srgb, var(--accent) 30%, transparent);
color: inherit;
border-radius: 3px;
padding: 0 1px;
}
}
.sr-meta {
display: flex;
align-items: center;
gap: 10px;
margin-top: 1px;
font-size: 0.74rem;
color: var(--faint);
}
.search-quick-title { @include eyebrow; margin: 6px 8px 4px; }
.search-none {
text-align: center;
padding: 32px 16px;
color: var(--muted);
font-size: 0.88rem;
strong { color: var(--text); }
}
.search-skeleton {
height: 54px;
margin: 4px;
border-radius: 11px;
@include shimmer;
}
.search-modal-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 14px;
border-top: 1px solid var(--border);
background: var(--surface);
font-size: 0.75rem;
color: var(--faint);
}
.foot-hints {
display: flex;
align-items: center;
gap: 12px;
.kbd-hint { margin-right: 4px; }
}
.foot-all {
color: var(--accent);
text-decoration: none;
font-weight: 600;
white-space: nowrap;
&:hover { text-decoration: underline; }
}
@include popup-anim(
'search-popup',
'.search-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);

View File

@@ -0,0 +1,73 @@
@use 'mixins' as *;
// ---------- Settings popup ----------
.settings-overlay { @include overlay(120); }
.settings-modal {
@include sheet(460px, min(560px, calc(100vh - 110px)));
&:focus, &:focus-visible { outline: none; }
}
.settings-modal-head { @include sheet-head; }
.settings-title { @include sheet-title; }
.settings-modal-body { @include sheet-body; }
.settings-group + .settings-group {
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--border);
}
.settings-group-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
}
.settings-group-title { font-size: 0.92rem; font-weight: 700; color: var(--text); }
.settings-group-hint { font-size: 0.8rem; color: var(--muted); }
// segmented light / dark switch
.theme-switch { @include segmented; }
.theme-option {
display: inline-flex;
align-items: center;
gap: 7px;
@include segmented-tab(8px 16px, 0.86rem);
}
// accent colour swatches
.accent-swatches { display: flex; flex-wrap: wrap; gap: 14px; }
.accent-swatch {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 0;
padding: 0;
color: #fff;
background: linear-gradient(135deg, var(--swatch), var(--swatch-2));
transition: transform 0.12s, box-shadow 0.15s;
&:hover { transform: scale(1.1); }
&:active { transform: scale(0.94); }
&.active { box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--swatch); }
}
@include popup-anim(
'settings-popup',
'.settings-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);

View File

@@ -0,0 +1,46 @@
@use 'mixins' as *;
// ---------- States ----------
.empty {
text-align: center;
padding: 72px 20px;
color: var(--muted);
.glyph {
font-size: 3.4rem;
line-height: 1;
margin-bottom: 14px;
opacity: 0.65;
}
h3 { margin: 0 0 6px; color: var(--text); font-size: 1.15rem; }
p { margin: 0 auto 22px; max-width: 42ch; font-size: 0.93rem; }
}
.banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 15px 18px;
border-radius: 13px;
border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border));
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
font-size: 0.9rem;
margin: 14px 0;
.glyph { color: var(--danger); font-weight: 800; }
code {
background: color-mix(in srgb, var(--text) 8%, transparent);
padding: 0.1em 0.4em;
border-radius: 5px;
font-size: 0.85em;
}
}
.skeleton { border-radius: 16px; height: 172px; @include shimmer; }
@keyframes shimmer {
to { background-position-x: -200%; }
}

View File

@@ -0,0 +1,110 @@
@use 'sass:list';
// ---------- Theme tokens ----------
// [data-theme] swaps the surfaces, [data-accent] the accents (no attribute
// = violet, the defaults below). Reading-surface sizes live here too: the
// sidebar's width feeds the book layer's left inset (see _book.scss).
:root {
color-scheme: light;
--bg: #f7f8fb;
--bg-elev: #ffffff;
--surface: #ffffff;
--surface-2: #eef1f7;
--border: #e2e6ef;
--border-strong: #cfd5e3;
--text: #1b2130;
--muted: #5b6478;
--faint: #8a93a8;
--accent: #4f5fe8;
--accent-strong: #3d4dd6;
--accent-2: #0ea5c9;
--danger: #d1344b;
--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%);
--ring: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
// code token colors (light)
--tok-key: #8250df;
--tok-str: #0a3069;
--tok-com: #6e7781;
--tok-num: #0550ae;
--tok-fn: #6639ba;
--tok-built: #953800;
--tok-lit: #cf222e;
--tok-meta: #7d4e00;
// the reader's book (see _book.scss)
--sidebar-w: 232px;
--spine-w: 34px;
// how wide a page of the book is allowed to get: past this the desk shows
// beside the paper rather than the page stretching on and on. The one page
// of a book with nothing else in it is exempt (see .book-pane:only-child).
--pane-max-w: 60rem;
}
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #0a0c12;
--bg-elev: #10131c;
--surface: #141826;
--surface-2: #1a2030;
--border: #232a3c;
--border-strong: #313a52;
--text: #e7eaf3;
--muted: #99a1b5;
--faint: #6b7488;
--accent: #7c8cff;
--accent-strong: #99a5ff;
--accent-2: #38d3ee;
--danger: #f47085;
--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%);
// code token colors (dark)
--tok-key: #c792ea;
--tok-str: #9ece6a;
--tok-com: #565f89;
--tok-num: #ff9e64;
--tok-fn: #7aa2f7;
--tok-built: #2ac3de;
--tok-lit: #ff757f;
--tok-meta: #e0af68;
}
// ---------- Accent colors ----------
// [data-accent] on <html> swaps the accent tokens (accent, accent-strong,
// accent-2). Every colour needs a light and a dark variant — the paired
// [data-theme='dark'] selector outranks the plain one, so it follows it.
$accents: (
'purple': ((#7c3aed, #6d28d9, #c026d3), (#a78bfa, #c4b5fd, #e879f9)),
'ocean': ((#2563eb, #1d4ed8, #06b6d4), (#60a5fa, #93c5fd, #22d3ee)),
'teal': ((#0d9488, #0f766e, #10b981), (#2dd4bf, #5eead4, #34d399)),
'forest': ((#16a34a, #15803d, #84cc16), (#4ade80, #86efac, #a3e635)),
'amber': ((#d97706, #b45309, #f97316), (#fbbf24, #fcd34d, #fb923c)),
'rose': ((#e11d48, #be123c, #ec4899), (#fb7185, #fda4af, #f472b6)),
'slate': ((#334155, #1e293b, #64748b), (#94a3b8, #cbd5e1, #7b8aa1)),
);
@each $name, $pair in $accents {
$light: list.nth($pair, 1);
$dark: list.nth($pair, 2);
:root[data-accent='#{$name}'] {
--accent: #{list.nth($light, 1)};
--accent-strong: #{list.nth($light, 2)};
--accent-2: #{list.nth($light, 3)};
}
:root[data-theme='dark'][data-accent='#{$name}'] {
--accent: #{list.nth($dark, 1)};
--accent-strong: #{list.nth($dark, 2)};
--accent-2: #{list.nth($dark, 3)};
}
}
// the desk is the page itself: no sidebar width to stand aside for
@media (max-width: 900px) {
:root { --sidebar-w: 0px; --spine-w: 30px; }
}

View File

@@ -0,0 +1,10 @@
// ---------- Nuxt route transitions ----------
.fade-enter-active, .fade-leave-active { transition: opacity 0.16s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
// shared between the plain pages — the reader doesn't use it: its pages
// move inside the open book (see .book-pane in _book.scss)
.page-enter-active, .page-leave-active { transition: opacity 0.16s ease; }
.page-enter-from, .page-leave-to { opacity: 0; }

View File

@@ -0,0 +1,28 @@
// ============================================================
// Mathew — the mathematics wiki
// Hand-written design system: light/dark themes, layout,
// article typography (prose), editor, code + KaTeX styling.
// Split into partials, one per section; order mirrors the old CSS.
// ============================================================
@use 'fonts';
@use 'tokens';
@use 'base';
@use 'layout'; // shell, sidebar, account row, collapsed rail
@use 'search'; // sidebar bar + the ⌘K popup
@use 'settings';
@use 'profile';
@use 'auth'; // sign-in popup + the editor's gate
@use 'buttons'; // .btn family + .icon-btn
@use 'home'; // hero, results bar
@use 'cards'; // article cards, tags
@use 'articles'; // topics index bits
@use 'langs'; // badges, chips, version & language menus
@use 'states'; // empty, banner, skeleton
@use 'admin'; // the accounts dashboard
@use 'article'; // article page, ⋮ menu, TOC, recently viewed
@use 'prose'; // rendered markdown, highlight.js, KaTeX
@use 'editor'; // editor, cheatsheet, confirm, fullscreen
@use 'book'; // the reader: an open book of article pages
@use 'graph'; // the link map
@use 'transitions'; // route fades, scrollbars

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
export interface ArticleTab {
slug: string
title: string
/** the language version this page holds */
lang?: string
/** the article this document is a version of — its canonical slug */
identity?: string
}
const MAX_TABS = 10
/** The address a page of the book was read from: the article's own URL for
* the canonical version, /wiki/<lang>/<slug> for the others. */
export function tabHref(tab: Pick<ArticleTab, 'slug' | 'lang' | 'identity'>): string {
const identity = tab.identity ?? tab.slug
return versionHref(identity, { language: tab.lang, slug: tab.slug })
}
/** Which page of the stack an address points at, if any. */
export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null {
const loc = parseReaderPath(path)
if (!loc) return null
const exact = tabs.find((t) => {
const identity = t.identity ?? t.slug
if (loc.lang) return identity === loc.identity && (t.lang ?? 'en') === loc.lang
return identity === loc.identity && t.slug === loc.identity
})
/* an address naming a document directly (older /slug-fr form) still finds it */
return exact ?? tabs.find((t) => t.slug === loc.identity) ?? null
}
/**
* The session stack of articles opened in the reader — the pages of the
* book, in the order they were opened, newest at the right. The page
* currently in view is simply wherever the route points; the stack lives in
* shared state and survives every client navigation (reset on reload), so the
* open pages are still there when you come back to the reading surface.
*/
export function useArticleTabs() {
const route = useRoute()
const router = useRouter()
const tabs = useState<ArticleTab[]>('article-tabs', () => [])
/* the last page seen outside the reading surface, so closing the last tab
returns there instead of always going home */
const origin = useState<string>('article-tabs:origin', () => '')
/* the page last in view, so the stack can be found again from any page that
hides the reading surface — the editor, a new article, the graph */
const lastSlug = useState<string>('article-tabs:last', () => '')
function ensureTab(
slug: string,
title: string,
meta?: { lang?: string; identity?: string }
) {
const existing = tabs.value.find((t) => t.slug === slug)
if (existing) {
existing.title = title // follow the tab label after a rename
if (meta?.lang) existing.lang = meta.lang
if (meta?.identity) existing.identity = meta.identity
return
}
const stack = [...tabs.value, { slug, title, lang: meta?.lang, identity: meta?.identity }]
if (stack.length > MAX_TABS) {
/* don't stack forever — retire the oldest tab that isn't in view */
const drop = stack.findIndex((t) => t.slug !== slug)
if (drop !== -1) stack.splice(drop, 1)
}
tabs.value = stack
}
/* Close a page. Closing one that isn't in view just takes it out of the
stack and the rest settle; closing the page in view turns back to the
page before it — or returns to the last page outside the book once the
last page is gone. */
function closeTab(slug: string) {
const at = tabs.value.findIndex((t) => t.slug === slug)
const isActive = tabAt(tabs.value, route.path)?.slug === slug
tabs.value = tabs.value.filter((t) => t.slug !== slug)
if (isActive) {
const back = tabs.value[Math.max(0, Math.min(at - 1, tabs.value.length - 1))]
void router.push(back ? tabHref(back) : origin.value || '/')
}
}
return { tabs, origin, lastSlug, ensureTab, closeTab }
}
/**
* Keeps the book's stack of pages in step with the route: which page was last
* in view, and the last plain page outside the reading surface. The stack
* itself is left alone — it survives the whole session, so wandering off to
* the graph or the article list and coming back finds the pages as they were.
* Call once, high in the app.
*/
export function useArticleTabsSync() {
const route = useRoute()
const { tabs, lastSlug, origin } = useArticleTabs()
watch(
() => route.fullPath,
(to) => {
/* filed under the tab's own slug, so a language version is found again
as the version it was, not merely as the article */
if (isReaderPath(to)) lastSlug.value = tabAt(tabs.value, to)?.slug ?? wikiSlugFromPath(to) ?? ''
else if (!isReadingSurface(to)) origin.value = to
},
{ immediate: true }
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
/* 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. */
/** 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 }
}
export default defineNuxtRouteMiddleware(async (to) => {
const loc = parseReaderPath(to.path)
if (!loc) return
/* taken before any await, while the app context is certainly at hand */
const library = useArticleLibrary()
const tabs = useArticleTabs()
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 = loc.lang
? await library.loadVersion(loc.identity, loc.lang)
: await library.load(loc.identity)
if (article) tabs.ensureTab(article.slug, article.title, tabMeta(article))
})

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
/* 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. */
import type { AuthRole } from '~/composables/useAuth'
export interface AdminAccount {
id: string
username: string
avatar: string
role: AuthRole
createdAt: string
}
/* 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 { user, loggedIn, isAdmin, authHeaders, signOut } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t, locale } = 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 profile popup 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('admin: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')
}
/* 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 list with
it, which is what the watch below is for. */
const {
data: accounts,
status,
refresh,
} = await useAsyncData(
'admin-accounts',
async () => {
failure.value = ''
if (!isAdmin.value) return []
try {
const { accounts: list } = await $fetch<{ accounts: AdminAccount[] }>(
`${api}/admin/users`,
{ headers: authHeaders() }
)
return list
} catch (err: any) {
failure.value = describeFailure(err)
return []
}
},
{ default: () => [] }
)
watch(isAdmin, (admin) => {
if (admin) void refresh()
})
const loading = computed(() => status.value === 'pending')
const adminCount = computed(() => accounts.value.filter((a) => a.role === 'admin').length)
/* --- handing the role on, taking it back, closing an account ---
One change at a time: while a request is in flight the menu waits, so two
clicks cannot disagree about what an account is. */
const changingId = ref('')
const removing = ref(false)
const actionError = ref('')
async function setRole(account: AdminAccount, role: AuthRole) {
if (changingId.value || removing.value) return
actionError.value = ''
changingId.value = account.id
try {
const { account: updated } = await $fetch<{ account: AdminAccount }>(
`${api}/admin/users/${account.id}/role`,
{ method: 'PUT', headers: authHeaders(), body: { role } }
)
/* the whole list is written again rather than the one row changed in
place: useAsyncData keeps it shallow, so a row would not notice */
accounts.value = accounts.value.map((a) => (a.id === updated.id ? updated : a))
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.roleChangeFailed')
} finally {
changingId.value = ''
}
}
/* Deleting is the one thing on this page that does not come back, so the menu
hands it to the yes/no popup first: the account awaiting an answer, null
when nothing is. */
const removalTarget = ref<AdminAccount | null>(null)
async function removeAccount() {
const account = removalTarget.value
if (!account || removing.value || changingId.value) return
actionError.value = ''
removing.value = true
try {
await $fetch(`${api}/admin/users/${account.id}`, {
method: 'DELETE',
headers: authHeaders()
})
accounts.value = accounts.value.filter((a) => a.id !== account.id)
removalTarget.value = null
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.deleteFailed')
} finally {
removing.value = false
}
}
/* esc answers the yes/no popup the way its Cancel button does: the dashboard has
no other use for the key. */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !removalTarget.value || removing.value) return
removalTarget.value = null
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
/* "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 joined(iso: string): string {
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)
}
useSeoMeta({
title: () => t('admin.metaTitle'),
description: () => t('admin.metaDescription'),
})
</script>
<template>
<div class="home-main">
<!-- Nobody but an admin is given the list: 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('admin.gateHeading')"
:text="t('admin.gateText')"
/>
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="admin-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="admin-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
<p class="auth-gate-text">
{{ t('admin.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('admin.hero')"></h1>
<p class="sub">{{ t('admin.sub') }}</p>
</section>
<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>
<template v-if="loading">{{ t('common.loading') }}</template>
<template v-else>
{{ t('admin.accountCount', accounts.length) }}
</template>
</h2>
<span v-if="!loading && accounts.length" class="topic-tally">
{{ t('admin.adminTally', adminCount) }} ·
{{ t('admin.memberTally', accounts.length - adminCount) }}
</span>
</div>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<div v-if="loading" class="account-list">
<div v-for="i in 4" :key="i" class="skeleton skeleton-row" />
</div>
<ul v-else-if="accounts.length" class="account-list">
<li v-for="account in accounts" :key="account.id" class="account-entry">
<span class="account-avatar" aria-hidden="true">
<img v-if="account.avatar" :src="account.avatar" alt="" />
<template v-else>{{ account.username.charAt(0).toUpperCase() }}</template>
</span>
<span class="account-entry-name">
{{ account.username }}
<span v-if="account.id === user?.id" class="account-entry-you">{{ t('admin.you') }}</span>
</span>
<span class="role-pill" :class="{ 'is-admin': account.role === 'admin' }">
{{ account.role === 'admin' ? t('admin.roleAdmin') : t('admin.roleMember') }}
</span>
<span class="account-entry-since">{{ t('admin.joined', { date: joined(account.createdAt) }) }}</span>
<!-- an admin changing their own role, or closing their own account,
is what the API will not do — so the row says it where the ⋮
would go, instead of offering a menu that could only fail -->
<span v-if="account.id === user?.id" class="account-entry-note">
{{ t('admin.selfNote') }}
</span>
<AccountActionsMenu
v-else
class="account-entry-kebab"
:account-id="account.id"
:username="account.username"
:role="account.role"
:busy="changingId === account.id || removing"
@set-role="setRole(account, $event)"
@remove="removalTarget = account"
/>
</li>
</ul>
<div v-else class="empty">
<div class="glyph">∅</div>
<h3>{{ t('admin.emptyTitle') }}</h3>
<p>{{ t('admin.emptyText') }}</p>
</div>
</template>
</section>
</template>
<ConfirmModal
:open="removalTarget !== null"
:title="t('admin.confirmDeleteTitle')"
:message="removalTarget
? t('admin.confirmDeleteMessage', { username: removalTarget.username })
: ''"
:confirm-label="t('admin.confirmDeleteButton')"
:cancel-label="t('admin.confirmKeepButton')"
@confirm="removeAccount"
@cancel="removalTarget = null"
/>
</div>
</template>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.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 route = useRoute()
const router = useRouter()
const { t } = 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())
watch([tag, lang], ([tv, lv]) => {
const query: Record<string, string> = {}
if (tv) query.tag = tv
if (lv) query.lang = lv
const current = route.query
if ((current.tag ?? '') === (query.tag ?? '') && (current.lang ?? '') === (query.lang ?? '')) return
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]
}
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 }))
})
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
})
})
/* 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 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
}
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)
}
}
/* 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))
useSeoMeta({
title: () => t('articles.metaTitle'),
description: () => t('articles.metaDescription'),
})
</script>
<template>
<div class="home-main">
<section class="hero">
<h1 v-html="t('articles.hero')"></h1>
<p class="sub">
{{ t('articles.sub') }}
</p>
</section>
<section class="section">
<div v-if="error" 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" />
</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>
<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>
<button
v-for="l in languages"
:key="l.code"
type="button"
class="lang-chip"
:class="{ active: lang === l.code }"
:aria-pressed="lang === l.code"
@click="lang = lang === l.code ? '' : l.code"
>
<span :lang="l.code">{{ l.name }}</span>
<span class="topic-count">{{ l.count }}</span>
</button>
</div>
<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>
</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: 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">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
</div>
<div v-if="status === 'pending'" class="articles-grid">
<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>
<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>
</template>
<template v-else>
<h3>{{ t('articles.emptyNoneTitle') }}</h3>
<p>{{ t('articles.emptyNoneText') }}</p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
</template>
</div>
</template>
</section>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { GraphEdgeDto, GraphNodeDto } from '~/components/GraphView.vue'
export interface GraphDto {
nodes: GraphNodeDto[]
edges: GraphEdgeDto[]
}
/* 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 { t } = useI18n()
const { data, status, error } = await useFetch<GraphDto>(`${api}/articles/graph`, {
default: () => ({ nodes: [], edges: [] }),
})
useSeoMeta({
title: () => t('graph.metaTitle'),
description: () => t('graph.metaDescription'),
})
</script>
<template>
<div class="graph-stage">
<div v-if="error" class="graph-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="t('common.apiDown')"></span>
</div>
</div>
<GraphView
v-else-if="status !== 'pending' && data.nodes.length"
:nodes="data.nodes"
:edges="data.edges"
/>
<div v-else-if="status !== 'pending'" class="graph-state">
<div class="empty">
<div class="glyph">∅</div>
<h3>{{ t('graph.emptyTitle') }}</h3>
<p>{{ t('graph.emptyText') }}</p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
/* matches the article page's transition so opening/closing the reader
slides one surface away as the next arrives (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
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
}
/* fetch one article from the API and open it */
const randomPending = ref(false)
async function showRandomArticle() {
if (randomPending.value) return
randomPending.value = true
try {
const article = await $fetch<ArticleMeta>(`${api}/articles/random`)
await router.push(versionHref(identityOf(article), article))
} catch {
// no articles yet or API unreachable — the banner below already covers API issues
} finally {
randomPending.value = false
}
}
/* "/" 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">
<section class="hero">
<h1 v-html="t('home.hero')"></h1>
<div class="search-wrap">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<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')"
:aria-label="t('common.searchArticles')"
/>
<span class="search-kbd">/</span>
</div>
<div class="hero-actions">
<NuxtLink to="/new" class="btn btn-primary">{{ t('home.writeNew') }}</NuxtLink>
<button
type="button"
class="btn btn-ghost"
:disabled="randomPending"
@click="showRandomArticle"
>
{{ t('home.random') }}
</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

@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const { t } = useI18n()
const { th } = useAppLocale()
/* ?translate=<slug> — start the editor from an existing article instead of
from scratch: the text opens ready to rewrite, and the save publishes a new
language version of that same article. */
const translateSlug = computed(() => String(route.query.translate ?? '').trim())
const { data: original, status } = await useFetch<ArticleMeta & { content?: string }>(
() => `${api}/articles/slug/${encodeURIComponent(translateSlug.value)}`,
{ enabled: computed(() => Boolean(translateSlug.value)), default: null }
)
const takenLanguages = computed(() => (original.value?.versions ?? []).map((v) => v.language))
useSeoMeta({
title: () => (original.value ? t('editor.metaTranslate', { title: original.value!.title }) : t('editor.newTitle')),
})
</script>
<template>
<!-- the editor itself only shows with an account behind it (AuthGate) -->
<AuthGate :heading="t('editor.gateStart')">
<ArticleForm
v-if="original"
mode="create"
:translate-from="original"
:taken-languages="takenLanguages"
:key="`translate-${original._id}`"
/>
<template v-else-if="translateSlug">
<div v-if="status === 'pending'" style="padding-top: 48px">
<div class="skeleton" style="height: 40px; width: 40%; margin-bottom: 14px; border-radius: 8px" />
<div class="skeleton" style="height: 420px" />
</div>
<div v-else class="empty" style="padding-top: 96px">
<div class="glyph">∄</div>
<h3>{{ t('editor.nothingToTranslate') }}</h3>
<p v-html="th('editor.noArticleAt', { path: `/wiki/${translateSlug}` })"></p>
<NuxtLink to="/" class="btn btn-primary">{{ t('editor.backToSearch') }}</NuxtLink>
</div>
</template>
<ArticleForm v-else mode="create" />
</AuthGate>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
/* One language version's address — /wiki/fr/bayes-theorem — is a thin host for
the same book page the article itself opens on. The open-article middleware
has already laid this version on the library's shelf, so all that is left to
do here is speak for the URL: the title, the visitor's "recently viewed"
line, and the words said when nothing lives at the address — this article,
or none in this language. */
definePageMeta({ middleware: 'open-article' })
const route = useRoute()
const identity = computed(() => String(route.params.slug))
const lang = computed(() => String(route.params.lang))
const { t } = useI18n()
const { th } = useAppLocale()
const { getVersion, unreadable } = useArticleLibrary()
const article = computed(() => getVersion(identity.value, lang.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 ? `${article.value.title} · ${languageName(lang.value)}` : t('card.kickerArticle'),
})
/* what the shelf could not bring in, the API said why */
const missingStatus = computed(
() => (article.value ? 0 : unreadable.value[versionKey(identity.value, lang.value)] ?? 0)
)
</script>
<template>
<div class="reader-host">
<div v-if="!article" class="reader-note">
<div class="empty" style="padding-top: 64px">
<template v-if="missingStatus === 404">
<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>
</template>
<template v-else>
<div class="glyph">!</div>
<h3>{{ t('reader.loadFailTitle') }}</h3>
<p v-html="t('common.apiShort')"></p>
<NuxtLink to="/" class="btn btn-ghost">{{ t('common.backHome') }}</NuxtLink>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const slug = computed(() => String(route.params.slug))
const { t } = useI18n()
const { th } = useAppLocale()
const { data: article, status, error } = await useFetch<ArticleMeta & { content?: string }>(
`${api}/articles/slug/${encodeURIComponent(slug.value)}`,
{ key: `article-${slug.value}-edit` }
)
useSeoMeta({ title: () => t('editor.metaEditing', { title: article.value?.title ?? slug.value }) })
</script>
<template>
<div>
<div v-if="error" class="empty" style="padding-top: 96px">
<div class="glyph">∄</div>
<h3>{{ t('editor.nothingToEdit') }}</h3>
<p v-html="th('editor.noArticleAt', { path: `/wiki/${slug}` })"></p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('editor.createInstead') }}</NuxtLink>
</div>
<div v-else-if="status === 'pending'" style="padding-top: 48px">
<div class="skeleton" style="height: 40px; width: 40%; margin-bottom: 14px; border-radius: 8px" />
<div class="skeleton" style="height: 420px" />
</div>
<AuthGate v-else-if="article" :heading="t('editor.gateEdit', { title: article.title })">
<ArticleForm mode="edit" :article="article" :key="article._id" />
</AuthGate>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<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. */
definePageMeta({ middleware: 'open-article' })
const route = useRoute()
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))
</script>
<template>
<div class="reader-host">
<div v-if="!article" 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>
</template>
<template v-else>
<div class="glyph">!</div>
<h3>{{ t('reader.loadFailTitle') }}</h3>
<p v-html="t('common.apiShort')"></p>
<NuxtLink to="/" class="btn btn-ghost">{{ t('common.backHome') }}</NuxtLink>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
/* The wiki is open to readers, but writing takes an account, and the page must
know which side of that line it is on before it draws anything: the sidebar's
account row and the editor's sign-in gate are both part of the first render,
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 { user, restoreSession } = useAuth()
await restoreSession()
/* The account's interface language outranks the device's own — the choice
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)
})

View File

@@ -0,0 +1,42 @@
/**
* The languages an article can be written in: an ISO 639-1 code with the
* name of the language in its own script, as the editor's picker lists them.
*/
export const LANGUAGES: ReadonlyArray<{ code: string; name: string }> = [
{ code: 'en', name: 'English' },
{ code: 'fr', name: 'Français' },
{ code: 'de', name: 'Deutsch' },
{ code: 'es', name: 'Español' },
{ code: 'ca', name: 'Català' },
{ code: 'pt', name: 'Português' },
{ code: 'it', name: 'Italiano' },
{ code: 'nl', name: 'Nederlands' },
{ code: 'pl', name: 'Polski' },
{ code: 'ru', name: 'Русский' },
{ code: 'uk', name: 'Українська' },
{ code: 'tr', name: 'Türkçe' },
{ code: 'ar', name: 'العربية' },
{ code: 'fa', name: 'فارسی' },
{ code: 'hi', name: 'हिन्दी' },
{ code: 'zh', name: '中文' },
{ code: 'ja', name: '日本語' },
{ code: 'ko', name: '한국어' },
]
export const DEFAULT_LANGUAGE = 'en'
/** The language an article is written in; articles predating the field are English. */
export function articleLanguage(article: { language?: string }): string {
return article.language?.trim().toLowerCase() || DEFAULT_LANGUAGE
}
/** Display name for a code — the code itself in upper case if it is unfamiliar. */
export function languageName(code: string): string {
return LANGUAGES.find((l) => l.code === code)?.name ?? code.toUpperCase()
}
/** 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())
return LANGUAGES.find((l) => !takenCodes.includes(l.code))?.code ?? DEFAULT_LANGUAGE
}

View File

@@ -0,0 +1,175 @@
/**
* Markdown -> HTML rendering with LaTeX support.
*
* Pipeline: markdown-it (html disabled for safety) + markdown-it-texmath
* (KaTeX for $...$ and $$...$$) + highlight.js for fenced code blocks.
* The result is sanitized with DOMPurify (isomorphic, works during SSR).
*/
import MarkdownIt from 'markdown-it'
import texmath from 'markdown-it-texmath'
import katex from 'katex'
import hljs from 'highlight.js/lib/common'
import DOMPurify from 'isomorphic-dompurify'
import type { StateInline, Token } from 'markdown-it'
export interface TocEntry {
id: string
text: string
level: number
}
export interface RenderedMarkdown {
html: string
toc: TocEntry[]
}
const md = new MarkdownIt({
html: false, // escape raw HTML in articles — safety first
linkify: true,
breaks: false,
highlight(code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
} catch {
/* fall through to plain rendering */
}
}
return ''
},
})
md.use(texmath, {
engine: katex,
delimiters: 'dollars',
katexOptions: { throwOnError: false, strict: false },
macros: {
'\\R': '\\mathbb{R}',
'\\N': '\\mathbb{N}',
'\\Z': '\\mathbb{Z}',
'\\C': '\\mathbb{C}',
'\\Q': '\\mathbb{Q}',
},
})
/** Convert a title to a URL slug — kept in sync with the backend slugify(). */
function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/** Turn heading text into a URL fragment id (kept in sync with slugs). */
function slugifyFragment(text: string): string {
return slugify(text) || 'section'
}
/**
* Wiki links: `[[Article Title]]` or `[[Article Title|display text]]`
* become links to /wiki/<slug>. Registered before the `link` rule so
* code spans, emphasis and TeX ($...$) keep their precedence.
*/
function wikilinkRule(state: StateInline, silent: boolean): boolean {
const src = state.src
const start = state.pos
// must open with [[ but not [[[
if (src.charCodeAt(start) !== 0x5b || src.charCodeAt(start + 1) !== 0x5b) return false
if (src.charCodeAt(start + 2) === 0x5b) return false
// find the closing ]] — bail on a nested [[ or end of the inline content
let end = -1
for (let p = start + 2; p < src.length - 1; p++) {
if (src.charCodeAt(p) === 0x5b && src.charCodeAt(p + 1) === 0x5b) return false
if (src.charCodeAt(p) === 0x5d && src.charCodeAt(p + 1) === 0x5d) {
end = p
break
}
}
if (end === -1) return false
const inner = src.slice(start + 2, end)
if (!inner.trim() || inner.includes('\n')) return false
const bar = inner.indexOf('|')
const target = (bar === -1 ? inner : inner.slice(0, bar)).trim()
const slug = slugify(target)
if (!slug) return false
if (silent) {
// markdown-it requires a matching rule to consume its span, even silently
state.pos = end + 2
return true
}
const label = (bar === -1 ? '' : inner.slice(bar + 1)).trim() || target
const open = state.push('link_open', 'a', 1)
open.attrs = [['href', `/wiki/${slug}`], ['class', 'wikilink']]
const text = state.push('text', '', 0)
text.content = label
state.push('link_close', 'a', -1)
state.pos = end + 2
return true
}
md.inline.ruler.before('link', 'wikilink', wikilinkRule)
/** Best-effort plain text of an inline token (for TOC labels). */
function inlineText(inline: Token): string {
let out = ''
for (const child of inline.children ?? []) {
if (child.type === 'text' || child.type === 'code_inline') out += child.content
else if (child.type === 'math_inline') out += child.content
else if (child.type === 'image') {
const alt = child.attrs?.find((a: string[]) => a[0] === 'alt')
if (alt) out += alt[1]
}
}
return out.replace(/\s+/g, ' ').trim()
}
const sanitizeConfig = {
USE_PROFILES: { html: true, mathMl: true, svg: true },
ADD_ATTR: ['target', 'class', 'style', 'aria-hidden', 'encoding'],
}
/**
* Parse markdown once: assign heading ids (for the TOC), collect the table
* of contents, render to HTML and sanitize it.
*
* `namespace` prefixes the heading ids (the reader passes the article slug),
* so that the articles open side by side never hand the document two
* headings with the same id.
*/
export function renderMarkdown(source: string, namespace?: string): RenderedMarkdown {
const src = source ?? ''
const tokens = md.parse(src, {})
const toc: TocEntry[] = []
const usedIds = new Set<string>()
const prefix = namespace ? `${slugify(namespace)}--` : ''
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]!
if (token.type !== 'heading_open') continue
const level = Number(token.tag.slice(1))
const inline = tokens[i + 1]
const text = inline?.type === 'inline' ? inlineText(inline) : ''
if (!text) continue
let id = slugifyFragment(text)
let n = 2
while (usedIds.has(id)) id = `${slugifyFragment(text)}-${n++}`
usedIds.add(id)
token.attrSet('id', `${prefix}${id}`)
if (level >= 2 && level <= 3) toc.push({ id: `${prefix}${id}`, text, level })
}
const raw = md.renderer.render(tokens, md.options, {})
const html = DOMPurify.sanitize(raw, sanitizeConfig) as unknown as string
return { html, toc }
}

View File

@@ -0,0 +1,57 @@
/* Route-shape helpers for the reader experience.
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. */
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). */
export interface ReaderLocation {
identity: string
lang: string | null
}
export function parseReaderPath(path: string): ReaderLocation | null {
const version = VERSION_READER_PATH.exec(path)
// /wiki/<x>/edit is the editor whatever <x> is — never a version address
if (version && version[2]!.toLowerCase() !== 'edit') {
return { identity: decodeURIComponent(version[2]!), lang: version[1]! }
}
const plain = READER_PATH.exec(path)
if (plain) return { identity: decodeURIComponent(plain[1]!), lang: null }
return null
}
export function isReaderPath(path: string): boolean {
return parseReaderPath(path) !== null
}
export function wikiSlugFromPath(path: string): string | null {
return parseReaderPath(path)?.identity ?? null
}
/**
* The address of one language version: the canonical document lives at the
* article's own URL, translations at /wiki/<lang>/<identity>.
*/
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)}`
}
/**
* The "reading surface": the reader itself plus the edit/new-article pages —
* the pages that belong to the open book. The stack of open pages survives the
* whole session either way; this marks the pages outside it (the list, the
* graph, home), which are the places worth returning to once the last page is
* closed.
*/
export function isReadingSurface(path: string): boolean {
return isReaderPath(path) || WIKI_SUB_PATH.test(path) || path === '/new'
}