More things

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

188
AGENTS.md
View File

@@ -40,10 +40,13 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor
- `src/index.js` — app setup: CORS, JSON body, `/health`, routers, 404 handler, central - `src/index.js` — app setup: CORS, JSON body, `/health`, routers, 404 handler, central
error handler (Mongoose `ValidationError`/`CastError` → 400), graceful shutdown. After error handler (Mongoose `ValidationError`/`CastError` → 400), graceful shutdown. After
the connection it calls `User.ensureAnAdmin()` so somebody can always open the dashboard. the connection it calls `User.ensureAnAdmin()` so somebody can always open the dashboard,
and `Article.replaceReservedLanguageIndex()` so an older database can write an article in
any language (see the language gotcha below).
- `src/config/db.js` — `connectDB` / `disconnectDB`. - `src/config/db.js` — `connectDB` / `disconnectDB`.
- `src/models/` — Mongoose schemas with `timestamps: true`. `Article` (slug, title, - `src/models/` — Mongoose schemas with `timestamps: true`. `Article` (slug, title,
summary, content, tags + text index), `Item` (simple demo resource), summary, content, tags + text index that points its reserved `language_override`
at a field no article carries — see the language gotcha below), `Item` (simple demo resource),
`User` (username + `passwordHash`, `select: false`; `setPassword`/`checkPassword` `User` (username + `passwordHash`, `select: false`; `setPassword`/`checkPassword`
via bcrypt, `toPublic()` for responses, and `credentialProblem()` as the single via bcrypt, `toPublic()` for responses, and `credentialProblem()` as the single
place account rules are checked). The profile picture lives on `User.avatar` as a place account rules are checked). The profile picture lives on `User.avatar` as a
@@ -52,6 +55,15 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor
`User.role` is `'admin'` or `'member'` `User.role` is `'admin'` or `'member'`
(`ROLES`); `User.hasNone()` and `User.ensureAnAdmin()` cover who gets the (`ROLES`); `User.hasNone()` and `User.ensureAnAdmin()` cover who gets the
former — see the roles gotcha below. former — see the roles gotcha below.
`EditProposal` — a member's request for a change. `kind` is `'edit'` — a page that
exists, named by its `article` `_id` — or `'create'`, a page the wiki does not have
yet: `article` stays null until an approval writes one, and `slug`/`baseSlug` keep
the address the request asks for and the version group a proposed translation joins.
Either way it holds the whole proposed state (title/content/tags/language), a `base`
snapshot of the article as it read when the request was sent (the diff's other half,
empty for a creation), a `status` (`pending`/`approved`/`rejected`), who sent it
(`createdBy` + the name recorded alongside, so it reads after an account closes) and
who decided it.
- `src/routes/` — one Express `Router` per resource, mounted in `index.js`. - `src/routes/` — one Express `Router` per resource, mounted in `index.js`.
- `src/routes/auth.js` — `POST /api/auth/register` (creates the account and returns - `src/routes/auth.js` — `POST /api/auth/register` (creates the account and returns
its session; the wiki's **first** account is created as an admin), its session; the wiki's **first** account is created as an admin),
@@ -60,12 +72,34 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor
all behind `requireAuth`: `PUT /api/auth/me` (rename), `PUT /api/auth/password` all behind `requireAuth`: `PUT /api/auth/me` (rename), `PUT /api/auth/password`
(needs the current one), `PUT /api/auth/avatar` (data URL, `""` clears), (needs the current one), `PUT /api/auth/avatar` (data URL, `""` clears),
`PUT /api/auth/locale` (interface language, one of `SUPPORTED_LOCALES`). `PUT /api/auth/locale` (interface language, one of `SUPPORTED_LOCALES`).
- `src/routes/articles.js` — reads open, writes by role. An **admin**'s save applies
straight: `POST /api/articles` creates through `createArticle()` and
`PUT /api/articles/slug/:slug` writes through `applyArticleEdit()` (which re-slugs on
a title change and refuses a language the version group already has); both answer the
wiki's rules through `resolveCreation()` and `requestError()`, so a create and an
approved request cannot drift apart. A **member's** same save answers `202` with
`{ proposal }` instead — an `EditProposal` upserted per (article, member, pending),
or, for a page that does not exist yet, per (member, pending, asked-for slug): saving
again *revises* the waiting request, and a draft that retitles itself sends the
`proposalId` the first answer carried so it stays one request.
`DELETE` by slug runs `requireAuth, requireAdmin` alone, and settles (deletes) that
article's proposals with it.
- `src/routes/admin.js` — mounted at `/api/admin`, everything behind - `src/routes/admin.js` — mounted at `/api/admin`, everything behind
`requireAuth, requireAdmin`: `GET /api/admin/users` (every account, newest `requireAuth, requireAdmin`: `GET /api/admin/users` (every account, newest
first, with `createdAt`), `PUT /api/admin/users/:id/role` (`role` is `'admin'` first, with `createdAt`), `PUT /api/admin/users/:id/role` (`role` is `'admin'`
or `'member'`; an admin's own role is the one they cannot change), and or `'member'`; an admin's own role is the one they cannot change), and
`DELETE /api/admin/users/:id` (closes an account for good — never their own, `DELETE /api/admin/users/:id` (closes an account for good — never their own,
by the same rule; the articles it wrote stay, since articles name no author). by the same rule; the articles it wrote stay, since articles name no author).
The edit-request shelf: `GET /api/admin/proposals` (every request, decided ones
included, each with its proposer, its `base`/proposed text and the article as it
stands now), `GET /api/admin/proposals/count` (just `{ pending }` — how many
still wait, which is what the sidebar's badge on the Requests link wears),
`PUT /api/admin/proposals/:id/approve` (an edit is written through
`applyArticleEdit`, a creation through `createArticle` — so a page that appeared
meanwhile, or a version group that grew the language asked for, is refused the same
honest way and the request stays waiting; a gone article or an already-decided
request is an error) and `PUT /api/admin/proposals/:id/reject` (refuses; the page
stays put).
- `src/middleware/auth.js` — `signToken(user)`, `requireAuth`, which reads - `src/middleware/auth.js` — `signToken(user)`, `requireAuth`, which reads
`Authorization: Bearer <token>` and sets `req.user = { id, username }`; anything `Authorization: Bearer <token>` and sets `req.user = { id, username }`; anything
else is answered with 401 `{ message }`. `requireAdmin` runs after it and reads else is answered with 401 `{ message }`. `requireAdmin` runs after it and reads
@@ -80,10 +114,25 @@ Conventions to follow:
- Articles are addressed by **slug**, not by Mongo id (`/api/articles/slug/:slug`). - Articles are addressed by **slug**, not by Mongo id (`/api/articles/slug/:slug`).
`slugify()` / `uniqueSlug()` (in `routes/articles.js`) generate uniquified slugs `slugify()` / `uniqueSlug()` (in `routes/articles.js`) generate uniquified slugs
(`-2`, `-3`, ... suffixes); POST requires `title`, PUT re-slugs when the title changes. (`-2`, `-3`, ... suffixes); POST requires `title`, PUT re-slugs when the title changes.
- The list endpoint (`GET /api/articles`) selects `-content` (no body) and supports - The list endpoint (`GET /api/articles`) selects `-content` (no body) and answers
`?q=` (regex search on title/summary/tags) and `?tag=`. `{ articles, total, hasMore }`. It filters by article, not by document — `?q=`
(regex on title/tags), `?tag=` a tag any version wears, `?lang=` a language any
version is written in — and `?limit=` / `?offset=` page it **by article** (the
identity, not the document), so `total` counts each article once however many
languages it exists in. `?prefer=` (a reader's interface language) picks which
version stands for an article — that language's, else English, else the canonical
one — but never decides which articles answer. `?preferFirst` additionally **ranks**
the answers, leading with the articles that speak that language themselves (the
Ctrl+K search popup asks for it; the shelf and home pages do not). Asked for no
`limit`, the whole shelf answers at once.
- `GET /api/articles/facets` answers `{ total, topics, languages }` counted over the
whole shelf (topics lowercased, both tallied by popularity). The `/articles` page
draws its topic folders and language chips from this, not from the cards it
happens to have on screen.
- Reads are public; the writes (`POST /api/articles`, `PUT`/`DELETE` by slug) run - Reads are public; the writes (`POST /api/articles`, `PUT`/`DELETE` by slug) run
through `requireAuth`. through `requireAuth`. Of those, a `PUT` applies straight only for an admin and a
`DELETE` is admin-only — a member's `PUT` is answered `202` with a proposal instead
(see the roles gotcha below).
## Frontend (`frontend/`) ## Frontend (`frontend/`)
@@ -96,7 +145,7 @@ properties so themes swap at runtime via `data-theme`/`data-accent` attributes
(set pre-paint by an inline script in `nuxt.config.ts`). (set pre-paint by an inline script in `nuxt.config.ts`).
UI wording is localized with `@nuxtjs/i18n` (`frontend/i18n/`): `i18n.config.ts` plus UI wording is localized with `@nuxtjs/i18n` (`frontend/i18n/`): `i18n.config.ts` plus
one JSON catalog per language in `i18n/locales/` (all 309 keys, kept in parity with one JSON catalog per language in `i18n/locales/` (all 350 keys, kept in parity with
`en.json`). `strategy: 'no_prefix'` — the interface language never appears in a URL `en.json`). `strategy: 'no_prefix'` — the interface language never appears in a URL
(`/wiki/<lang>/<slug>` is the *article's* language) — with cookie detection (`/wiki/<lang>/<slug>` is the *article's* language) — with cookie detection
(`mathew-locale`, `fallbackLocale: 'en'`) so the server renders in the right language (`mathew-locale`, `fallbackLocale: 'en'`) so the server renders in the right language
@@ -105,33 +154,52 @@ carry `<code>`/`<span>` markup are drawn with `v-html` through `th()`
(`useAppLocale`), which escapes interpolated values. Adding a language = add a catalog (`useAppLocale`), which escapes interpolated values. Adding a language = add a catalog
copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`. copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
- `app/pages/` — `/` (search), `/wiki/[slug]` (article + TOC), `/wiki/[slug]/edit`, `/new`, - `app/pages/` — `/` (search), `/wiki/[lang]/[slug]` (one language version of an
`/admin` (the dashboard: every account and its role, plus the buttons that hand the article — the address every version has, English included — drawn as a page of
role on or take it back — an admin alone sees anything but a refusal here). the open book), `/wiki/[slug]` (an address naming no language: the middleware
turns it onto the version the slug speaks, so what is left of this route is
saying the wiki has nothing there), `/wiki/[slug]/edit`, `/new`,
`/admin` (the account dashboard: every account and its role, plus the buttons that
hand the role on or take it back) and `/admin/proposals` (the edit-request desk:
the requests members have sent — approve, reject, and a
details view of each change as two pages, sent-state beside proposed) — the two
admin surfaces, each reached by its own admin-only sidebar link; an
admin alone sees anything but a refusal on either.
The two editor routes wrap their form in `AuthGate`, so it shows only with a session. The two editor routes wrap their form in `AuthGate`, so it shows only with a session.
- `app/components/` — PascalCase SFCs (header, editor, MarkdownView, TocNav, etc.). - `app/components/` — PascalCase SFCs (header, editor, MarkdownView, TocNav, etc.).
`AuthGate.vue` is the editor's stand-in for a signed-out visitor; `AuthModal.vue` `AuthGate.vue` is the editor's stand-in for a signed-out visitor; `AuthModal.vue`
is the popup that signs them in; `ProfileModal.vue` is the account popup the is the popup that signs them in; `ProfileModal.vue` is the account popup the
sidebar's account row opens (picture, username, password, sign out). `ConfirmModal.vue` sidebar's account row opens (picture, username, password, interface language,
sign out). `ConfirmModal.vue`
is the yes/no popup any destructive step asks through (the editor's discard, the is the yes/no popup any destructive step asks through (the editor's discard, the
dashboard's delete). All popups (teleported overlays, closed by esc/backdrop/ dashboard's delete); `ProposalSentModal.vue` is the success popup that confirms a
member's filed request — dismissing it is what leaves the editor. All popups (teleported overlays, closed by esc/backdrop/
navigation like the other popups — opening one calls `hide()` on the others, navigation like the other popups — opening one calls `hide()` on the others,
they never stack). A row of actions folds into a ⋮ menu built on the shared they never stack). A row of actions folds into a ⋮ menu built on the shared
`.kebab*` styles: `ArticleActionsMenu.vue` on an article's title row, `.kebab*` styles: `ArticleActionsMenu.vue` on an article's title row (its Delete
`AccountActionsMenu.vue` on a row of the dashboard (the role and the account). item is drawn for admins alone), `AccountActionsMenu.vue` on a row of the
- `app/composables/` — `useTopics` (topics = distinct tags computed client-side from the dashboard (the role and the account). `ProposalRow.vue` is the edit-request desk's row:
full article list, shared via `useState`), `useRecentlyViewed` (localStorage), who wants what (a change to a page, or a page the wiki does not have yet — a creation
is named by its proposed title with a dashed "new article" mark, since there is no
address to link to until it is granted), the status pill, the approve/reject buttons,
and the details diff — the article as sent against the text proposed, both through
`MarkdownView`, where a creation reads its asked-for address instead of a page that
is not there.
- `app/composables/` — `useRecentlyViewed` (localStorage),
`useAuth` (the session: token in the `mathew-session` cookie so the server sees it `useAuth` (the session: token in the `mathew-session` cookie so the server sees it
too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()` too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()`
asking `/auth/me` who a stored token belongs to, plus `updateUsername`/ asking `/auth/me` who a stored token belongs to, plus `updateUsername`/
`changePassword`/`setAvatar` for the profile popup, and `isAdmin` — whether the `changePassword`/`setAvatar` for the profile popup, and `isAdmin` — whether the
account holds the admin role, which is what the sidebar's Admin link and the account holds the admin role, which is what the sidebar's two admin links (Admin
dashboard are drawn from), `useSignInPopup` (open state of and Requests) and the two admin pages are drawn from), `useSignInPopup` (open state of
that popup, shaped like `useSettings`/`useSearch`), `useProfileMenu` (same, for the that popup, shaped like `useSettings`/`useSearch`), `useProfileMenu` (same, for the
profile popup), `useArticleActionsMenu` (same, for an article's ⋮ menu), and profile popup), `useArticleActionsMenu` (same, for an article's ⋮ menu), and
`useAccountActionsMenu` (same, for a dashboard row's ⋮ — it holds the **id** of `useAccountActionsMenu` (same, for a dashboard row's ⋮ — it holds the **id** of
the row that is open, so one row's menu is open at a time rather than a the row that is open, so one row's menu is open at a time rather than a
boolean per row), and `useAppLocale` (the interface language: `APP_LANGUAGES` — boolean per row), `usePendingRequests` (how many edit requests wait — shared
state the sidebar draws as a badge on its Requests link and the requests desk
keeps in step: `refresh()` asks the API's count, `report(n)` lays the desk's
own loaded tally on it), and `useAppLocale` (the interface language: `APP_LANGUAGES` —
the five supported UI languages with their own names —, `choose(code)` to turn the five supported UI languages with their own names —, `choose(code)` to turn
the UI (`setLocale` also keeps the `mathew-locale` cookie in step), `applyAccount(code)` the UI (`setLocale` also keeps the `mathew-locale` cookie in step), `applyAccount(code)`
to let an account overrule the device, and `th()` for messages drawn as HTML). to let an account overrule the device, and `th()` for messages drawn as HTML).
@@ -149,13 +217,46 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
- The **interface language** is not the article language: `/wiki/fr/…` addresses the - The **interface language** is not the article language: `/wiki/fr/…` addresses the
French *article*, never the UI. UI precedence: the signed-in account's `locale` (applied French *article*, never the UI. UI precedence: the signed-in account's `locale` (applied
at startup and on signing in) > the `mathew-locale` cookie (written by `setLocale`, read at startup and on signing in) > the `mathew-locale` cookie (written by `setLocale`, read
on the server pre-render) > browser > `en`. The settings popup always turns this device; on the server pre-render) > browser > `en`. The profile popup always turns this
with a session it also saves to the account — an account that refuses (dead session) device; with a session it also saves to the account — an account that refuses
doesn't undo the switch. Error messages that come back from the API stay English; only (dead session) doesn't undo the switch. Error messages that come back from the API
the frontend's own fallbacks are translated. stay English; only the frontend's own fallbacks are translated.
- **Every version has an address, English included**: `/wiki/<lang>/<identity>`, where the
identity is the canonical version's slug — so `/wiki/en/bayes-theorem` and
`/wiki/fr/bayes-theorem` are one article two ways, and a version's own stored slug
(`bayes-theorem-fr`) appears in no address. `versionHref(identity, lang)`
(`app/utils/routes.ts`) is the one place that writes such an address; the
`open-article` middleware is the one place that reads them and knows where a version
lives, so the short `/wiki/<slug>` form — a wiki link, an old bookmark, a graph node —
is answered by resolving the document the slug speaks and turning the address onto its
version (302 on the server; on the client the navigation to the short address is
abandoned rather than committed, so it never reaches the history and Back still leads to
the page the link was pressed on). The short form opens an article in the reader's own
language when the article speaks it — English failing that (`preferredLanguage` in
`app/utils/languages.ts`, and the home/shelf/search lists pass the same choice to the
API as `?prefer=` so a card shows the version it will open) — while an address that
does name a language is honoured as asked, which is how the version switcher keeps its
promise. A slug the wiki has nothing at keeps its address and says so.
- **Changing an article's language turns its page, it does not open another.** The book's
stack (`useArticleTabs`) is keyed by article *identity*, not by document slug:
`openVersion(article)` finds that article's page and moves its `lang`/`slug`/`title`
over, so the row keeps its shape and the same page shows the other language (the pane
starts at the top of the new text). The tab also carries the document's `_id`, so a
canonical retitle — same document, new identity — follows the page to its new address
instead of opening a second one beside it. `tabHref`/`tabAt`/`closeTab` all speak
identity (+ language); `useArticleLibrary.put` files a document both under its stored
slug and under its `lang:identity` version key, so the short and versioned addresses are
the same page of the shelf; and `useRecentlyViewed` remembers identity + language, so an
article read in two languages is still one article remembered once, at the version it
was last opened at.
- Never call `t()` in `withDefaults` static prop defaults — they don't react to locale - Never call `t()` in `withDefaults` static prop defaults — they don't react to locale
changes. Use `props.x ?? t(...)` in the template/computed instead (see `AuthGate`, changes. Use `props.x ?? t(...)` in the template/computed instead (see `AuthGate`,
`ConfirmModal`). `ConfirmModal`).
- `useAppLocale` takes the composer from `useNuxtApp().$i18n`, **not** `useI18n()`: a Nuxt
plugin has no component instance, and the auth plugin applies the account's language from
one. `useI18n()` there throws *"Must be called at the top of a `setup` function"* — a 500 on
every request from an account with a saved language. Composables a plugin needs are taken
before its first `await`.
- Message catalogs are compiled: literal `{`/`@` break the compiler, so the editor's LaTeX - Message catalogs are compiled: literal `{`/`@` break the compiler, so the editor's LaTeX
seed lives as a JS constant in `ArticleForm.vue` and only its words are catalog keys. seed lives as a JS constant in `ArticleForm.vue` and only its words are catalog keys.
- Reading is open; **writing takes an account**. `POST /api/articles` and `PUT`/`DELETE` - Reading is open; **writing takes an account**. `POST /api/articles` and `PUT`/`DELETE`
@@ -173,10 +274,53 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
own role, so there is always at least one admin left to open the page. The same own role, so there is always at least one admin left to open the page. The same
menu deletes an account — through `ConfirmModal`, and never the admin's own, for menu deletes an account — through `ConfirmModal`, and never the admin's own, for
the same reason. Articles outlive the account that wrote them. the same reason. Articles outlive the account that wrote them.
- **A member's save is not a write — neither a page nor a new one.** `POST /api/articles`
and `PUT /api/articles/slug/:slug` check the account's role: an admin's save applies at
once; a member's is filed as an `EditProposal` and answered `202 { proposal }` — the live
article does not move, and a requested page does not appear. Only one pending request per
(article, member): saving again while one waits *revises* it, and the request records a
`base` snapshot of the article as it read when sent, so the dashboard can diff it and warn
when the page has since moved. A *creation* is held the same way with no article to hang it
on: it keeps the address it asks for (`slug`, plus `baseSlug` when it translates an existing
article), is found by that address while it waits, and takes the `proposalId` its first answer
carried so a retitled draft revises the same request instead of filing a second one for a
page the wiki has twice over. Approving runs the very code a direct admin write runs by —
`applyArticleEdit()` for an edit (so a retitle re-slugs, and a taken language is refused),
`createArticle()` for a creation, which records the article it brought about so the decided
row links to it; the edit-request desk's diff lays a creation against the empty address it asks for.
Rejecting leaves everything alone, and both decided and waiting requests stay on the
edit-request desk's list as the record. **Delete is the one thing no member does at all** — `DELETE`
runs `requireAdmin` and, when it succeeds, deletes that article's proposals with it.
`ArticleForm.vue` reads the `202`: instead of navigating to the (unchanged) article, or one
that does not exist yet, it marks the draft sent and opens `ProposalSentModal` over the
form — so the button cannot be pressed twice on an already-sent draft — and dismissing
that popup (button, esc, backdrop) leaves the editor: to the version that was sent, or
for a creation to the article it translates, else the search page.
- Profile pictures are stored as data URLs on the account (the browser shrinks them - Profile pictures are stored as data URLs on the account (the browser shrinks them
to a 256px square before upload); `express.json` allows 1.5 MB bodies so one fits to a 256px square before upload); `express.json` allows 1.5 MB bodies so one fits
a request. A 401 from a profile update means the session died — the popup signs a request. A 401 from a profile update means the session died — the popup signs
out and opens the sign-in popup over it. out and opens the sign-in popup over it.
- Changing an article's title can change its slug; old URLs 404 (no redirects). - Changing an article's title can change its slug; old URLs 404 (no redirects).
- **Any language writes, but MongoDB reserves the name.** A text index reads a document's
`language` field as *which language to analyse it in*, and refuses a value it has no
analyzer for — `ca`, `ja`, `ko`, `ar`, `zh`, `pl`, `uk`, `fa`, `hi` among them, which is
why creating an article in one of those used to answer `500 language override unsupported`.
The article schema's text index points that reserved `language_override` at
`articleLanguage`, a field no article carries, so the wiki's own `language` stays the
wiki's business. Index options cannot change in place (MongoDB answers the second request
with "an equivalent index already exists"), so `index.js` drops a stale text index at
startup through `Article.replaceReservedLanguageIndex()` — one boot repairs an older
database, and any text index added later has to set `language_override` the same way.
- **`/articles` is filed by topic, not a flat shelf.** With no `?tag=` in the
address the page shows one folder per topic (drawn from `/articles/facets`);
a folder is a real link to `?tag=<topic>` and opens onto the articles wearing
it. **An open folder's shelf arrives a batch at a time.** 24 cards come down
with the route, and the row under the grid (`IntersectionObserver`, 600px of
reach) asks for the rest — so a topic or language filter is the API's to
apply (`?tag=&lang=`): the page holds no whole shelf to filter itself, and
changing the filter drops the batches read for the old one. That is also why
the folder tallies come from `/articles/facets` rather than from the cards on
screen — counting the loaded batch would let "40 topics" and the tallies
grow as the reader scrolls.
- Frontend dev server may fall back to port 3001 if 3000 is taken. - Frontend dev server may fall back to port 3001 if 3000 is taken.
- `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package. - `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package.

View File

@@ -7,7 +7,8 @@
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"seed": "node src/seed.js" "seed": "node src/seed.js",
"seed:bulk": "node src/seed-bulk.js"
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -7,6 +7,7 @@ import itemsRouter from "./routes/items.js";
import articlesRouter from "./routes/articles.js"; import articlesRouter from "./routes/articles.js";
import authRouter from "./routes/auth.js"; import authRouter from "./routes/auth.js";
import adminRouter from "./routes/admin.js"; import adminRouter from "./routes/admin.js";
import Article from "./models/Article.js";
import User from "./models/User.js"; import User from "./models/User.js";
const app = express(); const app = express();
@@ -51,6 +52,10 @@ const port = process.env.PORT || 5000;
try { try {
await connectDB(); await connectDB();
// A text index built before the language override was set (models/Article.js)
// is still in the way of writing an article in most languages: replace it.
const rebuilt = await Article.replaceReservedLanguageIndex();
if (rebuilt.length) console.log(`Rebuilt the article text index: ${rebuilt.join(", ")}`);
// Accounts opened before there were roles: the oldest of them gets one, so // Accounts opened before there were roles: the oldest of them gets one, so
// somebody can always open the dashboard and pass the role to anyone else. // somebody can always open the dashboard and pass the role to anyone else.
const promoted = await User.ensureAnAdmin(); const promoted = await User.ensureAnAdmin();

View File

@@ -44,8 +44,37 @@ const articleSchema = new mongoose.Schema(
{ timestamps: true } { timestamps: true }
); );
// Text index for search across title and tags /* Text index for search across title and tags.
articleSchema.index({ title: "text", tags: "text" }); `language` means something else to MongoDB: a text index reads that field as
the language to analyse the document in, and refuses to write a document
whose value it does not recognise — which rules out Catalan, Polish, Korean,
Arabic and the rest of the codes this wiki writes articles in. Pointing the
override at a field no article carries keeps the wiki's own `language` out
of the index's way, so an article in any language saves. */
articleSchema.index({ title: "text", tags: "text" }, { language_override: "articleLanguage" });
/* An index's options cannot be changed in place — MongoDB answers the second
request with "an equivalent index already exists" — so a database built
before the override above carries the old text index, whose writes still
fail. Drop those and let the schema's own indexes be rebuilt. Answers the
names it removed: nothing at all when the database is already up to date. */
articleSchema.statics.replaceReservedLanguageIndex = async function replaceReservedLanguageIndex() {
let existing;
try {
existing = await this.collection.indexes();
} catch (err) {
// a database with no articles collection yet has no index to replace, and
// asking for its list is MongoDB's `ns does not exist` — nothing to do here
if (err.codeName === "NamespaceNotFound" || err.code === 26) return [];
throw err;
}
const stale = existing.filter(
(index) => index.key?._fts === "text" && (index.language_override ?? "language") === "language"
);
for (const index of stale) await this.collection.dropIndex(index.name);
if (stale.length) await this.createIndexes();
return stale.map((index) => index.name);
};
const Article = mongoose.model("Article", articleSchema); const Article = mongoose.model("Article", articleSchema);

View File

@@ -0,0 +1,87 @@
import mongoose from "mongoose";
export const PROPOSAL_STATUSES = ["pending", "approved", "rejected"];
/* When a member saves the wiki the change does not land on it: it is held here
as a request for an admin to approve or reject. A request changes a page that
exists (`kind: "edit"`), or asks for one that does not yet (`kind: "create"`)
— an article, or a new language version of an existing one — and carries no
article until an admin's approval writes it. One pending request per article
and account — saving again while one waits revises it rather than stacking a
second copy, and the same draft a create editor holds revises itself the same
way. The account's name is kept beside its id so a request stays readable
after the account itself is closed. */
export const PROPOSAL_KINDS = ["edit", "create"];
const editProposalSchema = new mongoose.Schema(
{
// Whether the request rewrites a page or asks for a new one to exist.
kind: {
type: String,
enum: {
values: PROPOSAL_KINDS,
message: `Kind is one of: ${PROPOSAL_KINDS.join(", ")}.`,
},
default: "edit",
},
// The document the request wants to change, by id rather than slug: a
// retitle moves the slug out from under it. Null while the page is only
// being asked for; approval fills it in, so a decided request still names
// the article it brought about.
article: {
type: mongoose.Schema.Types.ObjectId,
ref: "Article",
default: null,
index: true,
},
// What a create asks the new page to be: the address it wants (the title
// slugified, or `<identity>-<language>` for a translation — the address is
// what tells one draft from another while both wait), and the identity of
// the article it joins as a version, if it translates one.
slug: { type: String, default: null, lowercase: true, trim: true },
baseSlug: { type: String, default: null, lowercase: true, trim: true },
// The text the editor was looking at — the other half of every diff on the
// dashboard, and how a reviewer spots an article that moved underneath.
// Empty for a creation: the page it asks for has nothing written on it.
base: {
title: { type: String, default: "" },
content: { type: String, default: "" },
tags: { type: [String], default: [] },
},
// The whole state being proposed, so approving is one application, not a patch
title: {
type: String,
required: [true, "Title is required"],
trim: true,
},
content: { type: String, default: "" },
tags: { type: [String], default: [] },
language: { type: String, default: "en", lowercase: true, trim: true },
status: {
type: String,
enum: {
values: PROPOSAL_STATUSES,
message: `Status is one of: ${PROPOSAL_STATUSES.join(", ")}.`,
},
default: "pending",
index: true,
},
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
createdByUsername: { type: String, default: "" },
decidedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", default: null },
decidedByUsername: { type: String, default: "" },
decidedAt: { type: Date, default: null },
},
{ timestamps: true }
);
// who has what waiting on which article — the filter the upsert saves through
editProposalSchema.index({ article: 1, createdBy: 1, status: 1 });
// ... and which address, for a request that names no article yet: the slug it
// asks for tells one member's waiting drafts apart from another's
editProposalSchema.index({ createdBy: 1, status: 1, kind: 1, slug: 1 });
const EditProposal = mongoose.model("EditProposal", editProposalSchema);
export default EditProposal;

View File

@@ -1,11 +1,15 @@
import { Router } from "express"; import { Router } from "express";
import Article from "../models/Article.js";
import EditProposal from "../models/EditProposal.js";
import User, { ROLES } from "../models/User.js"; import User, { ROLES } from "../models/User.js";
import { requireAdmin, requireAuth } from "../middleware/auth.js"; import { requireAdmin, requireAuth } from "../middleware/auth.js";
import { applyArticleEdit, createArticle } from "./articles.js";
const router = Router(); const router = Router();
/* The back office of the wiki: who holds an account, and what each of them may /* The back office of the wiki: who holds an account, what each of them may
do. Everything here takes an admin — `requireAdmin` reads the role off the do, and the edit requests members have sent in for an admin to decide.
Everything here takes an admin — `requireAdmin` reads the role off the
account, so a session outlives its own promotion the moment it is undone. */ account, so a session outlives its own promotion the moment it is undone. */
/** The shape the dashboard is given for one account: the public fields, plus /** The shape the dashboard is given for one account: the public fields, plus
@@ -76,4 +80,170 @@ router.delete("/users/:id", requireAuth, requireAdmin, async (req, res, next) =>
} }
}); });
/* --- edit requests -------------------------------------------------------
What a member's save became instead of a write (see routes/articles.js),
read here newest first with everything a reviewer needs beside it. */
/**
* Every proposal with its proposer, decider, and the article as it stands now —
* the review needs all three: who asked, whether it was answered, and whether
* the page moved after the request was sent. Names come from the accounts while
* they exist and from what each request recorded otherwise. A request for a new
* page names no article at all until an approval writes one (`kind` says so, and
* `slug`/`baseSlug` hold the address it asked for), and an approved one names
* the article it brought about.
*/
async function proposalRows(proposals) {
const userIds = [
...new Set(proposals.flatMap((p) => [p.createdBy, p.decidedBy]).filter(Boolean)),
];
const users = await User.find({ _id: { $in: userIds } }).select("username avatar").lean();
const userById = new Map(users.map((u) => [String(u._id), u]));
const nameOf = (id, stored) =>
id ? (userById.get(String(id))?.username ?? stored ?? "") : null;
const articleIds = [...new Set(proposals.map((p) => p.article).filter(Boolean).map(String))];
const articles = await Article.find({ _id: { $in: articleIds } })
.select("slug baseSlug title language content tags")
.lean();
const articleById = new Map(articles.map((a) => [String(a._id), a]));
return proposals.map((p) => {
const article = articleById.get(String(p.article)) ?? null;
return {
id: String(p._id),
kind: p.kind === "create" ? "create" : "edit",
status: p.status,
createdAt: p.createdAt,
decidedAt: p.decidedAt ?? null,
// what a request for a new page asks the article to be called at, and
// which one it joins as a version — the reviewer reads the address from here
slug: p.slug ?? null,
baseSlug: p.baseSlug ?? null,
proposed: {
title: p.title,
content: p.content ?? "",
tags: p.tags ?? [],
language: p.language ?? "en",
},
base: {
title: p.base?.title ?? "",
content: p.base?.content ?? "",
tags: p.base?.tags ?? [],
},
proposer: {
username: nameOf(p.createdBy, p.createdByUsername) || p.createdByUsername,
avatar: userById.get(String(p.createdBy))?.avatar ?? "",
},
decidedByUsername: nameOf(p.decidedBy, p.decidedByUsername),
article: article && {
id: String(article._id),
slug: article.slug,
identity: article.baseSlug || article.slug,
title: article.title,
language: article.language ?? "en",
content: article.content ?? "",
tags: article.tags ?? [],
},
};
});
}
// GET /api/admin/proposals — every edit request members have made, decided
// ones included: the dashboard shows the waiting pile and its history.
router.get("/proposals", requireAuth, requireAdmin, async (req, res, next) => {
try {
const proposals = await EditProposal.find().sort({ createdAt: -1 }).lean();
res.json({ proposals: await proposalRows(proposals) });
} catch (err) {
next(err);
}
});
// GET /api/admin/proposals/count — how many requests still wait. The sidebar
// wears this on the door to the desk, so an admin sees the pile without opening
// it; only the number is asked for, not the requests themselves.
router.get("/proposals/count", requireAuth, requireAdmin, async (req, res, next) => {
try {
const pending = await EditProposal.countDocuments({ status: "pending" });
res.json({ pending });
} catch (err) {
next(err);
}
});
/** Mark a request decided, recording which admin answered it and when. */
async function decide(proposal, status, admin) {
proposal.status = status;
proposal.decidedBy = admin.id;
proposal.decidedByUsername = admin.username;
proposal.decidedAt = new Date();
await proposal.save();
}
// PUT /api/admin/proposals/:id/approve — grant the request. An edit is written
// onto the article, through the same rules a direct admin edit runs by; a
// creation is written as a new article, through the rules a direct admin create
// runs by — so a page that has appeared in the meantime, or a version group
// that has grown the language asked for, is refused the same honest way.
router.put("/proposals/:id/approve", requireAuth, requireAdmin, async (req, res, next) => {
try {
const proposal = await EditProposal.findById(req.params.id);
if (!proposal) return res.status(404).json({ message: "No such edit request." });
if (proposal.status !== "pending") {
return res.status(400).json({ message: "That edit request is already decided." });
}
if (proposal.kind === "create") {
const created = await createArticle({
title: proposal.title,
content: proposal.content,
tags: proposal.tags,
language: proposal.language,
baseSlug: proposal.baseSlug,
});
// the record says which article the request brought about
proposal.article = created._id;
await decide(proposal, "approved", req.user);
return res.json({ message: "Article created.", article: created });
}
const article = await Article.findById(proposal.article);
if (!article) {
return res.status(404).json({ message: "The article that request changes is gone." });
}
const language =
proposal.language && proposal.language !== article.language ? proposal.language : undefined;
const updated = await applyArticleEdit(article, {
title: proposal.title,
content: proposal.content,
tags: proposal.tags,
language,
});
await decide(proposal, "approved", req.user);
res.json({ message: "Change applied.", article: updated });
} catch (err) {
next(err);
}
});
// PUT /api/admin/proposals/:id/reject — refuse the change; the article stays
// exactly as it is and the request stays on the record as a refusal.
router.put("/proposals/:id/reject", requireAuth, requireAdmin, async (req, res, next) => {
try {
const proposal = await EditProposal.findById(req.params.id);
if (!proposal) return res.status(404).json({ message: "No such edit request." });
if (proposal.status !== "pending") {
return res.status(400).json({ message: "That edit request is already decided." });
}
await decide(proposal, "rejected", req.user);
res.json({ message: "Edit request rejected." });
} catch (err) {
next(err);
}
});
export default router; export default router;

View File

@@ -1,11 +1,15 @@
import { Router } from "express"; import { Router } from "express";
import Article from "../models/Article.js"; import Article from "../models/Article.js";
import { requireAuth } from "../middleware/auth.js"; import EditProposal from "../models/EditProposal.js";
import User from "../models/User.js";
import { requireAdmin, requireAuth } from "../middleware/auth.js";
const router = Router(); const router = Router();
// Reading the wiki is open to everyone; the write endpoints at the bottom of // Reading the wiki is open to everyone; the write endpoints at the bottom of
// this file take an account — see routes/auth.js for how one is obtained. // this file take an account — see routes/auth.js for how one is obtained.
// Editing further: an admin writes straight to the page, a member's change is
// held as a proposal an admin approves (see models/EditProposal.js).
// Convert a title (or any string) into a URL-safe slug // Convert a title (or any string) into a URL-safe slug
export function slugify(str) { export function slugify(str) {
@@ -38,6 +42,82 @@ function languageCode(value) {
return code || "en"; return code || "en";
} }
// A value made safe to drop into a RegExp
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// The most any one answer may carry. `?limit=` past it is answered at this size.
const MAX_PAGE_SIZE = 100;
// A count from the query string: `fallback` when it is missing or nonsense.
function positiveCount(value, fallback) {
const n = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
/**
* How much of the shelf one answer carries: `limit` articles, from `offset`
* on. Asked for neither, the whole shelf answers at once — what the readers
* that want all of it (the home page's search, say) ask for.
*/
function pageBounds(query) {
const offset = positiveCount(query.offset, 0);
const limit = positiveCount(query.limit, 0);
return { offset, limit: limit ? Math.min(limit, MAX_PAGE_SIZE) : Infinity };
}
// Every version of the articles these identities name, translations included.
function versionsFilter(identities) {
return { $or: [{ slug: { $in: identities } }, { baseSlug: { $in: identities } }] };
}
/**
* The documents that say which articles the shelf shows: each condition is
* answered over an article's versions rather than over single documents — a
* tag any version wears, a language any version is written in — so an article
* tagged only in the language it was written in still shows when a tag and
* another language are asked for together.
*/
async function matchingVersions(conditions) {
if (!conditions.length) {
return Article.find().select("slug baseSlug").sort({ updatedAt: -1 }).lean();
}
const passes = await Promise.all(
conditions.map((c) => Article.find(c).select("slug baseSlug").sort({ updatedAt: -1 }).lean())
);
const [first, ...rest] = passes;
const also = rest.map((p) => new Set(p.map((a) => a.baseSlug || a.slug)));
return first.filter((a) => also.every((ids) => ids.has(a.baseSlug || a.slug)));
}
/**
* The version an article is shown as: the one written in the language asked
* for, else the reader's own language if the article speaks it (English as the
* wiki's common tongue failing that), else the canonical version, else the
* first of them — the version whose last edit puts the article in the shelf's
* order.
*/
function representative(versions, wantedLang, preferredLang, matchedSlugs) {
if (wantedLang) {
const inWanted = versions.find((a) => languageCode(a.language) === wantedLang);
if (inWanted) return inWanted;
}
if (preferredLang) {
const inPreferred = versions.find((a) => languageCode(a.language) === preferredLang);
if (inPreferred) return inPreferred;
if (preferredLang !== "en") {
const inEnglish = versions.find((a) => languageCode(a.language) === "en");
if (inEnglish) return inEnglish;
}
}
return (
versions.find((a) => !a.baseSlug) ??
versions.find((a) => matchedSlugs.has(a.slug)) ??
versions[0]
);
}
// The language versions of one article: every document sharing its identity — // The language versions of one article: every document sharing its identity —
// the canonical (original) version's slug. Translations carry that slug as // the canonical (original) version's slug. Translations carry that slug as
// `baseSlug`; the canonical document answers to it as its own `slug`. // `baseSlug`; the canonical document answers to it as its own `slug`.
@@ -64,58 +144,147 @@ async function resolveVersion(slug, lang) {
return Article.findOne({ baseSlug: identity, language: wanted }); return Article.findOne({ baseSlug: identity, language: wanted });
} }
// GET /api/articles?q=&tag=&lang= - list articles (without content body). // GET /api/articles?q=&tag=&lang=&prefer=&preferFirst=&limit=&offset= - a page
// of the shelf (without content body), newest edit first, with how many
// articles match.
// One entry per article regardless of how many languages it exists in: the // One entry per article regardless of how many languages it exists in: the
// canonical version answers with every language version attached, and with // canonical version answers with every language version attached, and with
// ?lang= the version written in that language answers instead. // ?lang= the version written in that language answers instead. `?prefer=` is
// the reader's own language: it picks which version an article stands with —
// that language's, else English, else the canonical one — but never decides
// which articles answer. `?preferFirst` additionally ranks the answers:
// articles written in the preferred language lead the ones only shown through
// a fallback.
router.get("/", async (req, res, next) => { router.get("/", async (req, res, next) => {
try { try {
const { q, tag, lang } = req.query; const { q, tag, lang, prefer, preferFirst } = req.query;
const filter = {};
if (typeof q === "string" && q.trim()) {
const safe = q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const rx = new RegExp(safe, "i");
filter.$or = [{ title: rx }, { tags: rx }];
}
if (typeof tag === "string" && tag.trim()) {
filter.tags = tag.trim().toLowerCase();
}
const wantedLang = typeof lang === "string" && lang.trim() ? languageCode(lang) : ""; const wantedLang = typeof lang === "string" && lang.trim() ? languageCode(lang) : "";
const preferredLang = typeof prefer === "string" && prefer.trim() ? languageCode(prefer) : "";
const rankPreferredFirst =
preferFirst !== undefined &&
preferFirst !== "false" &&
preferFirst !== "0" &&
Boolean(preferredLang);
const needle = typeof q === "string" ? q.trim() : "";
const wantedTag = typeof tag === "string" ? tag.trim() : "";
const conditions = [];
if (needle) {
const rx = new RegExp(escapeRegExp(needle), "i");
conditions.push({ $or: [{ title: rx }, { tags: rx }] });
}
if (wantedTag) conditions.push({ tags: new RegExp(`^${escapeRegExp(wantedTag)}$`, "i") });
if (wantedLang) { if (wantedLang) {
// articles written before the language field existed are English // articles written before the language field existed are English
filter.language = wantedLang === "en" ? { $in: ["en", null] } : wantedLang; conditions.push({ language: wantedLang === "en" ? { $in: ["en", null] } : wantedLang });
} }
const matched = await Article.find(filter).select("-content").sort({ updatedAt: -1 }); const matched = await matchingVersions(conditions);
if (!matched.length) return res.json([]);
// pull in the versions of every matched article so each can be counted
// as one article and carry the full list of its languages
const identities = [...new Set(matched.map((a) => a.baseSlug || a.slug))]; const identities = [...new Set(matched.map((a) => a.baseSlug || a.slug))];
const groupDocs = await Article.find({ if (!identities.length) return res.json({ articles: [], total: 0, hasMore: false });
$or: [{ slug: { $in: identities } }, { baseSlug: { $in: identities } }],
}) // Every version of every article on show, metadata only: enough to say
// which version stands for an article and when it was last edited, so the
// page is cut here — before any article's text is read at all.
const members = await Article.find(versionsFilter(identities))
.select("slug baseSlug language updatedAt")
.sort({ updatedAt: -1 })
.lean();
const versionsByIdentity = new Map(identities.map((id) => [id, []]));
for (const a of members) versionsByIdentity.get(a.baseSlug || a.slug)?.push(a);
const matchedSlugs = new Set(matched.map((a) => a.slug));
const shelf = identities
.map((id) => {
const versions = versionsByIdentity.get(id) ?? [];
const shown = representative(versions, wantedLang, preferredLang, matchedSlugs);
return shown
? {
id,
shownAt: new Date(shown.updatedAt),
shownSlug: shown.slug,
shownLang: languageCode(shown.language),
}
: null;
})
.filter(Boolean)
.sort((a, b) => b.shownAt - a.shownAt);
// `?preferFirst` (the search popup's ask): articles that actually speak the
// preferred language stand before ones the shelf shows in a fallback. The
// sort is stable, so the newest-edit order holds within each group — and
// it runs before the page is cut, so a language-match cannot be crowded
// out of a short batch by fallbacks that merely read as fresher.
if (rankPreferredFirst) {
shelf.sort(
(a, b) => Number(b.shownLang === preferredLang) - Number(a.shownLang === preferredLang)
);
}
const { offset, limit } = pageBounds(req.query);
const page = shelf.slice(offset, offset + limit);
// Now the page's articles in full apart from their text, so a whole shelf
// never crosses the wire for the sake of one batch of cards.
const pageIdentities = page.map((entry) => entry.id);
const groupDocs = await Article.find(versionsFilter(pageIdentities))
.select("-content") .select("-content")
.sort({ updatedAt: -1 }); // not lean: hydration fills in old defaults .sort({ updatedAt: -1 }); // not lean: hydration fills in old defaults
const groupByIdentity = new Map(pageIdentities.map((id) => [id, []]));
for (const a of groupDocs) groupByIdentity.get(a.baseSlug || a.slug)?.push(a);
const versionsByIdentity = new Map(identities.map((id) => [id, []])); const entries = page.map((entry) => {
for (const a of groupDocs) { const versions = groupByIdentity.get(entry.id) ?? [];
versionsByIdentity.get(a.baseSlug || a.slug)?.push(a); const shown = versions.find((a) => a.slug === entry.shownSlug) ?? versions[0];
} return { ...shown.toObject(), versions };
const matchedSlugs = new Set(matched.map((a) => a.slug)); });
const entries = identities.map((id) => { res.json({
const group = versionsByIdentity.get(id) ?? []; articles: entries,
const asVersion = (a) => ({ ...a.toObject(), versions: group }); total: shelf.length,
if (wantedLang) { hasMore: offset + page.length < shelf.length,
const inWanted = group.find((a) => languageCode(a.language) === wantedLang); });
if (inWanted) return asVersion(inWanted); } catch (err) {
} next(err);
const rep = group.find((a) => !a.baseSlug) ?? group.find((a) => matchedSlugs.has(a.slug)) ?? group[0]; }
return asVersion(rep); });
// GET /api/articles/facets - how the shelf is built, counted over every
// article rather than the page currently on show: how many there are, the
// topics they wear and the languages they are written in. An article counts
// once per topic however many versions carry it, and once per language. The
// shelf's topic list and language chips read this, so their counts stay true
// while its cards arrive a batch at a time.
router.get("/facets", async (req, res, next) => {
try {
const docs = await Article.find().select("slug baseSlug tags language").lean();
const articles = new Map(); // identity -> the topics and languages it wears
for (const doc of docs) {
const identity = doc.baseSlug || doc.slug;
const article = articles.get(identity) ?? { topics: new Set(), langs: new Set() };
for (const t of doc.tags ?? []) {
const name = String(t).trim().toLowerCase();
if (name) article.topics.add(name);
}
article.langs.add(languageCode(doc.language));
articles.set(identity, article);
}
const topics = new Map();
const languages = new Map();
for (const { topics: worn, langs } of articles.values()) {
for (const t of worn) topics.set(t, (topics.get(t) ?? 0) + 1);
for (const code of langs) languages.set(code, (languages.get(code) ?? 0) + 1);
}
const byPopularity = (counts) =>
[...counts.entries()].sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b));
res.json({
total: articles.size,
topics: byPopularity(topics).map(([name, count]) => ({ name, count })),
languages: byPopularity(languages).map(([code, count]) => ({ code, count })),
}); });
entries.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
res.json(entries);
} catch (err) { } catch (err) {
next(err); next(err);
} }
@@ -142,9 +311,12 @@ router.get("/random", async (req, res, next) => {
// Links inside article content, as the graph sees them: wiki links // Links inside article content, as the graph sees them: wiki links
// ([[Target]], with optional #section and |display) and markdown links // ([[Target]], with optional #section and |display) and markdown links
// pointing at /wiki/<slug>. Global so String#matchAll resumes per scan. // pointing at a wiki address — /wiki/<slug>, or the full address of one
// language version, /wiki/<lang>/<slug>. The language part is read off and the
// article behind the link is the node, whichever version was linked.
// Global so String#matchAll resumes per scan.
const WIKILINK_RX = /\[\[([^\[\]|#]+)(?:#[^\[\]|]*)?(?:\|[^\[\]]*)?\]\]/g; const WIKILINK_RX = /\[\[([^\[\]|#]+)(?:#[^\[\]|]*)?(?:\|[^\[\]]*)?\]\]/g;
const WIKI_URL_RX = /\]\((?:https?:\/\/[^/\s)]+)?\/?wiki\/([A-Za-z0-9][A-Za-z0-9_-]*)/g; const WIKI_URL_RX = /\]\((?:https?:\/\/[^/\s)]+)?\/?wiki\/(?:[a-z]{2}\/)?([A-Za-z0-9][A-Za-z0-9_-]*)/g;
// GET /api/articles/graph - the wiki's link map for the graph view: one node // GET /api/articles/graph - the wiki's link map for the graph view: one node
// per article — versions of the same article share a node — and one undirected // per article — versions of the same article share a node — and one undirected
@@ -221,137 +393,300 @@ router.get("/slug/:slug", async (req, res, next) => {
} }
}); });
/**
* The answer a request needs beyond the error handler's usual: a status of its
* own, which the central handler in index.js reads off the error.
*/
function requestError(status, message) {
const err = new Error(message);
err.status = status;
throw err;
}
/**
* A create request read out and checked against the wiki: the text the new
* document carries, the version group it belongs to (a translation names the
* article it translates; a fresh article joins one when its name is already
* taken in another language), the address it asks for, and a language that
* group does not already have. `createArticle` writes from this and a member's
* proposal records it, so both ask the wiki the same thing.
*/
async function resolveCreation(body = {}) {
const { title, content = "", tags = [], baseSlug } = body;
const language = languageCode(body.language);
if (!title || !String(title).trim()) requestError(400, "Title is required");
let identity = null;
if (baseSlug) {
const base = await Article.findOne({ slug: slugify(baseSlug) });
if (!base) requestError(400, "The article being translated does not exist");
identity = base.baseSlug || base.slug;
} else {
// Two articles must not share a name in different languages: when the
// slug is taken by an article written in another language, the document
// joins it as a version rather than standing beside it as a twin. (The
// same name in the same language stays the old `-2` suffixed sibling.)
const byName = await Article.findOne({ slug: slugify(body.slug || title) });
if (byName && languageCode(byName.language) !== language) {
identity = byName.baseSlug || byName.slug;
}
}
if (identity) {
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
language,
});
if (clash) requestError(400, `This article already has a version in ${language}`);
}
const requested = body.slug
? slugify(body.slug)
: identity
? `${identity}-${language}` // versions sit side by side: slug-fr, slug-de, ...
: slugify(title);
return { title, content, tags, language, identity, slug: requested };
}
/**
* Write a new article onto the wiki. Shared by the POST below — an admin's own
* create, which lands at once — and by approving a member's request for one
* (admin routes), which is created by these very rules.
*/
export async function createArticle(body = {}) {
const creation = await resolveCreation(body);
return Article.create({
title: creation.title,
content: creation.content,
tags: creation.tags,
language: creation.language,
slug: await uniqueSlug(creation.slug),
baseSlug: creation.identity,
});
}
// POST /api/articles - create an article (sign-in required; reading is public, // POST /api/articles - create an article (sign-in required; reading is public,
// writing the wiki takes an account). Passing `baseSlug` makes the new document // writing the wiki takes an account). Passing `baseSlug` makes the new document
// another language version of an existing article rather than a fresh one: it // another language version of an existing article rather than a fresh one: it
// joins the original's version group and takes a slug beside it (slug-fr, …). // joins the original's version group and takes a slug beside it (slug-fr, …).
// An admin's create is written at once; a member's is held as a proposal for an
// admin to approve — 202 says no page was created.
router.post("/", requireAuth, async (req, res, next) => { router.post("/", requireAuth, async (req, res, next) => {
try { try {
const { title, content = "", tags = [], baseSlug } = req.body || {}; const account = await User.findById(req.user.id).select("username role");
const language = languageCode(req.body?.language); if (!account) return res.status(401).json({ message: "No such account any more." });
if (!title || !String(title).trim()) {
return res.status(400).json({ message: "Title is required" }); if (account.role !== "admin") {
// the request is weighed against the wiki first: a title or language that
// could not be honoured is told to the editor now, not to an admin
// reading the request later
const creation = await resolveCreation(req.body || {});
const proposal = await submitCreationProposal(
account,
creation,
(req.body || {}).proposalId
);
return res.status(202).json({ proposal });
} }
let identity = null; res.status(201).json(await createArticle(req.body || {}));
if (baseSlug) {
const base = await Article.findOne({ slug: slugify(baseSlug) });
if (!base) {
return res.status(400).json({ message: "The article being translated does not exist" });
}
identity = base.baseSlug || base.slug;
} else {
// Two articles must not share a name in different languages: when the
// slug is taken by an article written in another language, the document
// joins it as a version rather than standing beside it as a twin. (The
// same name in the same language stays the old `-2` suffixed sibling.)
const byName = await Article.findOne({
slug: slugify(req.body.slug || title),
});
if (byName && languageCode(byName.language) !== language) {
identity = byName.baseSlug || byName.slug;
}
}
if (identity) {
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
language,
});
if (clash) {
return res
.status(400)
.json({ message: `This article already has a version in ${language}` });
}
}
const requested = req.body.slug
? slugify(req.body.slug)
: identity
? `${identity}-${language}` // versions sit side by side: slug-fr, slug-de, ...
: slugify(title);
const slug = await uniqueSlug(requested);
const article = await Article.create({
title,
content,
tags,
language,
slug,
baseSlug: identity,
});
res.status(201).json(article);
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
// PUT /api/articles/slug/:slug - update an article (sign-in required) /**
* Write an edit onto the article document: the fields given, a re-slug when
* the title moved, and a language the version group already has is refused.
* Throws with `.status` for the central error handler to answer with.
* Shared by the direct write below and by approving a proposal (admin routes).
*/
export async function applyArticleEdit(article, body = {}) {
const { title, content, tags, language } = body;
if (title !== undefined && !String(title).trim()) {
requestError(400, "Title is required");
}
const update = {};
if (title !== undefined) update.title = title;
if (content !== undefined) update.content = content;
if (tags !== undefined) update.tags = tags;
// Moving this version to another language must not collide with a
// version the article already has in that language.
if (language !== undefined) {
const code = languageCode(language);
if (code !== article.language) {
const identity = article.baseSlug || article.slug;
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
slug: { $ne: article.slug },
language: code,
});
if (clash) {
requestError(400, `This article already has a version in ${code}`);
}
update.language = code;
}
}
// Re-slug when the title changes and no explicit slug was provided
if (title !== undefined) {
const requested = body.slug ? slugify(body.slug) : slugify(title);
if (requested && requested !== article.slug) {
update.slug = await uniqueSlug(requested, article.slug);
}
}
const updated = await Article.findOneAndUpdate(
{ slug: article.slug },
update,
{ new: true, runValidators: true }
);
// The version group's identity is the canonical article's slug: when the
// canonical is renamed, its translations follow it to the new slug.
if (updated && !article.baseSlug && update.slug) {
await Article.updateMany({ baseSlug: article.slug }, { baseSlug: update.slug });
}
return updated;
}
/**
* A member's edit becomes a proposal instead of a write. The whole proposed
* state is kept, next to the article as it stands right now; saving again
* while the request waits revises it rather than queueing a second copy.
*/
async function submitProposal(article, account, body) {
const proposed = {
title: body.title !== undefined ? String(body.title).trim() : article.title,
content: body.content !== undefined ? String(body.content) : article.content,
tags: body.tags !== undefined ? body.tags : article.tags,
language:
body.language !== undefined ? languageCode(body.language) : languageCode(article.language),
};
return EditProposal.findOneAndUpdate(
{ article: article._id, createdBy: account._id, status: "pending" },
{
$set: {
...proposed,
base: {
title: article.title,
content: article.content,
tags: article.tags ?? [],
},
decidedBy: null,
decidedByUsername: "",
decidedAt: null,
},
$setOnInsert: {
article: article._id,
createdBy: account._id,
createdByUsername: account.username,
},
},
{ new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true }
);
}
/**
* A member's create becomes a request for a page that does not exist yet: the
* whole state of the article they want, with the address it asks for and the
* version group it joins, if it translates one. The editor holds one draft, so
* saving again while the request waits revises it — by the request's own id
* when the editor carries one (a draft retitled since it was sent asks for a
* different address, and is still the same piece of work), else by the address
* it asks for.
*/
async function submitCreationProposal(account, creation, proposalId) {
const state = {
title: creation.title,
content: creation.content,
tags: creation.tags,
language: creation.language,
slug: creation.slug,
baseSlug: creation.identity,
};
if (proposalId) {
const waiting = await EditProposal.findById(proposalId);
if (
waiting?.kind === "create" &&
waiting.status === "pending" &&
waiting.createdBy?.equals(account._id)
) {
Object.assign(waiting, state);
await waiting.save();
return waiting;
}
// an id that is not this account's own waiting request is not trusted: the
// draft is filed as a fresh one rather than dropped
}
// The address is what the request is found by, so the filter alone gives a
// new one its `slug`: writing it here as well would conflict with itself.
return EditProposal.findOneAndUpdate(
{ article: null, kind: "create", createdBy: account._id, status: "pending", slug: creation.slug },
{
$set: {
title: creation.title,
content: creation.content,
tags: creation.tags,
language: creation.language,
baseSlug: creation.identity,
decidedBy: null,
decidedByUsername: "",
decidedAt: null,
},
$setOnInsert: {
kind: "create",
article: null,
createdBy: account._id,
createdByUsername: account.username,
},
},
{ new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true }
);
}
// PUT /api/articles/slug/:slug - update an article (sign-in required).
// An admin writes through; a member's change is held as a proposal an admin
// approves later — 202 says the page itself has not moved.
router.put("/slug/:slug", requireAuth, async (req, res, next) => { router.put("/slug/:slug", requireAuth, async (req, res, next) => {
try { try {
const { title, content, tags, language } = req.body || {}; const { title } = req.body || {};
if (title !== undefined && !String(title).trim()) { if (title !== undefined && !String(title).trim()) {
return res.status(400).json({ message: "Title is required" }); return res.status(400).json({ message: "Title is required" });
} }
const article = await Article.findOne({ slug: req.params.slug }); const article = await Article.findOne({ slug: req.params.slug });
if (!article) return res.status(404).json({ message: "Article not found" }); if (!article) return res.status(404).json({ message: "Article not found" });
const update = {}; const account = await User.findById(req.user.id).select("username role");
if (title !== undefined) update.title = title; if (!account) return res.status(401).json({ message: "No such account any more." });
if (content !== undefined) update.content = content;
if (tags !== undefined) update.tags = tags;
// Moving this version to another language must not collide with a if (account.role !== "admin") {
// version the article already has in that language. const proposal = await submitProposal(article, account, req.body || {});
if (language !== undefined) { return res.status(202).json({ proposal });
const code = languageCode(language);
if (code !== article.language) {
const identity = article.baseSlug || article.slug;
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
slug: { $ne: article.slug },
language: code,
});
if (clash) {
return res
.status(400)
.json({ message: `This article already has a version in ${code}` });
}
update.language = code;
}
} }
// Re-slug when the title changes and no explicit slug was provided res.json(await applyArticleEdit(article, req.body || {}));
if (title !== undefined) {
const requested = req.body.slug ? slugify(req.body.slug) : slugify(title);
if (requested && requested !== article.slug) {
update.slug = await uniqueSlug(requested, article.slug);
}
}
const updated = await Article.findOneAndUpdate(
{ slug: req.params.slug },
update,
{ new: true, runValidators: true }
);
// The version group's identity is the canonical article's slug: when the
// canonical is renamed, its translations follow it to the new slug.
if (updated && !article.baseSlug && update.slug) {
await Article.updateMany({ baseSlug: article.slug }, { baseSlug: update.slug });
}
res.json(updated);
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
// DELETE /api/articles/slug/:slug - delete an article (sign-in required). // DELETE /api/articles/slug/:slug - delete an article, admins alone. Writing
// Deleting the canonical version leaves its translations readable: they still // through a proposal is how a member's change lands; deleting is the one thing
// recognise each other through the baseSlug they share. // nobody does to the wiki without holding the role. Deleting the canonical
router.delete("/slug/:slug", requireAuth, async (req, res, next) => { // version leaves its translations readable: they still recognise each other
// through the baseSlug they share.
router.delete("/slug/:slug", requireAuth, requireAdmin, async (req, res, next) => {
try { try {
const deleted = await Article.findOneAndDelete({ slug: req.params.slug }); const deleted = await Article.findOneAndDelete({ slug: req.params.slug });
if (!deleted) return res.status(404).json({ message: "Article not found" }); if (!deleted) return res.status(404).json({ message: "Article not found" });
// requests for a deleted article are settled with it: nothing is left to
// approve, and a reviewer should not be shown a page that has gone
await EditProposal.deleteMany({ article: deleted._id });
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) {
next(err); next(err);

1304
backend/src/seed-bulk.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,9 @@ npm run dev # → http://localhost:3000
| Route | What it does | | Route | What it does |
| --- | --- | | --- | --- |
| `/` | Search + recently updated articles | | `/` | Search + recently updated articles |
| `/wiki/<slug>` | Rendered article with table of contents | | `/wiki/<lang>/<slug>` | One language version of an article, with its table of contents — every version has an address of its own, English included (`/wiki/en/…`) |
| `/wiki/<slug>/edit` | Markdown editor with live preview | | `/wiki/<slug>` | The short form, naming no language: it is turned onto the address of the version the slug speaks |
| `/wiki/<lang>/<slug>/edit` — `/wiki/<slug>/edit` | Markdown editor with live preview |
| `/new` | Create a new article | | `/new` | Create a new article |
## Markdown + LaTeX ## Markdown + LaTeX
@@ -46,7 +47,8 @@ Handy macros: `\R`, `\N`, `\Z`, `\Q`, `\C` for the number sets.
| Method | Endpoint | | Method | Endpoint |
| --- | --- | | --- | --- |
| GET | `/api/articles?q=&tag=` — search / filter, no content body | | GET | `/api/articles?q=&tag=&lang=&limit=&offset=` — search / filter, paged **by article** (`{ articles, total, hasMore }`; no `limit` means the whole shelf, and `?lang=` answers each article as the version written in it) |
| GET | `/api/articles/facets` — `{ total, topics, languages }` counted over the whole shelf |
| GET | `/api/articles/slug/:slug` — one article | | GET | `/api/articles/slug/:slug` — one article |
| POST | `/api/articles` — create (`title` required; slug auto-generated & uniquified) | | POST | `/api/articles` — create (`title` required; slug auto-generated & uniquified) |
| PUT | `/api/articles/slug/:slug` — update | | PUT | `/api/articles/slug/:slug` — update |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,9 @@
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar() const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
const { show: openSearch } = useSearch() const { show: openSearch } = useSearch()
const { show: openSettings } = useSettings() const { show: openSettings } = useSettings()
const { tabs, lastSlug } = useArticleTabs() const { tabs, lastIdentity } = useArticleTabs()
const { user, isAdmin, signOut } = useAuth() const { user, isAdmin, signOut } = useAuth()
const { pending: waitingRequests, refresh: refreshPending } = usePendingRequests()
const { show: openSignIn } = useSignInPopup() const { show: openSignIn } = useSignInPopup()
const { show: openProfile } = useProfileMenu() const { show: openProfile } = useProfileMenu()
const { t } = useI18n() const { t } = useI18n()
@@ -15,21 +16,43 @@ const router = useRouter()
lays their pages side by side, older ones parked with only their spines 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 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 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 (the graph, the list, the editor, a missing article) and every article you
into view, stacked as you left it. */ have open comes back into view, stacked as you left it. In view only when the
const inReader = computed(() => wikiSlugFromPath(route.path) !== null) route names one of the open pages: a missing article reads like the reader
but holds none of them, so the button still turns back from there. */
const inReader = computed(() => tabAt(tabs.value, route.path) !== null)
function showOpenArticles() { function showOpenArticles() {
if (inReader.value) return // the pages are already spread out in view 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 /* turn back to the page that was last in view — every tab knows its own
address, language versions included */ address, language versions included */
const target = const target =
tabs.value.find((t) => t.slug === lastSlug.value) ?? tabs.value[tabs.value.length - 1] tabs.value.find((t) => t.identity === lastIdentity.value) ?? tabs.value[tabs.value.length - 1]
if (target) void router.push(tabHref(target)) if (target) void router.push(tabHref(target))
} }
/* the shortcut hint beside the search bar — "/" keeps focus on the home /* How many requests are waiting is an admin's business: ask once the session
page's inline box there, so the popup's universal key is Ctrl/⌘+K */ is known to be one — and when a session stops being one, the door goes blank
with the links — and again on every navigation, so the number on the door is
no older than the page under it. While an admin decides at the desk, the
desk itself keeps the count exact (see usePendingRequests). */
watch(
isAdmin,
() => {
if (import.meta.client) void refreshPending()
},
{ immediate: true }
)
watch(
() => route.path,
() => {
if (import.meta.client && isAdmin.value) void refreshPending()
}
)
/* the shortcut hint beside the search bar — refined on the client to the
device's chord ("/" opens the popup too, just not while a field holds
the caret) */
const kbdHint = ref('/') const kbdHint = ref('/')
onMounted(() => { onMounted(() => {
kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K' kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K'
@@ -100,6 +123,7 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
<NuxtLink <NuxtLink
v-if="isAdmin" v-if="isAdmin"
to="/admin" to="/admin"
end
class="nav-link" class="nav-link"
:title="t('sidebar.adminHint')" :title="t('sidebar.adminHint')"
> >
@@ -109,6 +133,20 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
</svg> </svg>
<span class="hide-collapsed">{{ t('sidebar.admin') }}</span> <span class="hide-collapsed">{{ t('sidebar.admin') }}</span>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin/proposals"
class="nav-link sidebar-requests"
:title="waitingRequests ? t('sidebar.requestsWaiting', waitingRequests) : t('sidebar.requestsHint')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="m9 15 2 2 4-4" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.requests') }}</span>
<span v-if="waitingRequests" class="requests-count">{{ waitingRequests }}</span>
</NuxtLink>
<NuxtLink to="/new" class="btn btn-primary btn-sm" :title="t('sidebar.newArticle')"> <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"> <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" /> <path d="M12 5v14M5 12h14" />

View File

@@ -24,6 +24,9 @@ const emit = defineEmits<{
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
/* deleting is an admin's call: a member's changes to an article go through
proposals, and dropping the page outright is not among what they may do */
const { isAdmin } = useAuth()
const { open, hide } = useArticleActionsMenu() const { open, hide } = useArticleActionsMenu()
const { open: langMenuOpen } = useLanguageMenu() const { open: langMenuOpen } = useLanguageMenu()
const { open: searchOpen } = useSearch() const { open: searchOpen } = useSearch()
@@ -106,22 +109,24 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
:editor-slug="editorSlug" :editor-slug="editorSlug"
/> />
<div class="kebab-sep" /> <template v-if="isAdmin">
<div class="kebab-sep" />
<button <button
type="button" type="button"
class="kebab-item kebab-danger" class="kebab-item kebab-danger"
role="menuitem" role="menuitem"
@click="hide(); emit('remove')" @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"> <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="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <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="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M10 11v6M14 11v6" /> <path d="M10 11v6M14 11v6" />
</svg> </svg>
{{ t('common.delete') }} {{ t('common.delete') }}
</button> </button>
</template>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -4,8 +4,10 @@
A page turned to slides over the older ones, which park along the left 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 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 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 truth — every page is a plain /wiki/<lang>/<slug> URL — and useArticleTabs
the stack of pages. keeps the stack of pages, one page per article however many languages the
article speaks: turning one to another language turns this page over, it
does not add one to the row.
Pages further along the book than the one in view wait off the right edge 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 of the desk, where you would have to scroll sideways to reach them. Their
@@ -31,7 +33,7 @@ const { open: actionsMenuOpen } = useArticleActionsMenu()
const { collapsed } = useSidebar() const { collapsed } = useSidebar()
const activeTab = computed(() => tabAt(tabs.value, route.path)) const activeTab = computed(() => tabAt(tabs.value, route.path))
const activeSlug = computed(() => activeTab.value?.slug ?? null) const activeIdentity = computed(() => activeTab.value?.identity ?? null)
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0) const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
const trackEl = ref<HTMLElement | null>(null) const trackEl = ref<HTMLElement | null>(null)
@@ -44,8 +46,8 @@ function liesFlat() {
/** Which page of the book is the one in view, or -1 when the route names /** 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. */ * none of them. Only the pages beyond it can be waiting for you. */
const openIndex = computed(() => { const openIndex = computed(() => {
const slug = activeSlug.value const identity = activeIdentity.value
return slug === null ? -1 : tabs.value.findIndex((t) => t.slug === slug) return identity === null ? -1 : tabs.value.findIndex((t) => t.identity === identity)
}) })
/** /**
@@ -57,10 +59,10 @@ const openIndex = computed(() => {
async function alignToActive(behavior: ScrollBehavior = 'smooth') { async function alignToActive(behavior: ScrollBehavior = 'smooth') {
await nextTick() await nextTick()
const track = trackEl.value const track = trackEl.value
const slug = activeSlug.value const identity = activeIdentity.value
if (!track || !slug || !liesFlat()) return if (!track || !identity || !liesFlat()) return
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`) const page = track.querySelector<HTMLElement>(`[data-tab="${CSS.escape(identity)}"]`)
const i = Number(page?.dataset.index) const i = Number(page?.dataset.index)
if (!page || !Number.isFinite(i)) return if (!page || !Number.isFinite(i)) return
@@ -77,10 +79,10 @@ async function alignToActive(behavior: ScrollBehavior = 'smooth') {
off the row, not guessed at: slide the book and a spine leaves the stack as 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. */ 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. */ /** The identities of the pages whose spines are in the dock. */
const waitingSlugs = ref<string[]>([]) const waitingPages = ref<string[]>([])
/** Those pages, in the order they were opened. */ /** Those pages, in the order they were opened. */
const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug))) const waiting = computed(() => tabs.value.filter((t) => waitingPages.value.includes(t.identity)))
/** /**
* Fill the dock with the pages beyond the one in view whose spines have no * Fill the dock with the pages beyond the one in view whose spines have no
@@ -91,22 +93,24 @@ function measureDock() {
const track = trackEl.value const track = trackEl.value
const open = openIndex.value const open = openIndex.value
if (!track || open === -1 || !liesFlat()) { if (!track || open === -1 || !liesFlat()) {
waitingSlugs.value = [] waitingPages.value = []
return return
} }
const edge = track.getBoundingClientRect().right const edge = track.getBoundingClientRect().right
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0 const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
const held = new Set(waitingSlugs.value) const held = new Set(waitingPages.value)
for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) { for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) {
const i = Number(page.dataset.index) const i = Number(page.dataset.index)
const slug = page.dataset.slug const identity = page.dataset.tab
if (!slug || !Number.isFinite(i) || i <= open) continue if (!identity || !Number.isFinite(i) || i <= open) continue
const left = page.getBoundingClientRect().left const left = page.getBoundingClientRect().left
if (left > edge - 6) held.add(slug) if (left > edge - 6) held.add(identity)
else if (left < edge - spine - 10) held.delete(slug) else if (left < edge - spine - 10) held.delete(identity)
} }
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug) waitingPages.value = tabs.value
.filter((t, i) => i > open && held.has(t.identity))
.map((t) => t.identity)
} }
/* the row moves on scroll, so the dock is measured once a frame while it does */ /* the row moves on scroll, so the dock is measured once a frame while it does */
@@ -121,7 +125,7 @@ function watchDock() {
onBeforeUnmount(() => cancelAnimationFrame(measuring)) onBeforeUnmount(() => cancelAnimationFrame(measuring))
/* turning to an article slides the book round to it */ /* turning to an article slides the book round to it */
watch(activeSlug, () => void alignToActive('smooth')) watch(activeIdentity, () => void alignToActive('smooth'))
/* and the row changing shape — however it changed — changes which pages are /* and the row changing shape — however it changed — changes which pages are
waiting out beyond the edge of the desk */ waiting out beyond the edge of the desk */
watch(tabs, () => void nextTick(measureDock)) watch(tabs, () => void nextTick(measureDock))
@@ -162,18 +166,21 @@ watch(
{ immediate: true } { immediate: true }
) )
function turnTo(slug: string) { function turnTo(identity: string) {
const tab = tabs.value.find((t) => t.slug === slug) const tab = tabs.value.find((t) => t.identity === identity)
if (tab && slug !== activeSlug.value) void router.push(tabHref(tab)) if (tab && identity !== activeIdentity.value) void router.push(tabHref(tab))
} }
async function removeArticle(slug: string) { /* the page's × deletes the very version it holds — the document its address
const title = tabs.value.find((t) => t.slug === slug)?.title ?? slug asks for, which for a translation is its own — and closes the page */
if (!window.confirm(t('book.deleteConfirm', { title }))) return async function removeArticle(identity: string) {
const tab = tabs.value.find((t) => t.identity === identity)
if (!tab) return
if (!window.confirm(t('book.deleteConfirm', { title: tab.title }))) return
try { try {
await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' }) await $fetch(`${api}/articles/slug/${encodeURIComponent(tab.slug)}`, { method: 'DELETE' })
forget(slug) forget(tab.slug)
closeTab(slug) closeTab(identity)
} catch { } catch {
window.alert(t('book.deleteFailed')) window.alert(t('book.deleteFailed'))
} }
@@ -185,7 +192,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') return if (e.key !== 'Escape') return
if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value) if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value)
return return
const current = tabAt(tabs.value, route.path)?.slug const current = tabAt(tabs.value, route.path)?.identity
if (current) closeTab(current) if (current) closeTab(current)
} }
onMounted(() => window.addEventListener('keydown', onGlobalKeydown)) onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
@@ -202,18 +209,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
> >
<div <div
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.slug" :key="tab.identity"
class="book-chip" class="book-chip"
:class="{ active: tab.slug === activeSlug }" :class="{ active: tab.identity === activeIdentity }"
role="tab" role="tab"
tabindex="0" tabindex="0"
:aria-selected="tab.slug === activeSlug" :aria-selected="tab.identity === activeIdentity"
:title="t('book.chipHint', { title: tab.title })" :title="t('book.chipHint', { title: tab.title })"
@click="turnTo(tab.slug)" @click="turnTo(tab.identity)"
@keydown.enter.prevent="turnTo(tab.slug)" @keydown.enter.prevent="turnTo(tab.identity)"
> >
<span class="chip-title">{{ tab.title }}</span> <span class="chip-title">{{ tab.title }}</span>
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)"> <button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true"> <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" /> <path d="M6 6l12 12M18 6L6 18" />
</svg> </svg>
@@ -224,28 +231,29 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
<div ref="trackEl" class="book-track" @scroll.passive="watchDock"> <div ref="trackEl" class="book-track" @scroll.passive="watchDock">
<ArticlePane <ArticlePane
v-for="(tab, i) in tabs" v-for="(tab, i) in tabs"
:key="tab.slug" :key="tab.identity"
:identity="tab.identity"
:slug="tab.slug" :slug="tab.slug"
:index="i" :index="i"
:active="tab.slug === activeSlug" :active="tab.identity === activeIdentity"
@activate="turnTo(tab.slug)" @activate="turnTo(tab.identity)"
@close="closeTab(tab.slug)" @close="closeTab(tab.identity)"
@remove="removeArticle(tab.slug)" @remove="removeArticle(tab.identity)"
/> />
</div> </div>
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')"> <nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
<div <div
v-for="tab in waiting" v-for="tab in waiting"
:key="tab.slug" :key="tab.identity"
class="dock-spine" class="dock-spine"
role="button" role="button"
tabindex="0" tabindex="0"
:title="t('book.dockHint', { title: tab.title })" :title="t('book.dockHint', { title: tab.title })"
@click="turnTo(tab.slug)" @click="turnTo(tab.identity)"
@keydown.enter.prevent="turnTo(tab.slug)" @keydown.enter.prevent="turnTo(tab.identity)"
> >
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)"> <button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"> <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" /> <path d="M6 6l12 12M18 6L6 18" />
</svg> </svg>

View File

@@ -35,9 +35,7 @@ const multiLang = computed(() => langs.value.length > 1)
const langCodes = computed(() => langs.value.map((v) => v.language.toUpperCase()).join(' · ')) const langCodes = computed(() => langs.value.map((v) => v.language.toUpperCase()).join(' · '))
const langNames = computed(() => langs.value.map((v) => languageName(v.language)).join(', ')) const langNames = computed(() => langs.value.map((v) => languageName(v.language)).join(', '))
/* where the card leads: the language address of the version it shows */ /* where the card leads: the language address of the version it shows */
const href = computed(() => const href = computed(() => versionHref(identityOf(props.article), language.value))
versionHref(identityOf(props.article), { language: language.value, slug: props.article.slug })
)
const updated = computed(() => { const updated = computed(() => {
const d = props.article.updatedAt ? new Date(props.article.updatedAt) : null const d = props.article.updatedAt ? new Date(props.article.updatedAt) : null

View File

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

View File

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

View File

@@ -1,15 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
/* One page of the open book. Its left edge is a spine — the title, set /* One page of the open book: one article, in whichever language it is turned
vertically, with the × that closes the page — and the article itself to. Its left edge is a spine — the title, set vertically, with the × that
scrolls on its own inside the page. A newer page slides over this one and closes the page — and the article itself scrolls on its own inside the page.
leaves only the spine showing, the way a stack of read pages does; click A newer page slides over this one and leaves only the spine showing, the way
anywhere on the page — or its spine — to turn back to it. a stack of read pages does; click anywhere on the page — or its spine — to
turn back to it. Turning the article to another language changes the text on
this page and nothing else: it is the same page of the book, read again.
Everything but the spine of a page that isn't in view is `inert`: it can 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 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 focus or clicks — the pane's own click handler catches those instead, so
one click turns to the page before its links do anything. */ one click turns to the page before its links do anything. */
const props = defineProps<{ const props = defineProps<{
/** the article this page holds — its canonical slug */
identity: string
/** the version in view, as the library files it */
slug: string slug: string
index: number index: number
active: boolean active: boolean
@@ -32,12 +37,20 @@ const language = computed(() => articleLanguage(article.value ?? {}))
const languageLabel = computed(() => languageName(language.value)) const languageLabel = computed(() => languageName(language.value))
/* every language this article exists in, the one in view included */ /* every language this article exists in, the one in view included */
const versions = computed(() => article.value?.versions ?? []) const versions = computed(() => article.value?.versions ?? [])
/* the article behind this document, and the slug its editor answers to: /* the slug the editor answers to: the identity when the canonical version is
the identity when the canonical version is still around, this document's still around, this document's own slug when only translations of it remain */
own slug when only translations of it remain */
const identity = computed(() => identityOf(article.value ?? { slug: props.slug }))
const editorSlug = computed(() => const editorSlug = computed(() =>
versions.value.some((v) => v.slug === identity.value) ? identity.value : props.slug versions.value.some((v) => v.slug === props.identity) ? props.identity : props.slug
)
/* turned to another language, the page begins again at the top of the text
it now holds — the article is the same, its words are not */
const scrollEl = ref<HTMLElement | null>(null)
watch(
() => props.slug,
(to, from) => {
if (from) scrollEl.value?.scrollTo({ top: 0 })
}
) )
const updated = computed(() => { const updated = computed(() => {
@@ -61,7 +74,7 @@ function topicLabel(tag: string) {
<section <section
class="book-pane" class="book-pane"
:class="{ active }" :class="{ active }"
:data-slug="slug" :data-tab="identity"
:data-index="index" :data-index="index"
:style="{ '--i': index }" :style="{ '--i': index }"
:aria-label="t('pane.aria', { title })" :aria-label="t('pane.aria', { title })"
@@ -91,7 +104,7 @@ function topicLabel(tag: string) {
</div> </div>
<div class="pane-body" :inert="active ? null : ''"> <div class="pane-body" :inert="active ? null : ''">
<div class="pane-scroll"> <div ref="scrollEl" class="pane-scroll">
<div v-if="failed && !article" class="empty"> <div v-if="failed && !article" class="empty">
<div class="glyph">!</div> <div class="glyph">!</div>
<h3>{{ t('pane.readFailTitle') }}</h3> <h3>{{ t('pane.readFailTitle') }}</h3>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
/* Centered profile popup, opened by the account row in the sidebar (and closed /* 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 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 popup). It holds the account itself: the profile picture, the username, the
the password, and the way out. */ password, the interface language the account reads the wiki in, and the
way out. */
const { open, hide } = useProfileMenu() const { open, hide } = useProfileMenu()
const { hide: hideSearch } = useSearch() const { hide: hideSearch } = useSearch()
const { hide: hideSettings } = useSettings() const { hide: hideSettings } = useSettings()
const { hide: hideSignIn, show: openSignIn } = useSignInPopup() const { hide: hideSignIn, show: openSignIn } = useSignInPopup()
const { user, signOut, updateUsername, changePassword, setAvatar } = useAuth() const { user, signOut, updateUsername, changePassword, setAvatar, updateLocale } = useAuth()
const { t } = useI18n() const { t, locale } = useI18n()
const { choose } = useAppLocale()
const route = useRoute() const route = useRoute()
const panelEl = ref<HTMLElement | null>(null) const panelEl = ref<HTMLElement | null>(null)
@@ -166,6 +168,24 @@ function leave() {
hide() hide()
} }
/* --- the interface language ---
Turn the language Mathew speaks in. The device's own choice (the cookie
setLocale keeps) lands at once, and the account saves it too, so the next
device says the same hello. An account that won't take it — a session gone
quiet — doesn't undo the switch made here. */
const language = ref('en')
async function setLanguage(code: string) {
if (language.value === code) return
language.value = code
await choose(code)
try {
await updateLocale(code)
} catch {
/* the device-level switch stands on its own */
}
}
/* sync the form from the account whenever the popup opens */ /* sync the form from the account whenever the popup opens */
watch( watch(
open, open,
@@ -178,6 +198,7 @@ watch(
username.value = user.value?.username ?? '' username.value = user.value?.username ?? ''
currentPassword.value = '' currentPassword.value = ''
newPassword.value = '' newPassword.value = ''
language.value = locale.value
avatarBusy.value = false avatarBusy.value = false
avatarError.value = '' avatarError.value = ''
nameError.value = '' nameError.value = ''
@@ -340,6 +361,30 @@ watch(
<p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p> <p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p>
</section> </section>
<!-- the interface language -->
<section class="profile-group" aria-labelledby="profile-language">
<div class="profile-group-head">
<span id="profile-language" class="profile-group-title">{{ t('profile.language') }}</span>
<span class="profile-group-hint">{{ t('profile.languageHint') }}</span>
</div>
<div class="theme-switch" role="radiogroup" :aria-label="t('profile.languageAria')">
<button
v-for="l in APP_LANGUAGES"
:key="l.code"
type="button"
role="radio"
class="theme-option"
:class="{ active: language === l.code }"
:aria-checked="language === l.code"
:lang="l.code"
@click="setLanguage(l.code)"
>
<span>{{ l.name }}</span>
</button>
</div>
</section>
<section class="profile-group"> <section class="profile-group">
<button type="button" class="btn btn-danger btn-sm profile-signout" @click="leave()"> <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"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ const others = computed(() =>
<div class="rv-title">{{ t('recent.title') }}</div> <div class="rv-title">{{ t('recent.title') }}</div>
<ul> <ul>
<li v-for="v in others" :key="v.slug"> <li v-for="v in others" :key="v.slug">
<NuxtLink :to="`/wiki/${v.slug}`">{{ v.title }}</NuxtLink> <NuxtLink :to="viewedHref(v)">{{ v.title }}</NuxtLink>
</li> </li>
</ul> </ul>
</nav> </nav>

View File

@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ArticleMeta } from './ArticleCard.vue' import type { ArticleMeta } from './ArticleCard.vue'
/* Centered search popup: the sidebar search bar (or Ctrl/⌘+K, or "/" /* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
on pages without their own search box) opens it, then it live-searches or "/" anywhere but a text field) open it, then it live-searches
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */ articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
const api = `${useRuntimeConfig().public.apiBase}/api` const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { th } = useAppLocale() const { th, locale } = useAppLocale()
const { open, show, hide, toggle } = useSearch() const { open, show, hide, toggle } = useSearch()
const { hide: hideSettings } = useSettings() const { hide: hideSettings } = useSettings()
const { hide: hideProfile } = useProfileMenu() const { hide: hideProfile } = useProfileMenu()
@@ -59,11 +59,16 @@ watch(trimmed, () => {
async function runSearch() { async function runSearch() {
const seq = ++requestSeq const seq = ++requestSeq
try { try {
const res = await $fetch<ArticleMeta[]>(`${api}/articles`, { /* the popup never shows more than MAX_RESULTS, so that is what it asks
query: { q: trimmed.value }, the shelf for rather than every match it has; `prefer` asks each answer
to stand with the reader's own language when the article speaks it, and
`preferFirst` ranks the articles that truly speak it above the ones
shown only through the fallback */
const page = await $fetch<{ articles: ArticleMeta[] }>(`${api}/articles`, {
query: { q: trimmed.value, limit: MAX_RESULTS, prefer: locale.value, preferFirst: '1' },
}) })
if (seq !== requestSeq) return // a newer search already won if (seq !== requestSeq) return // a newer search already won
results.value = res.slice(0, MAX_RESULTS) results.value = page.articles
failed.value = false failed.value = false
} catch { } catch {
if (seq !== requestSeq) return if (seq !== requestSeq) return
@@ -86,9 +91,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
hide() hide()
return return
} }
/* "/" also opens the popup — except on the home page, where the key /* "/" also opens the popup anywhere — except while typing in a field */
focuses the hero's inline search, and while typing in a field */ if (e.key === '/' && !open.value) {
if (e.key === '/' && !open.value && route.path !== '/') {
const t = e.target const t = e.target
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
e.preventDefault() e.preventDefault()
@@ -105,7 +109,7 @@ onBeforeUnmount(() => {
/* where a result leads: the language address of the version it shows */ /* where a result leads: the language address of the version it shows */
function articleHref(a: ArticleMeta): string { function articleHref(a: ArticleMeta): string {
return versionHref(identityOf(a), { language: articleLanguage(a), slug: a.slug }) return versionHref(identityOf(a), articleLanguage(a))
} }
function openArticle(article: ArticleMeta) { function openArticle(article: ArticleMeta) {
@@ -250,7 +254,7 @@ function highlight(text: string): Part[] {
<div class="search-quick-title">{{ t('recent.title') }}</div> <div class="search-quick-title">{{ t('recent.title') }}</div>
<ul class="search-results"> <ul class="search-results">
<li v-for="v in viewed.slice(0, 6)" :key="v.slug"> <li v-for="v in viewed.slice(0, 6)" :key="v.slug">
<NuxtLink :to="`/wiki/${v.slug}`" class="search-result"> <NuxtLink :to="viewedHref(v)" class="search-result">
<span class="sr-title">{{ v.title }}</span> <span class="sr-title">{{ v.title }}</span>
</NuxtLink> </NuxtLink>
</li> </li>

View File

@@ -1,23 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
/* Centered settings popup, opened by the sidebar cog (and closed with esc, /* Centered settings popup, opened by the sidebar cog (and closed with esc,
a backdrop click, or navigation). It holds the preferences — the light / a backdrop click, or navigation). It holds the preferences — the light /
dark theme switch, the accent colour, and the interface language — which dark theme switch and the accent colour — which drive the data-theme and
drive the data-theme and data-accent attributes that the pre-paint script data-accent attributes that the pre-paint script in nuxt.config.ts
in nuxt.config.ts restores from localStorage. The language lives in the restores from localStorage. The interface language lives in the profile
mathew-locale cookie and, with an account behind the session, on the popup instead, since it follows the person across devices. */
account itself, so it follows the person across devices. */
const { open, hide } = useSettings() const { open, hide } = useSettings()
const { hide: hideSearch } = useSearch() const { hide: hideSearch } = useSearch()
const { hide: hideSignIn } = useSignInPopup() const { hide: hideSignIn } = useSignInPopup()
const { hide: hideProfile } = useProfileMenu() const { hide: hideProfile } = useProfileMenu()
const { loggedIn, updateLocale } = useAuth() const { t } = useI18n()
const { t, locale } = useI18n()
const { choose } = useAppLocale()
const route = useRoute() const route = useRoute()
const theme = ref<'light' | 'dark'>('light') const theme = ref<'light' | 'dark'>('light')
const accent = ref('violet') const accent = ref('violet')
const language = ref('en')
const panelEl = ref<HTMLElement | null>(null) const panelEl = ref<HTMLElement | null>(null)
const themes = [ const themes = [
@@ -38,7 +34,7 @@ const accents = [
{ value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' }, { value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' },
] as const ] as const
/* read the live theme, accent + language whenever the popup opens so the /* read the live theme + accent whenever the popup opens so the
switches are in sync */ switches are in sync */
watch( watch(
open, open,
@@ -51,7 +47,6 @@ watch(
theme.value = theme.value =
(document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light' (document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light'
accent.value = document.documentElement.getAttribute('data-accent') || 'violet' accent.value = document.documentElement.getAttribute('data-accent') || 'violet'
language.value = locale.value
await nextTick() await nextTick()
panelEl.value?.focus() panelEl.value?.focus()
}, },
@@ -79,23 +74,6 @@ function setAccent(value: string) {
} }
} }
/* Turn the interface language. The device's own choice (the cookie setLocale
keeps) lands at once; with an account it is saved there too, so the next
device says the same hello. An account that won't take it — a session gone
quiet — doesn't undo the switch made here. */
async function setLanguage(code: string) {
if (language.value === code) return
language.value = code
await choose(code)
if (loggedIn.value) {
try {
await updateLocale(code)
} catch {
/* the device-level switch stands on its own */
}
}
}
/* --- esc closes, navigation closes --- */ /* --- esc closes, navigation closes --- */
function onGlobalKeydown(e: KeyboardEvent) { function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) hide() if (e.key === 'Escape' && open.value) hide()
@@ -198,29 +176,6 @@ watch(
</button> </button>
</div> </div>
</section> </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> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,41 +9,12 @@ const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { show: openSearch } = useSearch()
const q = ref(String(route.query.q ?? '')) /* the bar opens the Ctrl/⌘+K popup rather than searching on the page itself,
const tag = ref(String(route.query.tag ?? '')) but it still reads back the query the address carries — the popup's
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase()) "all results" link lands here with one */
const langLabel = computed(() => (lang.value ? languageName(lang.value) : '')) const askedQuery = computed(() => String(route.query.q ?? ''))
/* 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 */ /* fetch one article from the API and open it */
const randomPending = ref(false) const randomPending = ref(false)
@@ -52,7 +23,14 @@ async function showRandomArticle() {
randomPending.value = true randomPending.value = true
try { try {
const article = await $fetch<ArticleMeta>(`${api}/articles/random`) const article = await $fetch<ArticleMeta>(`${api}/articles/random`)
await router.push(versionHref(identityOf(article), article)) /* the canonical slug is the article's address, and the middleware there
turns it onto the reader's language; an orphaned translation answers to
no such address, so that one still goes straight to its own version */
await router.push(
article.baseSlug
? versionHref(identityOf(article), articleLanguage(article))
: `/wiki/${encodeURIComponent(article.slug)}`
)
} catch { } catch {
// no articles yet or API unreachable — the banner below already covers API issues // no articles yet or API unreachable — the banner below already covers API issues
} finally { } finally {
@@ -60,38 +38,14 @@ async function showRandomArticle() {
} }
} }
/* "/" focuses the search box */
const searchEl = ref<HTMLInputElement | null>(null)
function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
if (e.key === '/' && !typing) {
e.preventDefault()
searchEl.value?.focus()
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
useSeoMeta({ useSeoMeta({
title: () => t('home.metaTitle'), title: () => t('home.metaTitle'),
description: () => t('home.metaDescription'), 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> </script>
<template> <template>
<div class="home-main"> <div class="home-main is-home">
<section class="hero"> <section class="hero">
<h1 v-html="t('home.hero')"></h1> <h1 v-html="t('home.hero')"></h1>
@@ -100,14 +54,14 @@ const emptyKey = computed(() =>
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" /> <path d="m20 20-3.2-3.2" />
</svg> </svg>
<input <button
ref="searchEl" type="button"
v-model="q" class="search-input search-trigger"
class="search-input"
type="text"
:placeholder="t('home.searchPlaceholder')"
:aria-label="t('common.searchArticles')" :aria-label="t('common.searchArticles')"
/> @click="openSearch()"
>
{{ askedQuery || t('home.searchPlaceholder') }}
</button>
<span class="search-kbd">/</span> <span class="search-kbd">/</span>
</div> </div>
@@ -123,52 +77,5 @@ const emptyKey = computed(() =>
</button> </button>
</div> </div>
</section> </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> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
}, },
"common": { "common": {
"close": "Tanca", "close": "Tanca",
"done": "Fet",
"cancel": "Cancel·la", "cancel": "Cancel·la",
"confirm": "Confirma", "confirm": "Confirma",
"delete": "Elimina", "delete": "Elimina",
@@ -19,9 +20,7 @@
"apiDown": "No s'ha pogut connectar amb l'API. Inicia el backend amb <code>npm run dev</code> a la carpeta <code>backend/</code> i torna-ho a provar.", "apiDown": "No s'ha pogut connectar amb l'API. Inicia el backend amb <code>npm run dev</code> a la carpeta <code>backend/</code> i torna-ho a provar.",
"apiShort": "No s'ha pogut connectar amb l'API. Inicia el backend amb <code>npm run dev</code> a <code>backend/</code>.", "apiShort": "No s'ha pogut connectar amb l'API. Inicia el backend amb <code>npm run dev</code> a <code>backend/</code>.",
"writtenIn": "Escrit en {lang}", "writtenIn": "Escrit en {lang}",
"tagChip": "etiqueta: {tag}",
"languageChip": "idioma: {lang}", "languageChip": "idioma: {lang}",
"clearTag": "Treu el filtre d'etiqueta",
"clearLanguage": "Treu el filtre d'idioma" "clearLanguage": "Treu el filtre d'idioma"
}, },
"sidebar": { "sidebar": {
@@ -34,6 +33,9 @@
"graph": "Graf", "graph": "Graf",
"admin": "Admin", "admin": "Admin",
"adminHint": "Administració — els comptes d'aquesta wiki", "adminHint": "Administració — els comptes d'aquesta wiki",
"requests": "Sol·licituds",
"requestsHint": "Sol·licituds — aprova o rebutja el que proposen els membres",
"requestsWaiting": "{n} sol·licitud d'edició pendent | {n} sol·licituds d'edició pendents",
"newArticle": "Article nou", "newArticle": "Article nou",
"signedInAs": "Sessió de {name} — perfil i compte", "signedInAs": "Sessió de {name} — perfil i compte",
"signInHint": "Iniciar sessió — llegir és lliure, escriure necessita un compte", "signInHint": "Iniciar sessió — llegir és lliure, escriure necessita un compte",
@@ -61,18 +63,13 @@
"metaDescription": "Tota la wiki d'un cop d'ull — quants articles hi ha i quins temes abasten.", "metaDescription": "Tota la wiki d'un cop d'ull — quants articles hi ha i quins temes abasten.",
"hero": "Tots els <span class=\"gradient\">articles</span>", "hero": "Tots els <span class=\"gradient\">articles</span>",
"sub": "Tota la prestatgeria d'un cop d'ull — quant s'ha escrit i quins temes cobreix.", "sub": "Tota la prestatgeria d'un cop d'ull — quant s'ha escrit i quins temes cobreix.",
"countOfTotal": "{shown} de {total} articles", "backToTopics": "Torna als temes",
"articleCount": "{n} article | {n} articles", "articleCount": "{n} article | {n} articles",
"topicCount": "{n} tema | {n} temes", "topicCount": "{n} tema | {n} temes",
"topicSearchPlaceholder": "Cerca temes — prova «calculus», «geometry»…",
"topicSearchAria": "Cerca temes",
"noTopic": "Cap tema coincideix amb «{q}».",
"filterByLanguage": "Filtra per idioma", "filterByLanguage": "Filtra per idioma",
"writtenIn": "Escrits en", "writtenIn": "Escrits en",
"headingTagLang": "Articles amb l'etiqueta «{tag}» · en {lang}", "headingTagLang": "Articles amb l'etiqueta «{tag}» · en {lang}",
"headingTag": "Articles amb l'etiqueta «{tag}»", "headingTag": "Articles amb l'etiqueta «{tag}»",
"headingLang": "Articles en {lang}",
"headingAll": "Tots els articles",
"emptyNoTopicTitle": "No hi ha articles amb aquest tema", "emptyNoTopicTitle": "No hi ha articles amb aquest tema",
"emptyNoLangTitle": "No hi ha articles en {lang}", "emptyNoLangTitle": "No hi ha articles en {lang}",
"emptyNoTopicsTitle": "Encara no hi ha temes", "emptyNoTopicsTitle": "Encara no hi ha temes",
@@ -84,7 +81,7 @@
}, },
"admin": { "admin": {
"metaTitle": "Administració", "metaTitle": "Administració",
"metaDescription": "Tots els comptes de la wiki — qui hi és i què pot fer, cadascun.", "metaDescription": "Tots els comptes de la wiki i el rol que cada un té.",
"sessionExpired": "La sessió ha caducat — inicia-la de nou per obrir el tauler.", "sessionExpired": "La sessió ha caducat — inicia-la de nou per obrir el tauler.",
"roleChangeFailed": "No s'ha pogut canviar aquest rol.", "roleChangeFailed": "No s'ha pogut canviar aquest rol.",
"deleteFailed": "No s'ha pogut eliminar aquest compte.", "deleteFailed": "No s'ha pogut eliminar aquest compte.",
@@ -94,7 +91,6 @@
"forAdminsText": "Aquesta pàgina enumera tots els comptes de la wiki, per això es manté fora de l'abast dels membres ordinaris. Un altre administrador ha de donar aquest rol al teu compte.", "forAdminsText": "Aquesta pàgina enumera tots els comptes de la wiki, per això es manté fora de l'abast dels membres ordinaris. Un altre administrador ha de donar aquest rol al teu compte.",
"backToArticles": "Torna als articles", "backToArticles": "Torna als articles",
"hero": "Tauler d'<span class=\"gradient\">administració</span>", "hero": "Tauler d'<span class=\"gradient\">administració</span>",
"sub": "Tothom qui té compte aquí — qui són i què poden fer.",
"accountCount": "{n} compte | {n} comptes", "accountCount": "{n} compte | {n} comptes",
"adminTally": "{n} admin | {n} admins", "adminTally": "{n} admin | {n} admins",
"memberTally": "{n} membre | {n} membres", "memberTally": "{n} membre | {n} membres",
@@ -108,7 +104,47 @@
"confirmDeleteTitle": "Vols eliminar aquest compte?", "confirmDeleteTitle": "Vols eliminar aquest compte?",
"confirmDeleteMessage": "{username} perd el compte per sempre i ja no hi podrà iniciar sessió. Els articles que va escriure es queden a la wiki.", "confirmDeleteMessage": "{username} perd el compte per sempre i ja no hi podrà iniciar sessió. Els articles que va escriure es queden a la wiki.",
"confirmDeleteButton": "Elimina el compte", "confirmDeleteButton": "Elimina el compte",
"confirmKeepButton": "Conserva'l" "confirmKeepButton": "Conserva'l",
"proposalsHeading": "Sol·licituds d'edició",
"proposalWaitingTally": "{n} pendent | {n} pendents",
"proposalsEmptyTitle": "Cap sol·licitud d'edició",
"proposalsEmptyText": "Quan un membre canvia un article o n'escriu un de nou, arriba aquí com una proposta: aprova-la i es publicarà al wiki.",
"statusPending": "Pendent",
"statusApproved": "Aprovada",
"statusRejected": "Rebutjada",
"actionApproved": "aprovada",
"actionRejected": "rebutjada",
"decidedBy": "{action} per {username} · {date}",
"wantsToChange": "vol canviar",
"wantedToChange": "va voler canviar",
"wantsToCreate": "vol crear",
"wantedToCreate": "va voler crear",
"articleGone": "un article que ja no existeix",
"newArticle": "article nou",
"approve": "Aprova",
"reject": "Rebutja",
"approveFailed": "No s'ha pogut aplicar aquest canvi.",
"rejectFailed": "No s'ha pogut rebutjar aquesta sol·licitud.",
"proposalStale": "L'article va canviar després d'enviar això: aprovar substitueix el que diu ara.",
"fieldTitle": "Títol",
"fieldContent": "Text",
"fieldTags": "Etiquetes",
"fieldNone": "Sense canvis visibles",
"asSent": "Com era quan es va enviar",
"asNewPage": "L'adreça que sol·licita",
"nothingHereYet": "Encara no hi ha res escrit en aquesta adreça: la sol·licitud demana que es creï la pàgina.",
"asProposed": "Com es proposa",
"details": "Detalls",
"hideDetails": "Amaga els detalls",
"decidedHeading": "{n} sol·licitud decidida | {n} sol·licituds decidides"
},
"requests": {
"metaTitle": "Sol·licituds d'edició",
"metaDescription": "Tots els canvis que els membres han proposat: aprova'n un i es publica a la wiki.",
"hero": "Revisió de <span class=\"gradient\">sol·licituds</span>",
"gateHeading": "Inicia sessió per revisar les sol·licituds",
"gateText": "Aquesta pàgina guarda els canvis que proposen els membres, per això es reserva a qui administra la wiki. Inicia sessió amb un compte que tingui aquest rol.",
"forAdminsText": "Aquesta pàgina guarda els canvis que proposen els membres, per això es manté fora de l'abast dels membres ordinaris. Un altre administrador ha de donar aquest rol al teu compte."
}, },
"graph": { "graph": {
"metaTitle": "Graf", "metaTitle": "Graf",
@@ -124,6 +160,7 @@
"notFoundTitle": "Aquest article no existeix (encara)", "notFoundTitle": "Aquest article no existeix (encara)",
"notFoundText": "No hi ha res a <code>{path}</code>. Tu mateix pots escriure'n la primera versió — només cal Markdown.", "notFoundText": "No hi ha res a <code>{path}</code>. Tu mateix pots escriure'n la primera versió — només cal Markdown.",
"writeTopic": "Escriure aquest tema", "writeTopic": "Escriure aquest tema",
"backToArticles": "Torna als articles oberts",
"loadFailTitle": "No s'ha pogut carregar l'article", "loadFailTitle": "No s'ha pogut carregar l'article",
"noVersionTitle": "No hi ha versió en {lang} (encara)", "noVersionTitle": "No hi ha versió en {lang} (encara)",
"noVersionText": "No hi ha res escrit a <code>{path}</code>. Potser l'article existeix en un altre idioma — o pots escriure tu aquesta versió." "noVersionText": "No hi ha res escrit a <code>{path}</code>. Potser l'article existeix en un altre idioma — o pots escriure tu aquesta versió."
@@ -190,7 +227,13 @@
"sessionExpired": "La sessió ha caducat — inicia-la de nou.", "sessionExpired": "La sessió ha caducat — inicia-la de nou.",
"saveFailed": "No s'ha pogut desar l'article. Està en marxa el backend?", "saveFailed": "No s'ha pogut desar l'article. Està en marxa el backend?",
"leaveGuard": "Tens canvis sense desar. Vols sortir sense desar?", "leaveGuard": "Tens canvis sense desar. Vols sortir sense desar?",
"thisArticle": "aquest article" "thisArticle": "aquest article",
"proposalNotice": "Ets membre del wiki: els teus canvis s'envien a l'administració com una proposta; no s'escriu res a la pàgina fins que algú l'aprovi.",
"proposalNoticeCreate": "Ets membre del wiki: els articles nous s'envien a l'administració com una proposta; no es publica res fins que algú l'aprovi.",
"submitProposal": "Envia la proposta",
"proposalSentTitle": "Sol·licitud enviada",
"proposalSent": "Els teus canvis s'han enviat a l'administració perquè els revisi. L'article manté el text actual fins que algú aprovi la sol·licitud.",
"proposalSentCreate": "El teu article nou s'ha enviat a l'administració perquè el revisi. Apareixerà al wiki quan algú aprovi la sol·licitud."
}, },
"cheatsheet": { "cheatsheet": {
"title": "Full d'ajuda", "title": "Full d'ajuda",
@@ -240,10 +283,7 @@
"accentForest": "bosc", "accentForest": "bosc",
"accentAmber": "ambre", "accentAmber": "ambre",
"accentRose": "rosa", "accentRose": "rosa",
"accentSlate": "pisarra", "accentSlate": "pisarra"
"language": "Idioma",
"languageHint": "L'idioma en què Mathew et parla. Si has iniciat sessió, es desa amb el teu compte.",
"languageAria": "Idioma de la interfície"
}, },
"auth": { "auth": {
"signin": "Iniciar sessió", "signin": "Iniciar sessió",
@@ -294,7 +334,10 @@
"newPassword": "Contrasenya nova", "newPassword": "Contrasenya nova",
"changePassword": "Canviar la contrasenya", "changePassword": "Canviar la contrasenya",
"passwordFailed": "No s'ha pogut canviar la contrasenya.", "passwordFailed": "No s'ha pogut canviar la contrasenya.",
"passwordChanged": "Contrasenya canviada." "passwordChanged": "Contrasenya canviada.",
"language": "Idioma",
"languageHint": "L'idioma en què Mathew et parla. Es desa amb el teu compte.",
"languageAria": "Idioma de la interfície"
}, },
"search": { "search": {
"placeholder": "Cerca articles per títol o etiqueta…", "placeholder": "Cerca articles per títol o etiqueta…",

View File

@@ -4,6 +4,7 @@
}, },
"common": { "common": {
"close": "Schließen", "close": "Schließen",
"done": "Fertig",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen", "confirm": "Bestätigen",
"delete": "Löschen", "delete": "Löschen",
@@ -19,9 +20,7 @@
"apiDown": "Die API war nicht zu erreichen. Starte das Backend mit <code>npm run dev</code> im Ordner <code>backend/</code> und versuch es erneut.", "apiDown": "Die API war nicht zu erreichen. Starte das Backend mit <code>npm run dev</code> im Ordner <code>backend/</code> und versuch es erneut.",
"apiShort": "Die API war nicht zu erreichen. Starte das Backend mit <code>npm run dev</code> in <code>backend/</code>.", "apiShort": "Die API war nicht zu erreichen. Starte das Backend mit <code>npm run dev</code> in <code>backend/</code>.",
"writtenIn": "Geschrieben auf {lang}", "writtenIn": "Geschrieben auf {lang}",
"tagChip": "Stichwort: {tag}",
"languageChip": "Sprache: {lang}", "languageChip": "Sprache: {lang}",
"clearTag": "Stichwortfilter entfernen",
"clearLanguage": "Sprachfilter entfernen" "clearLanguage": "Sprachfilter entfernen"
}, },
"sidebar": { "sidebar": {
@@ -34,6 +33,9 @@
"graph": "Graph", "graph": "Graph",
"admin": "Admin", "admin": "Admin",
"adminHint": "Verwaltung — die Konten in dieser Wiki", "adminHint": "Verwaltung — die Konten in dieser Wiki",
"requests": "Anfragen",
"requestsHint": "Anfragen — genehmige oder lehne ab, was Mitglieder vorschlagen",
"requestsWaiting": "{n} Änderungsanfrage wartet | {n} Änderungsanfragen warten",
"newArticle": "Neuer Artikel", "newArticle": "Neuer Artikel",
"signedInAs": "Angemeldet als {name} — Profil und Konto", "signedInAs": "Angemeldet als {name} — Profil und Konto",
"signInHint": "Anmelden — Lesen ist frei, Schreiben braucht ein Konto", "signInHint": "Anmelden — Lesen ist frei, Schreiben braucht ein Konto",
@@ -61,18 +63,13 @@
"metaDescription": "Die ganze Wiki auf einen Blick — wie viele Artikel es gibt und welche Themen sie behandeln.", "metaDescription": "Die ganze Wiki auf einen Blick — wie viele Artikel es gibt und welche Themen sie behandeln.",
"hero": "Alle <span class=\"gradient\">Artikel</span>", "hero": "Alle <span class=\"gradient\">Artikel</span>",
"sub": "Das ganze Regal auf einen Blick — wie viel geschrieben ist und welche Themen es abdeckt.", "sub": "Das ganze Regal auf einen Blick — wie viel geschrieben ist und welche Themen es abdeckt.",
"countOfTotal": "{shown} von {total} Artikeln", "backToTopics": "Zurück zu den Themen",
"articleCount": "{n} Artikel | {n} Artikel", "articleCount": "{n} Artikel | {n} Artikel",
"topicCount": "{n} Thema | {n} Themen", "topicCount": "{n} Thema | {n} Themen",
"topicSearchPlaceholder": "Themen durchsuchen — versuch „calculus“, „geometry“…",
"topicSearchAria": "Themen durchsuchen",
"noTopic": "Kein Thema passt zu „{q}“.",
"filterByLanguage": "Nach Sprache filtern", "filterByLanguage": "Nach Sprache filtern",
"writtenIn": "Geschrieben auf", "writtenIn": "Geschrieben auf",
"headingTagLang": "Artikel mit dem Stichwort „{tag}“ · auf {lang}", "headingTagLang": "Artikel mit dem Stichwort „{tag}“ · auf {lang}",
"headingTag": "Artikel mit dem Stichwort „{tag}“", "headingTag": "Artikel mit dem Stichwort „{tag}“",
"headingLang": "Artikel auf {lang}",
"headingAll": "Alle Artikel",
"emptyNoTopicTitle": "Keine Artikel zu diesem Thema", "emptyNoTopicTitle": "Keine Artikel zu diesem Thema",
"emptyNoLangTitle": "Keine Artikel auf {lang}", "emptyNoLangTitle": "Keine Artikel auf {lang}",
"emptyNoTopicsTitle": "Noch keine Themen", "emptyNoTopicsTitle": "Noch keine Themen",
@@ -84,7 +81,7 @@
}, },
"admin": { "admin": {
"metaTitle": "Verwaltung", "metaTitle": "Verwaltung",
"metaDescription": "Alle Konten der Wiki — wer hier ist und was jeder darf.", "metaDescription": "Alle Konten der Wiki und die Rolle, die jedes hat.",
"sessionExpired": "Diese Sitzung ist abgelaufen — melde dich erneut an, um das Dashboard zu öffnen.", "sessionExpired": "Diese Sitzung ist abgelaufen — melde dich erneut an, um das Dashboard zu öffnen.",
"roleChangeFailed": "Diese Rolle konnte nicht geändert werden.", "roleChangeFailed": "Diese Rolle konnte nicht geändert werden.",
"deleteFailed": "Dieses Konto konnte nicht gelöscht werden.", "deleteFailed": "Dieses Konto konnte nicht gelöscht werden.",
@@ -94,7 +91,6 @@
"forAdminsText": "Diese Seite listet alle Konten der Wiki und bleibt deshalb gewöhnlichen Mitgliedern vorenthalten. Ein anderer Administrator muss deinem Konto die Rolle geben.", "forAdminsText": "Diese Seite listet alle Konten der Wiki und bleibt deshalb gewöhnlichen Mitgliedern vorenthalten. Ein anderer Administrator muss deinem Konto die Rolle geben.",
"backToArticles": "Zurück zu den Artikeln", "backToArticles": "Zurück zu den Artikeln",
"hero": "Adminis<em>trations</em><span class=\"gradient\">dashboard</span>", "hero": "Adminis<em>trations</em><span class=\"gradient\">dashboard</span>",
"sub": "Alle, die hier ein Konto haben — wer sie sind und was sie dürfen.",
"accountCount": "{n} Konto | {n} Konten", "accountCount": "{n} Konto | {n} Konten",
"adminTally": "{n} Admin | {n} Admins", "adminTally": "{n} Admin | {n} Admins",
"memberTally": "{n} Mitglied | {n} Mitglieder", "memberTally": "{n} Mitglied | {n} Mitglieder",
@@ -108,7 +104,47 @@
"confirmDeleteTitle": "Dieses Konto löschen?", "confirmDeleteTitle": "Dieses Konto löschen?",
"confirmDeleteMessage": "{username} verliert das Konto endgültig und kann sich nicht mehr anmelden. Die geschriebenen Artikel bleiben in der Wiki.", "confirmDeleteMessage": "{username} verliert das Konto endgültig und kann sich nicht mehr anmelden. Die geschriebenen Artikel bleiben in der Wiki.",
"confirmDeleteButton": "Konto löschen", "confirmDeleteButton": "Konto löschen",
"confirmKeepButton": "Behalten" "confirmKeepButton": "Behalten",
"proposalsHeading": "Änderungsanfragen",
"proposalWaitingTally": "{n} wartend | {n} wartend",
"proposalsEmptyTitle": "Keine Änderungsanfragen",
"proposalsEmptyText": "Wenn ein Mitglied einen Artikel ändert oder einen neuen schreibt, kommt er als Vorschlag hier an — einmal angenommen, erscheint er im Wiki.",
"statusPending": "Wartend",
"statusApproved": "Angenommen",
"statusRejected": "Abgelehnt",
"actionApproved": "angenommen",
"actionRejected": "abgelehnt",
"decidedBy": "{action} von {username} · {date}",
"wantsToChange": "wünscht eine Änderung an",
"wantedToChange": "wünschte eine Änderung an",
"wantsToCreate": "möchte einen neuen Artikel anlegen:",
"wantedToCreate": "wollte einen neuen Artikel anlegen:",
"articleGone": "einen Artikel, den es nicht mehr gibt",
"newArticle": "neuer Artikel",
"approve": "Annehmen",
"reject": "Ablehnen",
"approveFailed": "Diese Änderung konnte nicht übernommen werden.",
"rejectFailed": "Diese Anfrage konnte nicht abgelehnt werden.",
"proposalStale": "Der Artikel hat sich nach dem Senden geändert — Annehmen ersetzt den heutigen Inhalt.",
"fieldTitle": "Titel",
"fieldContent": "Text",
"fieldTags": "Tags",
"fieldNone": "Keine sichtbaren Änderungen",
"asSent": "Wie beim Senden",
"asNewPage": "Die angefragte Adresse",
"nothingHereYet": "An dieser Adresse steht noch nichts — die Anfrage bittet darum, die Seite anzulegen.",
"asProposed": "Wie vorgeschlagen",
"details": "Details",
"hideDetails": "Details ausblenden",
"decidedHeading": "{n} beschiedene Anfrage | {n} beschiedene Anfragen"
},
"requests": {
"metaTitle": "Änderungsanfragen",
"metaDescription": "Alle von Mitgliedern vorgeschlagenen Änderungen — einmal angenommen, erscheinen sie im Wiki.",
"hero": "Anfragen <span class=\"gradient\">prüfen</span>",
"gateHeading": "Anmelden zum Prüfen von Anfragen",
"gateText": "Diese Seite enthält die Vorschläge der Mitglieder und bleibt deshalb den Verwaltern vorbehalten. Melde dich mit einem Konto an, das diese Rolle hat.",
"forAdminsText": "Diese Seite enthält die Vorschläge der Mitglieder und bleibt deshalb gewöhnlichen Mitgliedern vorenthalten. Ein anderer Administrator muss deinem Konto die Rolle geben."
}, },
"graph": { "graph": {
"metaTitle": "Graph", "metaTitle": "Graph",
@@ -124,6 +160,7 @@
"notFoundTitle": "Dieser Artikel existiert (noch) nicht", "notFoundTitle": "Dieser Artikel existiert (noch) nicht",
"notFoundText": "Unter <code>{path}</code> ist nichts. Du kannst die erste Version selbst schreiben — es braucht nur Markdown.", "notFoundText": "Unter <code>{path}</code> ist nichts. Du kannst die erste Version selbst schreiben — es braucht nur Markdown.",
"writeTopic": "Dieses Thema schreiben", "writeTopic": "Dieses Thema schreiben",
"backToArticles": "Zurück zu den offenen Artikeln",
"loadFailTitle": "Der Artikel konnte nicht geladen werden", "loadFailTitle": "Der Artikel konnte nicht geladen werden",
"noVersionTitle": "Keine Version auf {lang} (noch nicht)", "noVersionTitle": "Keine Version auf {lang} (noch nicht)",
"noVersionText": "Unter <code>{path}</code> ist nichts geschrieben. Der Artikel existiert vielleicht in einer anderen Sprache — oder du schreibst diese Version selbst." "noVersionText": "Unter <code>{path}</code> ist nichts geschrieben. Der Artikel existiert vielleicht in einer anderen Sprache — oder du schreibst diese Version selbst."
@@ -190,7 +227,13 @@
"sessionExpired": "Diese Sitzung ist abgelaufen — bitte melde dich erneut an.", "sessionExpired": "Diese Sitzung ist abgelaufen — bitte melde dich erneut an.",
"saveFailed": "Der Artikel konnte nicht gespeichert werden. Läuft das Backend?", "saveFailed": "Der Artikel konnte nicht gespeichert werden. Läuft das Backend?",
"leaveGuard": "Du hast ungespeicherte Änderungen. Verlassen ohne zu speichern?", "leaveGuard": "Du hast ungespeicherte Änderungen. Verlassen ohne zu speichern?",
"thisArticle": "diesen Artikel" "thisArticle": "diesen Artikel",
"proposalNotice": "Du bist Mitglied im Wiki: Deine Änderungen gehen als Vorschlag an die Verwaltung — auf die Seite geschrieben wird erst, wenn sie jemand annimmt.",
"proposalNoticeCreate": "Du bist Mitglied im Wiki: Neue Artikel gehen als Vorschlag an die Verwaltung — veröffentlicht wird erst, wenn jemand den Vorschlag annimmt.",
"submitProposal": "Vorschlag senden",
"proposalSentTitle": "Anfrage gesendet",
"proposalSent": "Deine Änderungen wurden zur Überprüfung an die Verwaltung gesendet. Der Artikel behält seinen bisherigen Text, bis jemand den Antrag annimmt.",
"proposalSentCreate": "Dein neuer Artikel wurde zur Überprüfung an die Verwaltung gesendet. Er erscheint im Wiki, sobald jemand den Antrag annimmt."
}, },
"cheatsheet": { "cheatsheet": {
"title": "Schreib-Spickzettel", "title": "Schreib-Spickzettel",
@@ -240,10 +283,7 @@
"accentForest": "Waldgrün", "accentForest": "Waldgrün",
"accentAmber": "Bernstein", "accentAmber": "Bernstein",
"accentRose": "Rose", "accentRose": "Rose",
"accentSlate": "Schiefer", "accentSlate": "Schiefer"
"language": "Sprache",
"languageHint": "Die Sprache, in der Mathew dich anspricht. Beim angemeldeten Konto mit diesem gespeichert.",
"languageAria": "Oberflächensprache"
}, },
"auth": { "auth": {
"signin": "Anmelden", "signin": "Anmelden",
@@ -294,7 +334,10 @@
"newPassword": "Neues Passwort", "newPassword": "Neues Passwort",
"changePassword": "Passwort ändern", "changePassword": "Passwort ändern",
"passwordFailed": "Das Passwort konnte nicht geändert werden.", "passwordFailed": "Das Passwort konnte nicht geändert werden.",
"passwordChanged": "Passwort geändert." "passwordChanged": "Passwort geändert.",
"language": "Sprache",
"languageHint": "Die Sprache, in der Mathew dich anspricht. Mit deinem Konto gespeichert.",
"languageAria": "Oberflächensprache"
}, },
"search": { "search": {
"placeholder": "Artikel nach Titel oder Stichwort durchsuchen…", "placeholder": "Artikel nach Titel oder Stichwort durchsuchen…",

View File

@@ -4,6 +4,7 @@
}, },
"common": { "common": {
"close": "Close", "close": "Close",
"done": "Done",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"delete": "Delete", "delete": "Delete",
@@ -19,9 +20,7 @@
"apiDown": "Could not reach the API. Start the backend with <code>npm run dev</code> in the <code>backend/</code> folder, then retry.", "apiDown": "Could not reach the API. Start the backend with <code>npm run dev</code> in the <code>backend/</code> folder, then retry.",
"apiShort": "Could not reach the API. Start the backend with <code>npm run dev</code> in <code>backend/</code>.", "apiShort": "Could not reach the API. Start the backend with <code>npm run dev</code> in <code>backend/</code>.",
"writtenIn": "Written in {lang}", "writtenIn": "Written in {lang}",
"tagChip": "tag: {tag}",
"languageChip": "language: {lang}", "languageChip": "language: {lang}",
"clearTag": "Clear tag filter",
"clearLanguage": "Clear language filter" "clearLanguage": "Clear language filter"
}, },
"sidebar": { "sidebar": {
@@ -34,6 +33,9 @@
"graph": "Graph", "graph": "Graph",
"admin": "Admin", "admin": "Admin",
"adminHint": "Admin — the accounts on this wiki", "adminHint": "Admin — the accounts on this wiki",
"requests": "Requests",
"requestsHint": "Requests — approve or refuse what members propose",
"requestsWaiting": "{n} edit request waiting | {n} edit requests waiting",
"newArticle": "New article", "newArticle": "New article",
"signedInAs": "Signed in as {name} — profile & account", "signedInAs": "Signed in as {name} — profile & account",
"signInHint": "Sign in — reading is open, writing takes an account", "signInHint": "Sign in — reading is open, writing takes an account",
@@ -61,18 +63,13 @@
"metaDescription": "Every article in the wiki at a glance — how many there are, and the topics they cover.", "metaDescription": "Every article in the wiki at a glance — how many there are, and the topics they cover.",
"hero": "All <span class=\"gradient\">articles</span>", "hero": "All <span class=\"gradient\">articles</span>",
"sub": "The whole shelf at a glance — how much has been written, and the topics it covers.", "sub": "The whole shelf at a glance — how much has been written, and the topics it covers.",
"countOfTotal": "{shown} of {total} articles", "backToTopics": "Back to topics",
"articleCount": "{n} article | {n} articles", "articleCount": "{n} article | {n} articles",
"topicCount": "{n} topic | {n} topics", "topicCount": "{n} topic | {n} topics",
"topicSearchPlaceholder": "Search topics — try “calculus”, “geometry”…",
"topicSearchAria": "Search topics",
"noTopic": "No topic matches “{q}”.",
"filterByLanguage": "Filter by language", "filterByLanguage": "Filter by language",
"writtenIn": "Written in", "writtenIn": "Written in",
"headingTagLang": "Articles tagged “{tag}” · in {lang}", "headingTagLang": "Articles tagged “{tag}” · in {lang}",
"headingTag": "Articles tagged “{tag}”", "headingTag": "Articles tagged “{tag}”",
"headingLang": "Articles in {lang}",
"headingAll": "All articles",
"emptyNoTopicTitle": "No articles with this topic", "emptyNoTopicTitle": "No articles with this topic",
"emptyNoLangTitle": "No articles in {lang}", "emptyNoLangTitle": "No articles in {lang}",
"emptyNoTopicsTitle": "No topics yet", "emptyNoTopicsTitle": "No topics yet",
@@ -84,7 +81,7 @@
}, },
"admin": { "admin": {
"metaTitle": "Admin", "metaTitle": "Admin",
"metaDescription": "Every account on the wiki — who is here, and what each of them may do.", "metaDescription": "Every account on the wiki and the role each one holds.",
"sessionExpired": "That session has expired — sign in again to open the dashboard.", "sessionExpired": "That session has expired — sign in again to open the dashboard.",
"roleChangeFailed": "Could not change that role.", "roleChangeFailed": "Could not change that role.",
"deleteFailed": "Could not delete that account.", "deleteFailed": "Could not delete that account.",
@@ -94,7 +91,6 @@
"forAdminsText": "This page lists every account on the wiki, so it is kept from ordinary members. Another admin has to give your account the role.", "forAdminsText": "This page lists every account on the wiki, so it is kept from ordinary members. Another admin has to give your account the role.",
"backToArticles": "Back to the articles", "backToArticles": "Back to the articles",
"hero": "Admin <span class=\"gradient\">dashboard</span>", "hero": "Admin <span class=\"gradient\">dashboard</span>",
"sub": "Everyone who holds an account here — who they are, and what they may do.",
"accountCount": "{n} account | {n} accounts", "accountCount": "{n} account | {n} accounts",
"adminTally": "{n} admin | {n} admins", "adminTally": "{n} admin | {n} admins",
"memberTally": "{n} member | {n} members", "memberTally": "{n} member | {n} members",
@@ -108,7 +104,47 @@
"confirmDeleteTitle": "Delete this account?", "confirmDeleteTitle": "Delete this account?",
"confirmDeleteMessage": "{username} loses their account for good and can no longer sign in. The articles they wrote stay on the wiki.", "confirmDeleteMessage": "{username} loses their account for good and can no longer sign in. The articles they wrote stay on the wiki.",
"confirmDeleteButton": "Delete account", "confirmDeleteButton": "Delete account",
"confirmKeepButton": "Keep them" "confirmKeepButton": "Keep them",
"proposalsHeading": "Edit requests",
"proposalWaitingTally": "{n} waiting | {n} waiting",
"proposalsEmptyTitle": "No edit requests",
"proposalsEmptyText": "When a member changes an article or writes a new one, it arrives here as a proposal — approve one and it goes live on the wiki.",
"statusPending": "Waiting",
"statusApproved": "Approved",
"statusRejected": "Rejected",
"actionApproved": "approved",
"actionRejected": "rejected",
"decidedBy": "{action} by {username} · {date}",
"wantsToChange": "wants to change",
"wantedToChange": "wanted to change",
"wantsToCreate": "wants to create",
"wantedToCreate": "wanted to create",
"articleGone": "an article that no longer exists",
"newArticle": "new article",
"approve": "Approve",
"reject": "Reject",
"approveFailed": "Could not apply that change.",
"rejectFailed": "Could not reject that request.",
"proposalStale": "The article changed after this was sent — approving replaces what it says now.",
"fieldTitle": "Title",
"fieldContent": "Text",
"fieldTags": "Tags",
"fieldNone": "No visible changes",
"asSent": "As it was when sent",
"asNewPage": "The address it asks for",
"nothingHereYet": "Nothing is written at this address yet — the request asks for the page to be created.",
"asProposed": "As proposed",
"details": "Details",
"hideDetails": "Hide details",
"decidedHeading": "{n} decided request | {n} decided requests"
},
"requests": {
"metaTitle": "Edit requests",
"metaDescription": "Every change members have proposed — approve one and it goes live on the wiki.",
"hero": "Review <span class=\"gradient\">requests</span>",
"gateHeading": "Sign in to review requests",
"gateText": "This page holds the changes members propose, so it is kept for the people who administer the wiki. Sign in with an account that holds that role.",
"forAdminsText": "This page holds the changes members propose, so it is kept from ordinary members. Another admin has to give your account the role."
}, },
"graph": { "graph": {
"metaTitle": "Graph", "metaTitle": "Graph",
@@ -124,6 +160,7 @@
"notFoundTitle": "This article does not exist (yet)", "notFoundTitle": "This article does not exist (yet)",
"notFoundText": "Nothing lives at <code>{path}</code>. You can write the first version yourself — it only takes Markdown.", "notFoundText": "Nothing lives at <code>{path}</code>. You can write the first version yourself — it only takes Markdown.",
"writeTopic": "Write this topic", "writeTopic": "Write this topic",
"backToArticles": "Back to the open articles",
"loadFailTitle": "Could not load the article", "loadFailTitle": "Could not load the article",
"noVersionTitle": "No version in {lang} (yet)", "noVersionTitle": "No version in {lang} (yet)",
"noVersionText": "Nothing is written at <code>{path}</code>. The article may exist in another language — or you can write this version yourself." "noVersionText": "Nothing is written at <code>{path}</code>. The article may exist in another language — or you can write this version yourself."
@@ -190,7 +227,13 @@
"sessionExpired": "That session has expired — please sign in again.", "sessionExpired": "That session has expired — please sign in again.",
"saveFailed": "Could not save the article. Is the backend running?", "saveFailed": "Could not save the article. Is the backend running?",
"leaveGuard": "You have unsaved changes. Leave without saving?", "leaveGuard": "You have unsaved changes. Leave without saving?",
"thisArticle": "this article" "thisArticle": "this article",
"proposalNotice": "You are a member of the wiki: your changes go to the admins as a proposal — nothing is written onto the page until one of them approves it.",
"proposalNoticeCreate": "You are a member of the wiki: new articles go to the admins as a proposal — nothing is published until one of them approves it.",
"submitProposal": "Send proposal",
"proposalSentTitle": "Request sent",
"proposalSent": "Your changes were sent to the admins for review. The article keeps its current text until one of them approves the request.",
"proposalSentCreate": "Your new article was sent to the admins for review. It appears on the wiki once one of them approves the request."
}, },
"cheatsheet": { "cheatsheet": {
"title": "Writing cheatsheet", "title": "Writing cheatsheet",
@@ -240,10 +283,7 @@
"accentForest": "Forest", "accentForest": "Forest",
"accentAmber": "Amber", "accentAmber": "Amber",
"accentRose": "Rose", "accentRose": "Rose",
"accentSlate": "Slate", "accentSlate": "Slate"
"language": "Language",
"languageHint": "The language Mathew speaks to you. Saved with your account when you are signed in.",
"languageAria": "Interface language"
}, },
"auth": { "auth": {
"signin": "Sign in", "signin": "Sign in",
@@ -294,7 +334,10 @@
"newPassword": "New password", "newPassword": "New password",
"changePassword": "Change password", "changePassword": "Change password",
"passwordFailed": "Could not change the password.", "passwordFailed": "Could not change the password.",
"passwordChanged": "Password changed." "passwordChanged": "Password changed.",
"language": "Language",
"languageHint": "The language Mathew speaks to you. Saved with your account.",
"languageAria": "Interface language"
}, },
"search": { "search": {
"placeholder": "Search articles by title or tag…", "placeholder": "Search articles by title or tag…",

View File

@@ -4,6 +4,7 @@
}, },
"common": { "common": {
"close": "Cerrar", "close": "Cerrar",
"done": "Listo",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar", "confirm": "Confirmar",
"delete": "Eliminar", "delete": "Eliminar",
@@ -19,9 +20,7 @@
"apiDown": "No se pudo conectar con la API. Inicia el backend con <code>npm run dev</code> en la carpeta <code>backend/</code> y vuelve a intentarlo.", "apiDown": "No se pudo conectar con la API. Inicia el backend con <code>npm run dev</code> en la carpeta <code>backend/</code> y vuelve a intentarlo.",
"apiShort": "No se pudo conectar con la API. Inicia el backend con <code>npm run dev</code> en <code>backend/</code>.", "apiShort": "No se pudo conectar con la API. Inicia el backend con <code>npm run dev</code> en <code>backend/</code>.",
"writtenIn": "Escrito en {lang}", "writtenIn": "Escrito en {lang}",
"tagChip": "etiqueta: {tag}",
"languageChip": "idioma: {lang}", "languageChip": "idioma: {lang}",
"clearTag": "Quitar el filtro de etiqueta",
"clearLanguage": "Quitar el filtro de idioma" "clearLanguage": "Quitar el filtro de idioma"
}, },
"sidebar": { "sidebar": {
@@ -34,6 +33,9 @@
"graph": "Grafo", "graph": "Grafo",
"admin": "Admin", "admin": "Admin",
"adminHint": "Administración — las cuentas de esta wiki", "adminHint": "Administración — las cuentas de esta wiki",
"requests": "Solicitudes",
"requestsHint": "Solicitudes — aprueba o rechaza lo que proponen los miembros",
"requestsWaiting": "{n} solicitud de edición pendiente | {n} solicitudes de edición pendientes",
"newArticle": "Artículo nuevo", "newArticle": "Artículo nuevo",
"signedInAs": "Sesión de {name} — perfil y cuenta", "signedInAs": "Sesión de {name} — perfil y cuenta",
"signInHint": "Iniciar sesión — leer es libre, escribir necesita una cuenta", "signInHint": "Iniciar sesión — leer es libre, escribir necesita una cuenta",
@@ -61,18 +63,13 @@
"metaDescription": "Toda la wiki de un vistazo — cuántos artículos hay y qué temas abarcan.", "metaDescription": "Toda la wiki de un vistazo — cuántos artículos hay y qué temas abarcan.",
"hero": "Todos los <span class=\"gradient\">artículos</span>", "hero": "Todos los <span class=\"gradient\">artículos</span>",
"sub": "Toda la estantería de un vistazo — cuánto se ha escrito y qué temas cubre.", "sub": "Toda la estantería de un vistazo — cuánto se ha escrito y qué temas cubre.",
"countOfTotal": "{shown} de {total} artículos", "backToTopics": "Volver a los temas",
"articleCount": "{n} artículo | {n} artículos", "articleCount": "{n} artículo | {n} artículos",
"topicCount": "{n} tema | {n} temas", "topicCount": "{n} tema | {n} temas",
"topicSearchPlaceholder": "Busca temas — prueba «calculus», «geometry»…",
"topicSearchAria": "Buscar temas",
"noTopic": "Ningún tema coincide con «{q}».",
"filterByLanguage": "Filtrar por idioma", "filterByLanguage": "Filtrar por idioma",
"writtenIn": "Escritos en", "writtenIn": "Escritos en",
"headingTagLang": "Artículos con la etiqueta «{tag}» · en {lang}", "headingTagLang": "Artículos con la etiqueta «{tag}» · en {lang}",
"headingTag": "Artículos con la etiqueta «{tag}»", "headingTag": "Artículos con la etiqueta «{tag}»",
"headingLang": "Artículos en {lang}",
"headingAll": "Todos los artículos",
"emptyNoTopicTitle": "No hay artículos con este tema", "emptyNoTopicTitle": "No hay artículos con este tema",
"emptyNoLangTitle": "No hay artículos en {lang}", "emptyNoLangTitle": "No hay artículos en {lang}",
"emptyNoTopicsTitle": "Aún no hay temas", "emptyNoTopicsTitle": "Aún no hay temas",
@@ -84,7 +81,7 @@
}, },
"admin": { "admin": {
"metaTitle": "Administración", "metaTitle": "Administración",
"metaDescription": "Todas las cuentas de la wiki — quién está aquí y qué puede hacer cada una.", "metaDescription": "Todas las cuentas de la wiki y el rol que cada una tiene.",
"sessionExpired": "La sesión ha caducado — inicia sesión otra vez para abrir el panel.", "sessionExpired": "La sesión ha caducado — inicia sesión otra vez para abrir el panel.",
"roleChangeFailed": "No se pudo cambiar ese rol.", "roleChangeFailed": "No se pudo cambiar ese rol.",
"deleteFailed": "No se pudo eliminar esa cuenta.", "deleteFailed": "No se pudo eliminar esa cuenta.",
@@ -94,7 +91,6 @@
"forAdminsText": "Esta página enumera todas las cuentas de la wiki, por eso se mantiene fuera del alcance de los miembros ordinarios. Otro administrador debe dar ese rol a tu cuenta.", "forAdminsText": "Esta página enumera todas las cuentas de la wiki, por eso se mantiene fuera del alcance de los miembros ordinarios. Otro administrador debe dar ese rol a tu cuenta.",
"backToArticles": "Volver a los artículos", "backToArticles": "Volver a los artículos",
"hero": "Panel de <span class=\"gradient\">administración</span>", "hero": "Panel de <span class=\"gradient\">administración</span>",
"sub": "Todos los que tienen cuenta aquí — quiénes son y qué pueden hacer.",
"accountCount": "{n} cuenta | {n} cuentas", "accountCount": "{n} cuenta | {n} cuentas",
"adminTally": "{n} admin | {n} admins", "adminTally": "{n} admin | {n} admins",
"memberTally": "{n} miembro | {n} miembros", "memberTally": "{n} miembro | {n} miembros",
@@ -108,7 +104,47 @@
"confirmDeleteTitle": "¿Eliminar esta cuenta?", "confirmDeleteTitle": "¿Eliminar esta cuenta?",
"confirmDeleteMessage": "{username} pierde su cuenta definitivamente y ya no podrá iniciar sesión. Los artículos que escribió siguen en la wiki.", "confirmDeleteMessage": "{username} pierde su cuenta definitivamente y ya no podrá iniciar sesión. Los artículos que escribió siguen en la wiki.",
"confirmDeleteButton": "Eliminar cuenta", "confirmDeleteButton": "Eliminar cuenta",
"confirmKeepButton": "Conservarla" "confirmKeepButton": "Conservarla",
"proposalsHeading": "Solicitudes de edición",
"proposalWaitingTally": "{n} pendiente | {n} pendientes",
"proposalsEmptyTitle": "Sin solicitudes de edición",
"proposalsEmptyText": "Cuando un miembro cambia un artículo o escribe uno nuevo, llega aquí como una propuesta: aprueba una y se publicará en la wiki.",
"statusPending": "Pendiente",
"statusApproved": "Aprobada",
"statusRejected": "Rechazada",
"actionApproved": "aprobada",
"actionRejected": "rechazada",
"decidedBy": "{action} por {username} · {date}",
"wantsToChange": "quiere cambiar",
"wantedToChange": "quiso cambiar",
"wantsToCreate": "quiere crear",
"wantedToCreate": "quiso crear",
"articleGone": "un artículo que ya no existe",
"newArticle": "artículo nuevo",
"approve": "Aprobar",
"reject": "Rechazar",
"approveFailed": "No se pudo aplicar ese cambio.",
"rejectFailed": "No se pudo rechazar esa solicitud.",
"proposalStale": "El artículo cambió después de enviar esto: aprobar reemplaza lo que dice ahora.",
"fieldTitle": "Título",
"fieldContent": "Texto",
"fieldTags": "Etiquetas",
"fieldNone": "Sin cambios visibles",
"asSent": "Como estaba al enviarlo",
"asNewPage": "La dirección que solicita",
"nothingHereYet": "Todavía no hay nada escrito en esta dirección: la solicitud pide que se cree la página.",
"asProposed": "Como se propone",
"details": "Detalles",
"hideDetails": "Ocultar detalles",
"decidedHeading": "{n} solicitud decidida | {n} solicitudes decididas"
},
"requests": {
"metaTitle": "Solicitudes de edición",
"metaDescription": "Todos los cambios que han propuesto los miembros: aprueba uno y se publica en la wiki.",
"hero": "Revisión de <span class=\"gradient\">solicitudes</span>",
"gateHeading": "Inicia sesión para revisar solicitudes",
"gateText": "Esta página guarda los cambios que proponen los miembros, así que está reservada a quienes administran la wiki. Inicia sesión con una cuenta que tenga ese rol.",
"forAdminsText": "Esta página guarda los cambios que proponen los miembros, por eso se mantiene fuera del alcance de los miembros ordinarios. Otro administrador debe dar ese rol a tu cuenta."
}, },
"graph": { "graph": {
"metaTitle": "Grafo", "metaTitle": "Grafo",
@@ -124,6 +160,7 @@
"notFoundTitle": "Este artículo no existe (todavía)", "notFoundTitle": "Este artículo no existe (todavía)",
"notFoundText": "No hay nada en <code>{path}</code>. Puedes escribir tú la primera versión — solo hace falta Markdown.", "notFoundText": "No hay nada en <code>{path}</code>. Puedes escribir tú la primera versión — solo hace falta Markdown.",
"writeTopic": "Escribir este tema", "writeTopic": "Escribir este tema",
"backToArticles": "Volver a los artículos abiertos",
"loadFailTitle": "No se pudo cargar el artículo", "loadFailTitle": "No se pudo cargar el artículo",
"noVersionTitle": "No hay versión en {lang} (todavía)", "noVersionTitle": "No hay versión en {lang} (todavía)",
"noVersionText": "No hay nada escrito en <code>{path}</code>. El artículo puede existir en otro idioma — o puedes escribir tú esta versión." "noVersionText": "No hay nada escrito en <code>{path}</code>. El artículo puede existir en otro idioma — o puedes escribir tú esta versión."
@@ -190,7 +227,13 @@
"sessionExpired": "La sesión ha caducado — inicia sesión otra vez.", "sessionExpired": "La sesión ha caducado — inicia sesión otra vez.",
"saveFailed": "No se pudo guardar el artículo. ¿Está el backend en marcha?", "saveFailed": "No se pudo guardar el artículo. ¿Está el backend en marcha?",
"leaveGuard": "Tienes cambios sin guardar. ¿Salir sin guardar?", "leaveGuard": "Tienes cambios sin guardar. ¿Salir sin guardar?",
"thisArticle": "este artículo" "thisArticle": "este artículo",
"proposalNotice": "Eres miembro de la wiki: tus cambios se envían a la administración como una propuesta; nada se escribe en la página hasta que alguien la aprueba.",
"proposalNoticeCreate": "Eres miembro de la wiki: los artículos nuevos se envían a la administración como una propuesta; no se publica nada hasta que alguien la aprueba.",
"submitProposal": "Enviar propuesta",
"proposalSentTitle": "Solicitud enviada",
"proposalSent": "Tus cambios se han enviado a la administración para su revisión. El artículo mantiene su texto actual hasta que alguien apruebe la solicitud.",
"proposalSentCreate": "Tu artículo nuevo se ha enviado a la administración para su revisión. Aparecerá en la wiki cuando alguien apruebe la solicitud."
}, },
"cheatsheet": { "cheatsheet": {
"title": "Chuleta de escritura", "title": "Chuleta de escritura",
@@ -240,10 +283,7 @@
"accentForest": "bosque", "accentForest": "bosque",
"accentAmber": "ámbar", "accentAmber": "ámbar",
"accentRose": "rosa", "accentRose": "rosa",
"accentSlate": "pizarra", "accentSlate": "pizarra"
"language": "Idioma",
"languageHint": "El idioma en que Mathew te habla. Se guarda en tu cuenta si has iniciado sesión.",
"languageAria": "Idioma de la interfaz"
}, },
"auth": { "auth": {
"signin": "Iniciar sesión", "signin": "Iniciar sesión",
@@ -294,7 +334,10 @@
"newPassword": "Contraseña nueva", "newPassword": "Contraseña nueva",
"changePassword": "Cambiar la contraseña", "changePassword": "Cambiar la contraseña",
"passwordFailed": "No se pudo cambiar la contraseña.", "passwordFailed": "No se pudo cambiar la contraseña.",
"passwordChanged": "Contraseña cambiada." "passwordChanged": "Contraseña cambiada.",
"language": "Idioma",
"languageHint": "El idioma en que Mathew te habla. Se guarda en tu cuenta.",
"languageAria": "Idioma de la interfaz"
}, },
"search": { "search": {
"placeholder": "Busca artículos por título o etiqueta…", "placeholder": "Busca artículos por título o etiqueta…",

View File

@@ -4,6 +4,7 @@
}, },
"common": { "common": {
"close": "Fermer", "close": "Fermer",
"done": "Terminé",
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Confirmer", "confirm": "Confirmer",
"delete": "Supprimer", "delete": "Supprimer",
@@ -19,9 +20,7 @@
"apiDown": "Impossible de joindre l'API. Démarrez le backend avec <code>npm run dev</code> dans le dossier <code>backend/</code>, puis réessayez.", "apiDown": "Impossible de joindre l'API. Démarrez le backend avec <code>npm run dev</code> dans le dossier <code>backend/</code>, puis réessayez.",
"apiShort": "Impossible de joindre l'API. Démarrez le backend avec <code>npm run dev</code> dans <code>backend/</code>.", "apiShort": "Impossible de joindre l'API. Démarrez le backend avec <code>npm run dev</code> dans <code>backend/</code>.",
"writtenIn": "Écrit en {lang}", "writtenIn": "Écrit en {lang}",
"tagChip": "étiquette : {tag}",
"languageChip": "langue : {lang}", "languageChip": "langue : {lang}",
"clearTag": "Retirer le filtre d'étiquette",
"clearLanguage": "Retirer le filtre de langue" "clearLanguage": "Retirer le filtre de langue"
}, },
"sidebar": { "sidebar": {
@@ -34,6 +33,9 @@
"graph": "Graphe", "graph": "Graphe",
"admin": "Admin", "admin": "Admin",
"adminHint": "Administration — les comptes de ce wiki", "adminHint": "Administration — les comptes de ce wiki",
"requests": "Demandes",
"requestsHint": "Demandes — approuvez ou refusez ce que proposent les membres",
"requestsWaiting": "{n} demande de modification en attente | {n} demandes de modification en attente",
"newArticle": "Nouvel article", "newArticle": "Nouvel article",
"signedInAs": "Connecté en tant que {name} — profil et compte", "signedInAs": "Connecté en tant que {name} — profil et compte",
"signInHint": "Se connecter — la lecture est libre, l'écriture demande un compte", "signInHint": "Se connecter — la lecture est libre, l'écriture demande un compte",
@@ -61,18 +63,13 @@
"metaDescription": "Tout le wiki d'un coup d'œil — combien d'articles il compte et les thèmes qu'ils couvrent.", "metaDescription": "Tout le wiki d'un coup d'œil — combien d'articles il compte et les thèmes qu'ils couvrent.",
"hero": "Tous les <span class=\"gradient\">articles</span>", "hero": "Tous les <span class=\"gradient\">articles</span>",
"sub": "Toute la bibliothèque d'un coup d'œil — ce qui a été écrit, et les thèmes couverts.", "sub": "Toute la bibliothèque d'un coup d'œil — ce qui a été écrit, et les thèmes couverts.",
"countOfTotal": "{shown} sur {total} articles", "backToTopics": "Retour aux thèmes",
"articleCount": "{n} article | {n} articles", "articleCount": "{n} article | {n} articles",
"topicCount": "{n} thème | {n} thèmes", "topicCount": "{n} thème | {n} thèmes",
"topicSearchPlaceholder": "Cherchez des thèmes — essayez « calculus », « geometry »…",
"topicSearchAria": "Rechercher des thèmes",
"noTopic": "Aucun thème ne correspond à « {q} ».",
"filterByLanguage": "Filtrer par langue", "filterByLanguage": "Filtrer par langue",
"writtenIn": "Écrits en", "writtenIn": "Écrits en",
"headingTagLang": "Articles étiquetés « {tag} » · en {lang}", "headingTagLang": "Articles étiquetés « {tag} » · en {lang}",
"headingTag": "Articles étiquetés « {tag} »", "headingTag": "Articles étiquetés « {tag} »",
"headingLang": "Articles en {lang}",
"headingAll": "Tous les articles",
"emptyNoTopicTitle": "Aucun article avec ce thème", "emptyNoTopicTitle": "Aucun article avec ce thème",
"emptyNoLangTitle": "Aucun article en {lang}", "emptyNoLangTitle": "Aucun article en {lang}",
"emptyNoTopicsTitle": "Pas encore de thèmes", "emptyNoTopicsTitle": "Pas encore de thèmes",
@@ -84,7 +81,7 @@
}, },
"admin": { "admin": {
"metaTitle": "Administration", "metaTitle": "Administration",
"metaDescription": "Tous les comptes du wiki — qui est là, et ce que chacun peut faire.", "metaDescription": "Tous les comptes du wiki et le rôle de chacun.",
"sessionExpired": "Cette session a expiré — reconnectez-vous pour ouvrir le tableau de bord.", "sessionExpired": "Cette session a expiré — reconnectez-vous pour ouvrir le tableau de bord.",
"roleChangeFailed": "Impossible de changer ce rôle.", "roleChangeFailed": "Impossible de changer ce rôle.",
"deleteFailed": "Impossible de supprimer ce compte.", "deleteFailed": "Impossible de supprimer ce compte.",
@@ -94,7 +91,6 @@
"forAdminsText": "Cette page liste tous les comptes du wiki ; elle est donc tenue à l'écart des membres ordinaires. Un autre administrateur doit donner ce rôle à votre compte.", "forAdminsText": "Cette page liste tous les comptes du wiki ; elle est donc tenue à l'écart des membres ordinaires. Un autre administrateur doit donner ce rôle à votre compte.",
"backToArticles": "Retour aux articles", "backToArticles": "Retour aux articles",
"hero": "Tableau d'<span class=\"gradient\">administration</span>", "hero": "Tableau d'<span class=\"gradient\">administration</span>",
"sub": "Tous ceux qui ont un compte ici — qui ils sont, et ce qu'ils peuvent faire.",
"accountCount": "{n} compte | {n} comptes", "accountCount": "{n} compte | {n} comptes",
"adminTally": "{n} admin | {n} admins", "adminTally": "{n} admin | {n} admins",
"memberTally": "{n} membre | {n} membres", "memberTally": "{n} membre | {n} membres",
@@ -108,7 +104,47 @@
"confirmDeleteTitle": "Supprimer ce compte ?", "confirmDeleteTitle": "Supprimer ce compte ?",
"confirmDeleteMessage": "{username} perd définitivement son compte et ne pourra plus se connecter. Les articles qu'il a écrits restent sur le wiki.", "confirmDeleteMessage": "{username} perd définitivement son compte et ne pourra plus se connecter. Les articles qu'il a écrits restent sur le wiki.",
"confirmDeleteButton": "Supprimer le compte", "confirmDeleteButton": "Supprimer le compte",
"confirmKeepButton": "Le conserver" "confirmKeepButton": "Le conserver",
"proposalsHeading": "Demandes de modification",
"proposalWaitingTally": "{n} en attente | {n} en attente",
"proposalsEmptyTitle": "Aucune demande de modification",
"proposalsEmptyText": "Quand un membre modifie un article ou en écrit un nouveau, il arrive ici comme une proposition — approuvez-le et il sera publié sur le wiki.",
"statusPending": "En attente",
"statusApproved": "Approuvée",
"statusRejected": "Refusée",
"actionApproved": "approuvée",
"actionRejected": "refusée",
"decidedBy": "{action} par {username} · {date}",
"wantsToChange": "souhaite modifier",
"wantedToChange": "a souhaité modifier",
"wantsToCreate": "souhaite créer",
"wantedToCreate": "a souhaité créer",
"articleGone": "un article qui n'existe plus",
"newArticle": "nouvel article",
"approve": "Approuver",
"reject": "Refuser",
"approveFailed": "Impossible d'appliquer cette modification.",
"rejectFailed": "Impossible de refuser cette demande.",
"proposalStale": "L'article a changé après l'envoi — approuver remplacerait son contenu actuel.",
"fieldTitle": "Titre",
"fieldContent": "Texte",
"fieldTags": "Étiquettes",
"fieldNone": "Aucun changement visible",
"asSent": "Tel qu'au moment de l'envoi",
"asNewPage": "L'adresse demandée",
"nothingHereYet": "Rien n'est écrit à cette adresse pour l'instant — la demande souhaite que cette page soit créée.",
"asProposed": "Tel que proposé",
"details": "Détails",
"hideDetails": "Masquer les détails",
"decidedHeading": "{n} demande traitée | {n} demandes traitées"
},
"requests": {
"metaTitle": "Demandes de modification",
"metaDescription": "Toutes les modifications proposées par les membres — approuvez-en une et elle paraît sur le wiki.",
"hero": "Revue des <span class=\"gradient\">demandes</span>",
"gateHeading": "Connectez-vous pour examiner les demandes",
"gateText": "Cette page rassemble les modifications proposées par les membres ; elle est donc réservée à ceux qui administrent le wiki. Connectez-vous avec un compte titulaire de ce rôle.",
"forAdminsText": "Cette page rassemble les modifications proposées par les membres ; elle est donc tenue à l'écart des membres ordinaires. Un autre administrateur doit donner ce rôle à votre compte."
}, },
"graph": { "graph": {
"metaTitle": "Graphe", "metaTitle": "Graphe",
@@ -124,6 +160,7 @@
"notFoundTitle": "Cet article n'existe pas (encore)", "notFoundTitle": "Cet article n'existe pas (encore)",
"notFoundText": "Rien ne vit à <code>{path}</code>. Vous pouvez en écrire vous-même la première version — il ne faut que du Markdown.", "notFoundText": "Rien ne vit à <code>{path}</code>. Vous pouvez en écrire vous-même la première version — il ne faut que du Markdown.",
"writeTopic": "Écrire ce thème", "writeTopic": "Écrire ce thème",
"backToArticles": "Retour aux articles ouverts",
"loadFailTitle": "Impossible de charger l'article", "loadFailTitle": "Impossible de charger l'article",
"noVersionTitle": "Pas de version en {lang} (pour l'instant)", "noVersionTitle": "Pas de version en {lang} (pour l'instant)",
"noVersionText": "Rien n'est écrit à <code>{path}</code>. L'article existe peut-être dans une autre langue — ou vous pouvez écrire cette version vous-même." "noVersionText": "Rien n'est écrit à <code>{path}</code>. L'article existe peut-être dans une autre langue — ou vous pouvez écrire cette version vous-même."
@@ -190,7 +227,13 @@
"sessionExpired": "Cette session a expiré — reconnectez-vous, s'il vous plaît.", "sessionExpired": "Cette session a expiré — reconnectez-vous, s'il vous plaît.",
"saveFailed": "Impossible d'enregistrer l'article. Le backend est-il en marche ?", "saveFailed": "Impossible d'enregistrer l'article. Le backend est-il en marche ?",
"leaveGuard": "Vous avez des modifications non enregistrées. Quitter sans enregistrer ?", "leaveGuard": "Vous avez des modifications non enregistrées. Quitter sans enregistrer ?",
"thisArticle": "cet article" "thisArticle": "cet article",
"proposalNotice": "Vous êtes membre du wiki : vos modifications partent vers les administrateurs comme une proposition — rien n'est écrit sur la page tant qu'ils ne l'ont pas approuvée.",
"proposalNoticeCreate": "Vous êtes membre du wiki : les nouveaux articles partent vers les administrateurs comme une proposition — rien n'est publié tant qu'ils ne l'ont pas approuvée.",
"submitProposal": "Envoyer la proposition",
"proposalSentTitle": "Demande envoyée",
"proposalSent": "Vos modifications ont été envoyées aux administrateurs pour revue. L'article garde son texte actuel jusqu'à ce que l'un d'entre eux approuve la demande.",
"proposalSentCreate": "Votre nouvel article a été envoyé aux administrateurs pour revue. Il apparaîtra sur le wiki lorsque l'un d'entre eux approuvera la demande."
}, },
"cheatsheet": { "cheatsheet": {
"title": "Aide-mémoire d'écriture", "title": "Aide-mémoire d'écriture",
@@ -240,10 +283,7 @@
"accentForest": "forêt", "accentForest": "forêt",
"accentAmber": "ambre", "accentAmber": "ambre",
"accentRose": "rose", "accentRose": "rose",
"accentSlate": "ardoise", "accentSlate": "ardoise"
"language": "Langue",
"languageHint": "La langue dans laquelle Mathew vous parle. Enregistrée avec votre compte quand vous êtes connecté.",
"languageAria": "Langue de l'interface"
}, },
"auth": { "auth": {
"signin": "Se connecter", "signin": "Se connecter",
@@ -294,7 +334,10 @@
"newPassword": "Nouveau mot de passe", "newPassword": "Nouveau mot de passe",
"changePassword": "Changer le mot de passe", "changePassword": "Changer le mot de passe",
"passwordFailed": "Impossible de changer le mot de passe.", "passwordFailed": "Impossible de changer le mot de passe.",
"passwordChanged": "Mot de passe changé." "passwordChanged": "Mot de passe changé.",
"language": "Langue",
"languageHint": "La langue dans laquelle Mathew vous parle. Enregistrée avec votre compte.",
"languageAria": "Langue de l'interface"
}, },
"search": { "search": {
"placeholder": "Recherchez des articles par titre ou étiquette…", "placeholder": "Recherchez des articles par titre ou étiquette…",