More things
This commit is contained in:
188
AGENTS.md
188
AGENTS.md
@@ -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
|
||||
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/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`
|
||||
via bcrypt, `toPublic()` for responses, and `credentialProblem()` as the single
|
||||
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'`
|
||||
(`ROLES`); `User.hasNone()` and `User.ensureAnAdmin()` cover who gets the
|
||||
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/auth.js` — `POST /api/auth/register` (creates the account and returns
|
||||
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`
|
||||
(needs the current one), `PUT /api/auth/avatar` (data URL, `""` clears),
|
||||
`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
|
||||
`requireAuth, requireAdmin`: `GET /api/admin/users` (every account, newest
|
||||
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
|
||||
`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).
|
||||
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
|
||||
`Authorization: Bearer <token>` and sets `req.user = { id, username }`; anything
|
||||
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`).
|
||||
`slugify()` / `uniqueSlug()` (in `routes/articles.js`) generate uniquified slugs
|
||||
(`-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
|
||||
`?q=` (regex search on title/summary/tags) and `?tag=`.
|
||||
- The list endpoint (`GET /api/articles`) selects `-content` (no body) and answers
|
||||
`{ 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
|
||||
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/`)
|
||||
|
||||
@@ -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`).
|
||||
|
||||
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
|
||||
(`/wiki/<lang>/<slug>` is the *article's* language) — with cookie detection
|
||||
(`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
|
||||
copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
|
||||
|
||||
- `app/pages/` — `/` (search), `/wiki/[slug]` (article + TOC), `/wiki/[slug]/edit`, `/new`,
|
||||
`/admin` (the dashboard: every account and its role, plus the buttons that hand the
|
||||
role on or take it back — an admin alone sees anything but a refusal here).
|
||||
- `app/pages/` — `/` (search), `/wiki/[lang]/[slug]` (one language version of an
|
||||
article — the address every version has, English included — drawn as a page of
|
||||
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.
|
||||
- `app/components/` — PascalCase SFCs (header, editor, MarkdownView, TocNav, etc.).
|
||||
`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
|
||||
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
|
||||
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,
|
||||
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,
|
||||
`AccountActionsMenu.vue` on a row of the dashboard (the role and the account).
|
||||
- `app/composables/` — `useTopics` (topics = distinct tags computed client-side from the
|
||||
full article list, shared via `useState`), `useRecentlyViewed` (localStorage),
|
||||
`.kebab*` styles: `ArticleActionsMenu.vue` on an article's title row (its Delete
|
||||
item is drawn for admins alone), `AccountActionsMenu.vue` on a row of the
|
||||
dashboard (the role and the account). `ProposalRow.vue` is the edit-request desk's row:
|
||||
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
|
||||
too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()`
|
||||
asking `/auth/me` who a stored token belongs to, plus `updateUsername`/
|
||||
`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
|
||||
dashboard are drawn from), `useSignInPopup` (open state of
|
||||
account holds the admin role, which is what the sidebar's two admin links (Admin
|
||||
and Requests) and the two admin pages are drawn from), `useSignInPopup` (open state of
|
||||
that popup, shaped like `useSettings`/`useSearch`), `useProfileMenu` (same, for the
|
||||
profile popup), `useArticleActionsMenu` (same, for an article's ⋮ menu), and
|
||||
`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
|
||||
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 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).
|
||||
@@ -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
|
||||
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
|
||||
on the server pre-render) > browser > `en`. The settings popup always turns this device;
|
||||
with a session it also saves to the account — an account that refuses (dead session)
|
||||
doesn't undo the switch. Error messages that come back from the API stay English; only
|
||||
the frontend's own fallbacks are translated.
|
||||
on the server pre-render) > browser > `en`. The profile popup always turns this
|
||||
device; with a session it also saves to the account — an account that refuses
|
||||
(dead session) doesn't undo the switch. Error messages that come back from the API
|
||||
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
|
||||
changes. Use `props.x ?? t(...)` in the template/computed instead (see `AuthGate`,
|
||||
`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
|
||||
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`
|
||||
@@ -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
|
||||
menu deletes an account — through `ConfirmModal`, and never the admin's own, for
|
||||
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
|
||||
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
|
||||
out and opens the sign-in popup over it.
|
||||
- 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/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package.
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"scripts": {
|
||||
"start": "node 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",
|
||||
"dependencies": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import itemsRouter from "./routes/items.js";
|
||||
import articlesRouter from "./routes/articles.js";
|
||||
import authRouter from "./routes/auth.js";
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import Article from "./models/Article.js";
|
||||
import User from "./models/User.js";
|
||||
|
||||
const app = express();
|
||||
@@ -51,6 +52,10 @@ const port = process.env.PORT || 5000;
|
||||
|
||||
try {
|
||||
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
|
||||
// somebody can always open the dashboard and pass the role to anyone else.
|
||||
const promoted = await User.ensureAnAdmin();
|
||||
|
||||
@@ -44,8 +44,37 @@ const articleSchema = new mongoose.Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
// Text index for search across title and tags
|
||||
articleSchema.index({ title: "text", tags: "text" });
|
||||
/* Text index for search across title and tags.
|
||||
`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);
|
||||
|
||||
|
||||
87
backend/src/models/EditProposal.js
Normal file
87
backend/src/models/EditProposal.js
Normal 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;
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Router } from "express";
|
||||
import Article from "../models/Article.js";
|
||||
import EditProposal from "../models/EditProposal.js";
|
||||
import User, { ROLES } from "../models/User.js";
|
||||
import { requireAdmin, requireAuth } from "../middleware/auth.js";
|
||||
import { applyArticleEdit, createArticle } from "./articles.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/* The back office of the wiki: who holds an account, and what each of them may
|
||||
do. Everything here takes an admin — `requireAdmin` reads the role off the
|
||||
/* The back office of the wiki: who holds an account, what each of them may
|
||||
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. */
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Router } from "express";
|
||||
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();
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
export function slugify(str) {
|
||||
@@ -38,6 +42,82 @@ function languageCode(value) {
|
||||
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 canonical (original) version's slug. Translations carry that slug as
|
||||
// `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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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) => {
|
||||
try {
|
||||
const { q, tag, lang } = 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 { q, tag, lang, prefer, preferFirst } = req.query;
|
||||
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) {
|
||||
// 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 });
|
||||
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 matched = await matchingVersions(conditions);
|
||||
const identities = [...new Set(matched.map((a) => a.baseSlug || a.slug))];
|
||||
const groupDocs = await Article.find({
|
||||
$or: [{ slug: { $in: identities } }, { baseSlug: { $in: identities } }],
|
||||
if (!identities.length) return res.json({ articles: [], total: 0, hasMore: false });
|
||||
|
||||
// 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")
|
||||
.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, []]));
|
||||
for (const a of groupDocs) {
|
||||
versionsByIdentity.get(a.baseSlug || a.slug)?.push(a);
|
||||
}
|
||||
const matchedSlugs = new Set(matched.map((a) => a.slug));
|
||||
|
||||
const entries = identities.map((id) => {
|
||||
const group = versionsByIdentity.get(id) ?? [];
|
||||
const asVersion = (a) => ({ ...a.toObject(), versions: group });
|
||||
if (wantedLang) {
|
||||
const inWanted = group.find((a) => languageCode(a.language) === wantedLang);
|
||||
if (inWanted) return asVersion(inWanted);
|
||||
}
|
||||
const rep = group.find((a) => !a.baseSlug) ?? group.find((a) => matchedSlugs.has(a.slug)) ?? group[0];
|
||||
return asVersion(rep);
|
||||
const entries = page.map((entry) => {
|
||||
const versions = groupByIdentity.get(entry.id) ?? [];
|
||||
const shown = versions.find((a) => a.slug === entry.shownSlug) ?? versions[0];
|
||||
return { ...shown.toObject(), versions };
|
||||
});
|
||||
|
||||
res.json({
|
||||
articles: entries,
|
||||
total: shelf.length,
|
||||
hasMore: offset + page.length < shelf.length,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
next(err);
|
||||
}
|
||||
@@ -142,9 +311,12 @@ router.get("/random", async (req, res, next) => {
|
||||
|
||||
// Links inside article content, as the graph sees them: wiki 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 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
|
||||
// per article — versions of the same article share a node — and one undirected
|
||||
@@ -221,33 +393,40 @@ router.get("/slug/:slug", async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/articles - create an article (sign-in required; reading is public,
|
||||
// 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
|
||||
// joins the original's version group and takes a slug beside it (slug-fr, …).
|
||||
router.post("/", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { title, content = "", tags = [], baseSlug } = req.body || {};
|
||||
const language = languageCode(req.body?.language);
|
||||
if (!title || !String(title).trim()) {
|
||||
return res.status(400).json({ message: "Title is required" });
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
return res.status(400).json({ message: "The article being translated does not exist" });
|
||||
}
|
||||
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(req.body.slug || title),
|
||||
});
|
||||
const byName = await Article.findOne({ slug: slugify(body.slug || title) });
|
||||
if (byName && languageCode(byName.language) !== language) {
|
||||
identity = byName.baseSlug || byName.slug;
|
||||
}
|
||||
@@ -257,42 +436,75 @@ router.post("/", requireAuth, async (req, res, next) => {
|
||||
$or: [{ slug: identity }, { baseSlug: identity }],
|
||||
language,
|
||||
});
|
||||
if (clash) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: `This article already has a version in ${language}` });
|
||||
}
|
||||
if (clash) requestError(400, `This article already has a version in ${language}`);
|
||||
}
|
||||
|
||||
const requested = req.body.slug
|
||||
? slugify(req.body.slug)
|
||||
const requested = body.slug
|
||||
? slugify(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,
|
||||
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,
|
||||
});
|
||||
res.status(201).json(article);
|
||||
}
|
||||
|
||||
// POST /api/articles - create an article (sign-in required; reading is public,
|
||||
// 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
|
||||
// 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) => {
|
||||
try {
|
||||
const account = await User.findById(req.user.id).select("username role");
|
||||
if (!account) return res.status(401).json({ message: "No such account any more." });
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
res.status(201).json(await createArticle(req.body || {}));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/articles/slug/:slug - update an article (sign-in required)
|
||||
router.put("/slug/:slug", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { title, content, tags, language } = req.body || {};
|
||||
/**
|
||||
* 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()) {
|
||||
return res.status(400).json({ message: "Title is required" });
|
||||
requestError(400, "Title is required");
|
||||
}
|
||||
const article = await Article.findOne({ slug: req.params.slug });
|
||||
if (!article) return res.status(404).json({ message: "Article not found" });
|
||||
|
||||
const update = {};
|
||||
if (title !== undefined) update.title = title;
|
||||
@@ -311,9 +523,7 @@ router.put("/slug/:slug", requireAuth, async (req, res, next) => {
|
||||
language: code,
|
||||
});
|
||||
if (clash) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: `This article already has a version in ${code}` });
|
||||
requestError(400, `This article already has a version in ${code}`);
|
||||
}
|
||||
update.language = code;
|
||||
}
|
||||
@@ -321,14 +531,14 @@ router.put("/slug/:slug", requireAuth, async (req, res, next) => {
|
||||
|
||||
// Re-slug when the title changes and no explicit slug was provided
|
||||
if (title !== undefined) {
|
||||
const requested = req.body.slug ? slugify(req.body.slug) : slugify(title);
|
||||
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: req.params.slug },
|
||||
{ slug: article.slug },
|
||||
update,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
@@ -339,19 +549,144 @@ router.put("/slug/:slug", requireAuth, async (req, res, next) => {
|
||||
await Article.updateMany({ baseSlug: article.slug }, { baseSlug: update.slug });
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
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) => {
|
||||
try {
|
||||
const { title } = req.body || {};
|
||||
if (title !== undefined && !String(title).trim()) {
|
||||
return res.status(400).json({ message: "Title is required" });
|
||||
}
|
||||
const article = await Article.findOne({ slug: req.params.slug });
|
||||
if (!article) return res.status(404).json({ message: "Article not found" });
|
||||
|
||||
const account = await User.findById(req.user.id).select("username role");
|
||||
if (!account) return res.status(401).json({ message: "No such account any more." });
|
||||
|
||||
if (account.role !== "admin") {
|
||||
const proposal = await submitProposal(article, account, req.body || {});
|
||||
return res.status(202).json({ proposal });
|
||||
}
|
||||
|
||||
res.json(await applyArticleEdit(article, req.body || {}));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/articles/slug/:slug - delete an article (sign-in required).
|
||||
// Deleting the canonical version leaves its translations readable: they still
|
||||
// recognise each other through the baseSlug they share.
|
||||
router.delete("/slug/:slug", requireAuth, async (req, res, next) => {
|
||||
// DELETE /api/articles/slug/:slug - delete an article, admins alone. Writing
|
||||
// through a proposal is how a member's change lands; deleting is the one thing
|
||||
// nobody does to the wiki without holding the role. Deleting the canonical
|
||||
// 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 {
|
||||
const deleted = await Article.findOneAndDelete({ slug: req.params.slug });
|
||||
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();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
1304
backend/src/seed-bulk.js
Normal file
1304
backend/src/seed-bulk.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,9 @@ npm run dev # → http://localhost:3000
|
||||
| Route | What it does |
|
||||
| --- | --- |
|
||||
| `/` | Search + recently updated articles |
|
||||
| `/wiki/<slug>` | Rendered article with table of contents |
|
||||
| `/wiki/<slug>/edit` | Markdown editor with live preview |
|
||||
| `/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>` | 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 |
|
||||
|
||||
## Markdown + LaTeX
|
||||
@@ -46,7 +47,8 @@ Handy macros: `\R`, `\N`, `\Z`, `\Q`, `\C` for the number sets.
|
||||
|
||||
| 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 |
|
||||
| POST | `/api/articles` — create (`title` required; slug auto-generated & uniquified) |
|
||||
| PUT | `/api/articles/slug/:slug` — update |
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
@use 'mixins' as *;
|
||||
|
||||
// ---------- The dashboard's two tabs: the requests and the accounts ----------
|
||||
// The same segmented control the sign-in popup and the settings switch wear
|
||||
// (the mixins in _mixins.scss); the panels they open are the two sections.
|
||||
.admin-tabs { @include segmented; margin-bottom: 22px; }
|
||||
|
||||
.admin-tab {
|
||||
@include segmented-tab(7px 14px, 0.84rem);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
// how many requests are still waiting, so the pile is visible even while the
|
||||
// admin is reading the accounts — the tally's shape from the sidebar's count
|
||||
.admin-tab-count {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// ---------- Admin dashboard: the list of accounts ----------
|
||||
// One row per account, read left to right: who, what they may do, when they
|
||||
// arrived, and the one thing an admin can change about them.
|
||||
|
||||
@@ -3,11 +3,68 @@
|
||||
// ---------- Articles index ----------
|
||||
.topic-tally { font-size: 0.85rem; color: var(--muted); }
|
||||
|
||||
.topic-dropdown {
|
||||
@include dropdown-panel;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
// ---------- Folders: the shelf filed by topic ----------
|
||||
.folder-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.folder-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 15px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.18s ease, border-color 0.18s, box-shadow 0.18s;
|
||||
|
||||
svg { flex: none; color: var(--accent); opacity: 0.85; transition: opacity 0.15s; }
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
box-shadow: var(--shadow-lg);
|
||||
svg { opacity: 1; }
|
||||
}
|
||||
|
||||
&:focus-visible { box-shadow: var(--ring); }
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
// the facet tally lowercases every topic; folders wear their names capitalized
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.folder-count { flex: none; margin-left: auto; }
|
||||
|
||||
// the folder grid's loading row: a shorter skeleton than an article card's
|
||||
.folder-skeleton { height: 60px; border-radius: 14px; @include shimmer; }
|
||||
|
||||
// closing a folder: the quiet way back onto the topics
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover { color: var(--accent); }
|
||||
}
|
||||
|
||||
.topic-option {
|
||||
@@ -18,45 +75,6 @@
|
||||
&:hover, &.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
}
|
||||
|
||||
.topic-dropdown-none {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.topic-search-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
color: var(--faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-search {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 14px 0 40px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s, box-shadow 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.topic-count {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
// the shelf's last line: reaching it brings the next batch of cards in, and
|
||||
// while that batch is on its way it says so
|
||||
.shelf-sentinel {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-top: 24px;
|
||||
color: var(--faint);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
|
||||
// editor body
|
||||
.editor-body {
|
||||
// One height for the writing field and the preview side by side: each holds
|
||||
// its text in a box of this height and scrolls it inside, so a long article
|
||||
// never makes the editor taller than the field the writer is looking at.
|
||||
--editor-pane-h: 480px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 15px;
|
||||
background: var(--bg-elev);
|
||||
@@ -141,6 +146,7 @@
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: var(--editor-pane-h);
|
||||
|
||||
&.mode-write {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -158,11 +164,11 @@
|
||||
textarea.md-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 480px;
|
||||
height: 100%;
|
||||
padding: 22px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
resize: vertical;
|
||||
resize: none;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.75;
|
||||
tab-size: 2;
|
||||
@@ -176,13 +182,24 @@ textarea.md-input {
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 22px 26px;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
// and reaching the end of a long preview doesn't start scrolling the page
|
||||
overscroll-behavior: contain;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.preview-label { @include eyebrow; margin-bottom: 14px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.editor-grid { grid-template-columns: 1fr; }
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// stacked, the two panes keep the height the split gives them — each still
|
||||
// scrolls its own text rather than stretching the editor
|
||||
.write-pane,
|
||||
.preview-pane { height: var(--editor-pane-h); }
|
||||
|
||||
.preview-pane { border-left: 0 !important; border-top: 1px solid var(--border); }
|
||||
}
|
||||
@@ -265,6 +282,21 @@ textarea.md-input {
|
||||
|
||||
@include popup-anim('confirm-popup', '.confirm-modal', 0.18s, transform 0.18s ease, 10px);
|
||||
|
||||
// sent-request popup — the discard popup's shape in a success temper: it
|
||||
// confirms a proposal filed with the admins, and closing it ends the edit
|
||||
.sent-check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--success) 14%, transparent);
|
||||
color: var(--success);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
// fullscreen editor
|
||||
.editor-body.is-fullscreen {
|
||||
position: fixed;
|
||||
@@ -278,9 +310,10 @@ textarea.md-input {
|
||||
|
||||
.editor-toolbar { flex: 0 0 auto; }
|
||||
|
||||
.editor-grid { flex: 1; min-height: 0; }
|
||||
.editor-grid { flex: 1; height: auto; min-height: 0; }
|
||||
|
||||
.write-pane, .preview-pane { min-height: 0; overflow-y: auto; }
|
||||
// the screen decides how tall a pane is here, not --editor-pane-h
|
||||
.write-pane, .preview-pane { height: auto; min-height: 0; overflow-y: auto; }
|
||||
|
||||
textarea.md-input { height: 100%; min-height: 0; resize: none; }
|
||||
}
|
||||
|
||||
@@ -8,10 +8,24 @@
|
||||
inset: -120px -20% auto -20%;
|
||||
height: 480px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(38% 60% at 30% 30%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 70%),
|
||||
radial-gradient(34% 55% at 72% 22%, color-mix(in srgb, var(--accent-2) 14%, transparent), transparent 70%);
|
||||
filter: blur(30px);
|
||||
}
|
||||
}
|
||||
|
||||
// the index page hangs its hero off the middle of the reading surface: only
|
||||
// `/` carries `.is-home`, so the stretch below stays here and the other pages
|
||||
// that share `.home-main` (the shelf, the two admin desks) are untouched.
|
||||
// `.app-main` already owns the viewport's height (flex: 1 beside the sidebar
|
||||
// on desktop, under the top bar on mobile); the container grows into it and
|
||||
// centres the page's block in what's left.
|
||||
.app-main:has(.home-main.is-home) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +102,18 @@
|
||||
&:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
||||
}
|
||||
|
||||
// the hero box opens the search popup (the Ctrl/⌘+K one) instead of taking
|
||||
// text, so it is a button wearing the search bar: the label sits where typed
|
||||
// text would, in the placeholder's colour
|
||||
.search-input.search-trigger {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--faint);
|
||||
|
||||
&:hover { border-color: var(--accent); }
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
|
||||
@@ -183,6 +183,15 @@
|
||||
min-height: 39px; // 8+8 padding + 14.4px x 1.6 label line
|
||||
}
|
||||
|
||||
// the waiting count is not carried off with the labels: pinned to the
|
||||
// icon's corner, it stays a dot on the rail
|
||||
.sidebar-requests .requests-count {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav .btn {
|
||||
min-height: 35px; // 7+7 padding + 13.12px x 1.6 label line
|
||||
}
|
||||
@@ -211,13 +220,14 @@
|
||||
html.sidebar-collapsed .hide-collapsed { display: none; transition: none; }
|
||||
}
|
||||
|
||||
// ---------- "Open" articles: the count beside the button that shows them ----------
|
||||
// ---------- Sidebar tallies: articles open, requests waiting ----------
|
||||
.sidebar-open.is-current {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.tabs-count {
|
||||
.tabs-count,
|
||||
.requests-count {
|
||||
margin-left: auto;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
@@ -232,6 +242,10 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// the waiting pile is the very reason to open the desk, so its number stays
|
||||
// on the collapsed rail — the link is the frame it rides the corner of
|
||||
.sidebar-requests { position: relative; }
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
// ---------- Profile popup ----------
|
||||
// The account row in the sidebar opens it; it draws and closes in the same
|
||||
// shape as the settings popup. Holds the profile picture (a data URL kept on
|
||||
// the account), the username, the password, and the way out.
|
||||
// the account), the username, the password, the interface language, and the
|
||||
// way out. Cut wider than the settings sheet so the language's five choices
|
||||
// fit in one row, and taller so the sections need no scrolling.
|
||||
.profile-overlay { @include overlay(120); }
|
||||
|
||||
.profile-modal {
|
||||
@include sheet(460px, min(620px, calc(100vh - 110px)));
|
||||
@include sheet(540px, min(720px, calc(100vh - 110px)));
|
||||
|
||||
&:focus, &:focus-visible { outline: none; }
|
||||
}
|
||||
|
||||
214
frontend/app/assets/css/_proposals.scss
Normal file
214
frontend/app/assets/css/_proposals.scss
Normal 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; }
|
||||
}
|
||||
@@ -32,8 +32,9 @@
|
||||
|
||||
.settings-group-hint { font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
// segmented light / dark switch
|
||||
.theme-switch { @include segmented; }
|
||||
// segmented control: the settings' light / dark switch and the profile's
|
||||
// language row; wraps so a narrow screen never clips the last choice
|
||||
.theme-switch { @include segmented; flex-wrap: wrap; }
|
||||
|
||||
.theme-option {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
h3 { margin: 0 0 6px; color: var(--text); font-size: 1.15rem; }
|
||||
|
||||
p { margin: 0 auto 22px; max-width: 42ch; font-size: 0.93rem; }
|
||||
|
||||
// two ways forward from a dead end, side by side
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
@@ -37,6 +45,32 @@
|
||||
border-radius: 5px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
// a rule the same size and shape, in another temper: green once a change
|
||||
// has landed somewhere, blue for what the page wants you to know first
|
||||
&.banner-success {
|
||||
border-color: color-mix(in srgb, var(--success) 35%, var(--border));
|
||||
background: color-mix(in srgb, var(--success) 7%, var(--surface));
|
||||
|
||||
.glyph { color: var(--success); }
|
||||
}
|
||||
|
||||
&.banner-info {
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
||||
|
||||
.glyph { color: var(--accent); }
|
||||
}
|
||||
|
||||
.banner-link {
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { color: var(--accent-strong); }
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton { border-radius: 16px; height: 172px; @include shimmer; }
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
--accent-strong: #3d4dd6;
|
||||
--accent-2: #0ea5c9;
|
||||
--danger: #d1344b;
|
||||
--success: #189a5c;
|
||||
--code-bg: #f4f6fa;
|
||||
--shadow-sm: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
||||
--shadow-lg: 0 4px 12px rgb(16 24 40 / 8%), 0 16px 40px -12px rgb(16 24 40 / 18%);
|
||||
@@ -58,6 +59,7 @@
|
||||
--accent-strong: #99a5ff;
|
||||
--accent-2: #38d3ee;
|
||||
--danger: #f47085;
|
||||
--success: #3fce8b;
|
||||
--code-bg: #0c0f16;
|
||||
--shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
|
||||
--shadow-lg: 0 4px 16px rgb(0 0 0 / 40%), 0 24px 48px -16px rgb(0 0 0 / 55%);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
@use 'langs'; // badges, chips, version & language menus
|
||||
@use 'states'; // empty, banner, skeleton
|
||||
@use 'admin'; // the accounts dashboard
|
||||
@use 'proposals'; // the dashboard's edit requests from members
|
||||
@use 'article'; // article page, ⋮ menu, TOC, recently viewed
|
||||
@use 'prose'; // rendered markdown, highlight.js, KaTeX
|
||||
@use 'editor'; // editor, cheatsheet, confirm, fullscreen
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
|
||||
const { show: openSearch } = useSearch()
|
||||
const { show: openSettings } = useSettings()
|
||||
const { tabs, lastSlug } = useArticleTabs()
|
||||
const { tabs, lastIdentity } = useArticleTabs()
|
||||
const { user, isAdmin, signOut } = useAuth()
|
||||
const { pending: waitingRequests, refresh: refreshPending } = usePendingRequests()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { show: openProfile } = useProfileMenu()
|
||||
const { t } = useI18n()
|
||||
@@ -15,21 +16,43 @@ const router = useRouter()
|
||||
lays their pages side by side, older ones parked with only their spines
|
||||
showing. The stack stays open all session, but the view of it is only drawn
|
||||
in the reader — so this button is the way back: press it wherever you are
|
||||
(the graph, the list, the editor) and every article you have open comes back
|
||||
into view, stacked as you left it. */
|
||||
const inReader = computed(() => wikiSlugFromPath(route.path) !== null)
|
||||
(the graph, the list, the editor, a missing article) and every article you
|
||||
have open comes back into view, stacked as you left it. In view only when the
|
||||
route names one of the open pages: a missing article reads like the reader
|
||||
but holds none of them, so the button still turns back from there. */
|
||||
const inReader = computed(() => tabAt(tabs.value, route.path) !== null)
|
||||
|
||||
function showOpenArticles() {
|
||||
if (inReader.value) return // the pages are already spread out in view
|
||||
/* turn back to the page that was last in view — every tab knows its own
|
||||
address, language versions included */
|
||||
const target =
|
||||
tabs.value.find((t) => t.slug === lastSlug.value) ?? tabs.value[tabs.value.length - 1]
|
||||
tabs.value.find((t) => t.identity === lastIdentity.value) ?? tabs.value[tabs.value.length - 1]
|
||||
if (target) void router.push(tabHref(target))
|
||||
}
|
||||
|
||||
/* the shortcut hint beside the search bar — "/" keeps focus on the home
|
||||
page's inline box there, so the popup's universal key is Ctrl/⌘+K */
|
||||
/* How many requests are waiting is an admin's business: ask once the session
|
||||
is known to be one — and when a session stops being one, the door goes blank
|
||||
with the links — and again on every navigation, so the number on the door is
|
||||
no older than the page under it. While an admin decides at the desk, the
|
||||
desk itself keeps the count exact (see usePendingRequests). */
|
||||
watch(
|
||||
isAdmin,
|
||||
() => {
|
||||
if (import.meta.client) void refreshPending()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
if (import.meta.client && isAdmin.value) void refreshPending()
|
||||
}
|
||||
)
|
||||
|
||||
/* the shortcut hint beside the search bar — refined on the client to the
|
||||
device's chord ("/" opens the popup too, just not while a field holds
|
||||
the caret) */
|
||||
const kbdHint = ref('/')
|
||||
onMounted(() => {
|
||||
kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K'
|
||||
@@ -100,6 +123,7 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
end
|
||||
class="nav-link"
|
||||
:title="t('sidebar.adminHint')"
|
||||
>
|
||||
@@ -109,6 +133,20 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.admin') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/admin/proposals"
|
||||
class="nav-link sidebar-requests"
|
||||
:title="waitingRequests ? t('sidebar.requestsWaiting', waitingRequests) : t('sidebar.requestsHint')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="m9 15 2 2 4-4" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.requests') }}</span>
|
||||
<span v-if="waitingRequests" class="requests-count">{{ waitingRequests }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/new" class="btn btn-primary btn-sm" :title="t('sidebar.newArticle')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
|
||||
@@ -24,6 +24,9 @@ const emit = defineEmits<{
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
/* deleting is an admin's call: a member's changes to an article go through
|
||||
proposals, and dropping the page outright is not among what they may do */
|
||||
const { isAdmin } = useAuth()
|
||||
const { open, hide } = useArticleActionsMenu()
|
||||
const { open: langMenuOpen } = useLanguageMenu()
|
||||
const { open: searchOpen } = useSearch()
|
||||
@@ -106,6 +109,7 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
|
||||
:editor-slug="editorSlug"
|
||||
/>
|
||||
|
||||
<template v-if="isAdmin">
|
||||
<div class="kebab-sep" />
|
||||
|
||||
<button
|
||||
@@ -122,6 +126,7 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
|
||||
</svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
A page turned to slides over the older ones, which park along the left
|
||||
edge with only their spines showing; click anywhere on a page — spine or
|
||||
body — to turn back to it, or × to close it. Routes stay the source of
|
||||
truth — every page is a plain /wiki/<slug> URL — and useArticleTabs keeps
|
||||
the stack of pages.
|
||||
truth — every page is a plain /wiki/<lang>/<slug> URL — and useArticleTabs
|
||||
keeps the stack of pages, one page per article however many languages the
|
||||
article speaks: turning one to another language turns this page over, it
|
||||
does not add one to the row.
|
||||
|
||||
Pages further along the book than the one in view wait off the right edge
|
||||
of the desk, where you would have to scroll sideways to reach them. Their
|
||||
@@ -31,7 +33,7 @@ const { open: actionsMenuOpen } = useArticleActionsMenu()
|
||||
const { collapsed } = useSidebar()
|
||||
|
||||
const activeTab = computed(() => tabAt(tabs.value, route.path))
|
||||
const activeSlug = computed(() => activeTab.value?.slug ?? null)
|
||||
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
|
||||
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null)
|
||||
@@ -44,8 +46,8 @@ function liesFlat() {
|
||||
/** Which page of the book is the one in view, or -1 when the route names
|
||||
* none of them. Only the pages beyond it can be waiting for you. */
|
||||
const openIndex = computed(() => {
|
||||
const slug = activeSlug.value
|
||||
return slug === null ? -1 : tabs.value.findIndex((t) => t.slug === slug)
|
||||
const identity = activeIdentity.value
|
||||
return identity === null ? -1 : tabs.value.findIndex((t) => t.identity === identity)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -57,10 +59,10 @@ const openIndex = computed(() => {
|
||||
async function alignToActive(behavior: ScrollBehavior = 'smooth') {
|
||||
await nextTick()
|
||||
const track = trackEl.value
|
||||
const slug = activeSlug.value
|
||||
if (!track || !slug || !liesFlat()) return
|
||||
const identity = activeIdentity.value
|
||||
if (!track || !identity || !liesFlat()) return
|
||||
|
||||
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`)
|
||||
const page = track.querySelector<HTMLElement>(`[data-tab="${CSS.escape(identity)}"]`)
|
||||
const i = Number(page?.dataset.index)
|
||||
if (!page || !Number.isFinite(i)) return
|
||||
|
||||
@@ -77,10 +79,10 @@ async function alignToActive(behavior: ScrollBehavior = 'smooth') {
|
||||
off the row, not guessed at: slide the book and a spine leaves the stack as
|
||||
its page comes into view, and joins it again as the page slides out. */
|
||||
|
||||
/** The slugs of the pages whose spines are in the dock. */
|
||||
const waitingSlugs = ref<string[]>([])
|
||||
/** The identities of the pages whose spines are in the dock. */
|
||||
const waitingPages = ref<string[]>([])
|
||||
/** Those pages, in the order they were opened. */
|
||||
const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug)))
|
||||
const waiting = computed(() => tabs.value.filter((t) => waitingPages.value.includes(t.identity)))
|
||||
|
||||
/**
|
||||
* Fill the dock with the pages beyond the one in view whose spines have no
|
||||
@@ -91,22 +93,24 @@ function measureDock() {
|
||||
const track = trackEl.value
|
||||
const open = openIndex.value
|
||||
if (!track || open === -1 || !liesFlat()) {
|
||||
waitingSlugs.value = []
|
||||
waitingPages.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const edge = track.getBoundingClientRect().right
|
||||
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
|
||||
const held = new Set(waitingSlugs.value)
|
||||
const held = new Set(waitingPages.value)
|
||||
for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) {
|
||||
const i = Number(page.dataset.index)
|
||||
const slug = page.dataset.slug
|
||||
if (!slug || !Number.isFinite(i) || i <= open) continue
|
||||
const identity = page.dataset.tab
|
||||
if (!identity || !Number.isFinite(i) || i <= open) continue
|
||||
const left = page.getBoundingClientRect().left
|
||||
if (left > edge - 6) held.add(slug)
|
||||
else if (left < edge - spine - 10) held.delete(slug)
|
||||
if (left > edge - 6) held.add(identity)
|
||||
else if (left < edge - spine - 10) held.delete(identity)
|
||||
}
|
||||
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug)
|
||||
waitingPages.value = tabs.value
|
||||
.filter((t, i) => i > open && held.has(t.identity))
|
||||
.map((t) => t.identity)
|
||||
}
|
||||
|
||||
/* the row moves on scroll, so the dock is measured once a frame while it does */
|
||||
@@ -121,7 +125,7 @@ function watchDock() {
|
||||
onBeforeUnmount(() => cancelAnimationFrame(measuring))
|
||||
|
||||
/* turning to an article slides the book round to it */
|
||||
watch(activeSlug, () => void alignToActive('smooth'))
|
||||
watch(activeIdentity, () => void alignToActive('smooth'))
|
||||
/* and the row changing shape — however it changed — changes which pages are
|
||||
waiting out beyond the edge of the desk */
|
||||
watch(tabs, () => void nextTick(measureDock))
|
||||
@@ -162,18 +166,21 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function turnTo(slug: string) {
|
||||
const tab = tabs.value.find((t) => t.slug === slug)
|
||||
if (tab && slug !== activeSlug.value) void router.push(tabHref(tab))
|
||||
function turnTo(identity: string) {
|
||||
const tab = tabs.value.find((t) => t.identity === identity)
|
||||
if (tab && identity !== activeIdentity.value) void router.push(tabHref(tab))
|
||||
}
|
||||
|
||||
async function removeArticle(slug: string) {
|
||||
const title = tabs.value.find((t) => t.slug === slug)?.title ?? slug
|
||||
if (!window.confirm(t('book.deleteConfirm', { title }))) return
|
||||
/* the page's × deletes the very version it holds — the document its address
|
||||
asks for, which for a translation is its own — and closes the page */
|
||||
async function removeArticle(identity: string) {
|
||||
const tab = tabs.value.find((t) => t.identity === identity)
|
||||
if (!tab) return
|
||||
if (!window.confirm(t('book.deleteConfirm', { title: tab.title }))) return
|
||||
try {
|
||||
await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' })
|
||||
forget(slug)
|
||||
closeTab(slug)
|
||||
await $fetch(`${api}/articles/slug/${encodeURIComponent(tab.slug)}`, { method: 'DELETE' })
|
||||
forget(tab.slug)
|
||||
closeTab(identity)
|
||||
} catch {
|
||||
window.alert(t('book.deleteFailed'))
|
||||
}
|
||||
@@ -185,7 +192,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return
|
||||
if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value)
|
||||
return
|
||||
const current = tabAt(tabs.value, route.path)?.slug
|
||||
const current = tabAt(tabs.value, route.path)?.identity
|
||||
if (current) closeTab(current)
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
@@ -202,18 +209,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.slug"
|
||||
:key="tab.identity"
|
||||
class="book-chip"
|
||||
:class="{ active: tab.slug === activeSlug }"
|
||||
:class="{ active: tab.identity === activeIdentity }"
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="tab.slug === activeSlug"
|
||||
:aria-selected="tab.identity === activeIdentity"
|
||||
:title="t('book.chipHint', { title: tab.title })"
|
||||
@click="turnTo(tab.slug)"
|
||||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||||
@click="turnTo(tab.identity)"
|
||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||
>
|
||||
<span class="chip-title">{{ tab.title }}</span>
|
||||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
@@ -224,28 +231,29 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
|
||||
<ArticlePane
|
||||
v-for="(tab, i) in tabs"
|
||||
:key="tab.slug"
|
||||
:key="tab.identity"
|
||||
:identity="tab.identity"
|
||||
:slug="tab.slug"
|
||||
:index="i"
|
||||
:active="tab.slug === activeSlug"
|
||||
@activate="turnTo(tab.slug)"
|
||||
@close="closeTab(tab.slug)"
|
||||
@remove="removeArticle(tab.slug)"
|
||||
:active="tab.identity === activeIdentity"
|
||||
@activate="turnTo(tab.identity)"
|
||||
@close="closeTab(tab.identity)"
|
||||
@remove="removeArticle(tab.identity)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
|
||||
<div
|
||||
v-for="tab in waiting"
|
||||
:key="tab.slug"
|
||||
:key="tab.identity"
|
||||
class="dock-spine"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('book.dockHint', { title: tab.title })"
|
||||
@click="turnTo(tab.slug)"
|
||||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||||
@click="turnTo(tab.identity)"
|
||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||
>
|
||||
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||||
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
|
||||
@@ -35,9 +35,7 @@ const multiLang = computed(() => langs.value.length > 1)
|
||||
const langCodes = computed(() => langs.value.map((v) => v.language.toUpperCase()).join(' · '))
|
||||
const langNames = computed(() => langs.value.map((v) => languageName(v.language)).join(', '))
|
||||
/* where the card leads: the language address of the version it shows */
|
||||
const href = computed(() =>
|
||||
versionHref(identityOf(props.article), { language: language.value, slug: props.article.slug })
|
||||
)
|
||||
const href = computed(() => versionHref(identityOf(props.article), language.value))
|
||||
|
||||
const updated = computed(() => {
|
||||
const d = props.article.updatedAt ? new Date(props.article.updatedAt) : null
|
||||
|
||||
@@ -16,11 +16,22 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
/* Writing is the one thing on this page that takes an account: the save carries
|
||||
the session token, and a save the API refuses is the editor's own signal that
|
||||
the session has gone while this draft was open. */
|
||||
const { authHeaders } = useAuth()
|
||||
the session has gone while this draft was open. A member's save is not a
|
||||
write at all — neither a change to a page nor a page the wiki does not have
|
||||
yet: the API keeps either as a proposal for an admin (models/EditProposal.js
|
||||
on the backend); only an admin's goes straight onto the page. */
|
||||
const { authHeaders, isAdmin } = useAuth()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { t } = useI18n()
|
||||
|
||||
/* saving without the admin role: every save is a request an admin answers, and
|
||||
the editor says so rather than pretending to publish */
|
||||
const memberWrite = computed(() => !isAdmin.value)
|
||||
/* which request the editor is holding: a change to an existing page, or a new
|
||||
one — the words it says the member differ, and a new page has nothing to view
|
||||
until an admin approves it */
|
||||
const creating = computed(() => props.mode === 'create')
|
||||
|
||||
const original = props.translateFrom ?? null
|
||||
/* the version group the new document joins, shared by every translation */
|
||||
const baseSlug = original ? original.baseSlug || original.slug : ''
|
||||
@@ -238,16 +249,20 @@ function slugifyText(text: string): string {
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/* the address being edited or published: versions live at /wiki/<lang>/<slug>,
|
||||
the canonical article at /wiki/<slug> — where a canonical retitle moves it */
|
||||
/* the address the article in hand lives at — its identity with the language of
|
||||
the version in view in front. The title plays no part: a translation's
|
||||
address is the article's identity whatever it is titled, and the canonical
|
||||
version's identity *is* its slug, so guessing from the title would send a
|
||||
retitle off to a new address (and onto a twin article's, when a name was
|
||||
taken and the slug carries a -2). Only an article the wiki has not been
|
||||
given yet can be previewed from its title. */
|
||||
const slugPreview = computed(() => {
|
||||
if (isVersioned.value) {
|
||||
const d = activeDraft()
|
||||
if (d && d.slug && d.slug !== identity) return `/wiki/${d.language}/${identity}`
|
||||
return `/wiki/${slugifyText(title.value) || savedSlug}`
|
||||
const lang = activeDraft()?.language ?? props.article?.language
|
||||
return versionHref(identity, lang)
|
||||
}
|
||||
if (original) return `/wiki/${language.value}/${baseSlug}`
|
||||
return `/wiki/${slugifyText(title.value) || '…'}`
|
||||
if (original) return versionHref(baseSlug, language.value)
|
||||
return versionHref(slugifyText(title.value) || '…', language.value)
|
||||
})
|
||||
|
||||
const savedSlug = props.article?.slug ?? ''
|
||||
@@ -362,6 +377,41 @@ onBeforeUnmount(() => {
|
||||
/* submit ----------------------------------------------------------- */
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref('')
|
||||
/* a member's save that the API kept as a proposal: a popup confirms the request
|
||||
and dismisses the editor with it — the sent draft is not left on screen to be
|
||||
submitted twice. The next edit of the same thing revises the waiting request
|
||||
rather than filing a second one, which is what `sentProposalId` carries. */
|
||||
const sentOpen = ref(false)
|
||||
/* a filed create answers with the request's id: the same draft saved again
|
||||
revises that request, even once its title — and so the address it asks for —
|
||||
has moved on since */
|
||||
const sentProposalId = ref('')
|
||||
|
||||
/* where leaving a sent request leads: the very version that was sent, read on
|
||||
the reader's side of the wiki — the text an admin has not touched yet.
|
||||
Nothing to read for a page the wiki has not been given: a create lands on
|
||||
the translation it joins, or on the search page. */
|
||||
const sentHref = computed(() => {
|
||||
if (isVersioned.value) {
|
||||
const d = activeDraft()
|
||||
if (!d?.slug && !savedSlug) return ''
|
||||
return versionHref(identity, d?.language ?? props.article?.language)
|
||||
}
|
||||
return savedSlug ? versionHref(savedSlug, props.article?.language) : ''
|
||||
})
|
||||
|
||||
function openSentPopup() {
|
||||
cheatsheet.value = false // popups don't stack in this app
|
||||
sentOpen.value = true
|
||||
}
|
||||
|
||||
/* the popup is the editor's last word on a filed save: dismissing it — button,
|
||||
esc or backdrop — is what leaves, taking the reader back to what was sent */
|
||||
async function closeSentPopup() {
|
||||
sentOpen.value = false
|
||||
const target = sentHref.value || (original ? versionHref(baseSlug, original.language) : '')
|
||||
await router.push(target || '/')
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
return text
|
||||
@@ -370,13 +420,23 @@ function parseTags(text: string): string[] {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/* the answer as `$fetch` gives it, status included: a 202 means the API filed
|
||||
the change as a proposal rather than writing it, and the request it filed
|
||||
comes back along with it so this draft can be sent to the same one again */
|
||||
async function request(body: Record<string, unknown>, method: 'POST' | 'PUT', url: string) {
|
||||
return $fetch<ArticleMeta>(url, { method, body, headers: authHeaders() })
|
||||
const res = await $fetch.raw<ArticleMeta & { proposal?: { _id?: string } }>(url, {
|
||||
method,
|
||||
body,
|
||||
headers: authHeaders(),
|
||||
})
|
||||
return { article: res._data, status: res.status, proposalId: res._data?.proposal?._id ?? '' }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
errorMsg.value = ''
|
||||
if (saving.value) return
|
||||
/* nothing to send while the "request sent" popup is up: this draft already
|
||||
is with the admins, whatever Ctrl+S might otherwise re-submit */
|
||||
if (saving.value || sentOpen.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (isVersioned.value) {
|
||||
@@ -384,7 +444,9 @@ async function save() {
|
||||
brand-new ones join the article through its identity */
|
||||
const pendingDrafts = versionList.value.filter(draftChanged)
|
||||
if (!pendingDrafts.length) {
|
||||
await router.push(slugPreview.value)
|
||||
/* nothing to send: leave for the page this version is actually read at
|
||||
(the identity and the language say where — no guessing from titles) */
|
||||
await router.push(versionHref(identity, activeLang.value))
|
||||
return
|
||||
}
|
||||
const missing = pendingDrafts.find((d) => !draftCurrent(d).title.trim())
|
||||
@@ -395,6 +457,7 @@ async function save() {
|
||||
}
|
||||
let savedActive: ArticleMeta | null = null
|
||||
let lastSaved: ArticleMeta | null = null
|
||||
let proposalSent = false
|
||||
for (const d of pendingDrafts) {
|
||||
const cur = draftCurrent(d)
|
||||
const body = {
|
||||
@@ -404,18 +467,33 @@ async function save() {
|
||||
/* a version written here joins the article it belongs to */
|
||||
...(d.slug ? {} : { baseSlug: identity, language: d.language }),
|
||||
}
|
||||
const saved = d.slug
|
||||
/* an existing version answers with the write — or, for a member, with
|
||||
the 202 of a proposal filed; a brand-new one is always published */
|
||||
const res = d.slug
|
||||
? await request(body, 'PUT', `${api}/articles/slug/${encodeURIComponent(d.slug)}`)
|
||||
: await request(body, 'POST', `${api}/articles`)
|
||||
d.slug = saved.slug
|
||||
if (res.status === 202) {
|
||||
proposalSent = true
|
||||
d.saved = cur // the draft is filed — with the admins now, not dirty here
|
||||
continue
|
||||
}
|
||||
const saved = res.article
|
||||
// a retitle moved the document; the answer carries where it lives now
|
||||
d.slug = saved!.slug
|
||||
d.saved = cur
|
||||
lastSaved = saved
|
||||
if (d.language === activeLang.value) savedActive = saved
|
||||
}
|
||||
/* a member's edits are with the admins now: the popup says so where a
|
||||
published save would have navigated, and closing it ends the editor */
|
||||
if (proposalSent) {
|
||||
openSentPopup()
|
||||
return
|
||||
}
|
||||
/* leave on the page that was in view — the answer itself carries where
|
||||
it lives now, so a canonical retitle follows it instead of stranding */
|
||||
const target = savedActive ?? lastSaved
|
||||
await router.push(target ? versionHref(identityOf(target), target) : '/')
|
||||
await router.push(target ? versionHref(identityOf(target), target.language) : '/')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -430,15 +508,27 @@ async function save() {
|
||||
/* a translation joins the article it translates rather than standing alone */
|
||||
...(original ? { baseSlug } : {}),
|
||||
tags: parseTags(tagsText.value),
|
||||
/* a create the API already filed: saving the draft again revises that
|
||||
request, whichever address it asked for when it was first sent */
|
||||
...(creating.value && sentProposalId.value ? { proposalId: sentProposalId.value } : {}),
|
||||
}
|
||||
const url =
|
||||
props.mode === 'create'
|
||||
const url = creating.value
|
||||
? `${api}/articles`
|
||||
: `${api}/articles/slug/${encodeURIComponent(savedSlug)}`
|
||||
const saved = await request(body, props.mode === 'create' ? 'POST' : 'PUT', url)
|
||||
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
|
||||
}
|
||||
// prevent the leave-guard from firing on our own navigation
|
||||
markSaved()
|
||||
await router.push(versionHref(identityOf(saved), saved))
|
||||
await router.push(versionHref(identityOf(saved!), saved!.language))
|
||||
} catch (err: any) {
|
||||
/* A session the API won't take any more. Signing out here would unmount this
|
||||
very form — and the draft in it — so instead the sign-in popup opens over
|
||||
@@ -497,9 +587,7 @@ async function discard() {
|
||||
const back = activeDraft() ?? props.article
|
||||
if (activeDraft()) putDraftFields(activeDraft()) // the fields follow the version in view
|
||||
errorMsg.value = ''
|
||||
const target = back
|
||||
? versionHref(identity, { language: back.language ?? 'en', slug: back.slug })
|
||||
: '/'
|
||||
const target = back ? versionHref(identity, back.language) : '/'
|
||||
await router.push(target)
|
||||
return
|
||||
}
|
||||
@@ -509,7 +597,11 @@ async function discard() {
|
||||
content.value = base.content
|
||||
errorMsg.value = ''
|
||||
await router.push(
|
||||
props.mode === 'create' ? (original ? versionHref(baseSlug, original) : '/') : `/wiki/${savedSlug}`
|
||||
props.mode === 'create'
|
||||
? original
|
||||
? versionHref(baseSlug, original.language)
|
||||
: '/'
|
||||
: versionHref(savedSlug, props.article?.language)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,6 +627,10 @@ function onTabKeydown(e: KeyboardEvent) {
|
||||
// Ctrl/Cmd+S saves; Esc closes the topmost popup first, then leaves fullscreen
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (sentOpen.value) {
|
||||
void closeSentPopup() // dismisses the popup and leaves the editor
|
||||
return
|
||||
}
|
||||
if (addOpen.value) {
|
||||
addOpen.value = false
|
||||
return
|
||||
@@ -564,7 +660,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
<template>
|
||||
<form class="editor-page" @submit.prevent="save">
|
||||
<div class="editor-head">
|
||||
<h1>{{ mode === 'create' ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
|
||||
<h1>{{ creating ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
|
||||
<div class="editor-head-actions">
|
||||
<button
|
||||
ref="discardButtonEl"
|
||||
@@ -581,8 +677,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
{{ t('editor.discard') }}
|
||||
</button>
|
||||
<NuxtLink
|
||||
v-if="mode === 'edit' && savedSlug"
|
||||
:to="versionHref(identity, { language: article?.language, slug: savedSlug })"
|
||||
v-if="props.mode === 'edit' && savedSlug"
|
||||
:to="versionHref(identity, activeLang)"
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
@@ -590,9 +686,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
<button class="btn btn-primary" type="submit" :disabled="saving">
|
||||
{{ saving
|
||||
? t('editor.saving')
|
||||
: memberWrite
|
||||
? t('editor.submitProposal')
|
||||
: original
|
||||
? t('editor.publishTranslation')
|
||||
: mode === 'create'
|
||||
: creating
|
||||
? t('editor.publishArticle')
|
||||
: dirtyCount > 1
|
||||
? t('editor.saveVersions', { n: dirtyCount })
|
||||
@@ -609,11 +707,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</svg>
|
||||
<span>
|
||||
{{ t('editor.translating') }}
|
||||
<NuxtLink :to="versionHref(baseSlug, original)">{{ original.title }}</NuxtLink>
|
||||
<NuxtLink :to="versionHref(baseSlug, original.language)">{{ original.title }}</NuxtLink>
|
||||
{{ t('editor.translatingTail') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- a member writes nothing onto the wiki themselves, a page nor a new one:
|
||||
the editor says so up front, and once a save has filed a request the
|
||||
popup confirms it — closing that popup is what leaves the editor, so
|
||||
the button cannot be pressed twice on an already-sent draft -->
|
||||
<div v-if="memberWrite" class="banner banner-info">
|
||||
<span class="glyph" aria-hidden="true">✎</span>
|
||||
<span>{{ creating ? t('editor.proposalNoticeCreate') : t('editor.proposalNotice') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- the article's language versions: turn the editor to another language,
|
||||
or add one — unsaved text waits on its own version's page -->
|
||||
<div v-if="isVersioned" class="version-bar">
|
||||
@@ -782,5 +889,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
@confirm="discard"
|
||||
@cancel="closeDiscardPrompt"
|
||||
/>
|
||||
|
||||
<ProposalSentModal :open="sentOpen" :creating="creating" @close="closeSentPopup" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -91,7 +91,7 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
|
||||
<NuxtLink
|
||||
v-for="v in existing"
|
||||
:key="v.slug"
|
||||
:to="versionHref(identity, v)"
|
||||
:to="versionHref(identity, v.language)"
|
||||
class="lang-menu-item"
|
||||
:lang="v.language"
|
||||
role="menuitem"
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/* One page of the open book. Its left edge is a spine — the title, set
|
||||
vertically, with the × that closes the page — and the article itself
|
||||
scrolls on its own inside the page. A newer page slides over this one and
|
||||
leaves only the spine showing, the way a stack of read pages does; click
|
||||
anywhere on the page — or its spine — to turn back to it.
|
||||
/* One page of the open book: one article, in whichever language it is turned
|
||||
to. Its left edge is a spine — the title, set vertically, with the × that
|
||||
closes the page — and the article itself scrolls on its own inside the page.
|
||||
A newer page slides over this one and leaves only the spine showing, the way
|
||||
a stack of read pages does; click anywhere on the page — or its spine — to
|
||||
turn back to it. Turning the article to another language changes the text on
|
||||
this page and nothing else: it is the same page of the book, read again.
|
||||
|
||||
Everything but the spine of a page that isn't in view is `inert`: it can
|
||||
still be seen while you slide the stack sideways, but it doesn't take
|
||||
focus or clicks — the pane's own click handler catches those instead, so
|
||||
one click turns to the page before its links do anything. */
|
||||
const props = defineProps<{
|
||||
/** the article this page holds — its canonical slug */
|
||||
identity: string
|
||||
/** the version in view, as the library files it */
|
||||
slug: string
|
||||
index: number
|
||||
active: boolean
|
||||
@@ -32,12 +37,20 @@ const language = computed(() => articleLanguage(article.value ?? {}))
|
||||
const languageLabel = computed(() => languageName(language.value))
|
||||
/* every language this article exists in, the one in view included */
|
||||
const versions = computed(() => article.value?.versions ?? [])
|
||||
/* the article behind this document, and the slug its editor answers to:
|
||||
the identity when the canonical version is still around, this document's
|
||||
own slug when only translations of it remain */
|
||||
const identity = computed(() => identityOf(article.value ?? { slug: props.slug }))
|
||||
/* the slug the editor answers to: the identity when the canonical version is
|
||||
still around, this document's own slug when only translations of it remain */
|
||||
const editorSlug = computed(() =>
|
||||
versions.value.some((v) => v.slug === identity.value) ? identity.value : props.slug
|
||||
versions.value.some((v) => v.slug === props.identity) ? props.identity : props.slug
|
||||
)
|
||||
|
||||
/* turned to another language, the page begins again at the top of the text
|
||||
it now holds — the article is the same, its words are not */
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
watch(
|
||||
() => props.slug,
|
||||
(to, from) => {
|
||||
if (from) scrollEl.value?.scrollTo({ top: 0 })
|
||||
}
|
||||
)
|
||||
|
||||
const updated = computed(() => {
|
||||
@@ -61,7 +74,7 @@ function topicLabel(tag: string) {
|
||||
<section
|
||||
class="book-pane"
|
||||
:class="{ active }"
|
||||
:data-slug="slug"
|
||||
:data-tab="identity"
|
||||
:data-index="index"
|
||||
:style="{ '--i': index }"
|
||||
:aria-label="t('pane.aria', { title })"
|
||||
@@ -91,7 +104,7 @@ function topicLabel(tag: string) {
|
||||
</div>
|
||||
|
||||
<div class="pane-body" :inert="active ? null : ''">
|
||||
<div class="pane-scroll">
|
||||
<div ref="scrollEl" class="pane-scroll">
|
||||
<div v-if="failed && !article" class="empty">
|
||||
<div class="glyph">!</div>
|
||||
<h3>{{ t('pane.readFailTitle') }}</h3>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/* Centered profile popup, opened by the account row in the sidebar (and closed
|
||||
with esc, a backdrop click, or navigation — the same shape as the settings
|
||||
popup). It holds the account itself: the profile picture, the username and
|
||||
the password, and the way out. */
|
||||
popup). It holds the account itself: the profile picture, the username, the
|
||||
password, the interface language the account reads the wiki in, and the
|
||||
way out. */
|
||||
const { open, hide } = useProfileMenu()
|
||||
const { hide: hideSearch } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideSignIn, show: openSignIn } = useSignInPopup()
|
||||
const { user, signOut, updateUsername, changePassword, setAvatar } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const { user, signOut, updateUsername, changePassword, setAvatar, updateLocale } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
const { choose } = useAppLocale()
|
||||
const route = useRoute()
|
||||
|
||||
const panelEl = ref<HTMLElement | null>(null)
|
||||
@@ -166,6 +168,24 @@ function leave() {
|
||||
hide()
|
||||
}
|
||||
|
||||
/* --- the interface language ---
|
||||
Turn the language Mathew speaks in. The device's own choice (the cookie
|
||||
setLocale keeps) lands at once, and the account saves it too, so the next
|
||||
device says the same hello. An account that won't take it — a session gone
|
||||
quiet — doesn't undo the switch made here. */
|
||||
const language = ref('en')
|
||||
|
||||
async function setLanguage(code: string) {
|
||||
if (language.value === code) return
|
||||
language.value = code
|
||||
await choose(code)
|
||||
try {
|
||||
await updateLocale(code)
|
||||
} catch {
|
||||
/* the device-level switch stands on its own */
|
||||
}
|
||||
}
|
||||
|
||||
/* sync the form from the account whenever the popup opens */
|
||||
watch(
|
||||
open,
|
||||
@@ -178,6 +198,7 @@ watch(
|
||||
username.value = user.value?.username ?? ''
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
language.value = locale.value
|
||||
avatarBusy.value = false
|
||||
avatarError.value = ''
|
||||
nameError.value = ''
|
||||
@@ -340,6 +361,30 @@ watch(
|
||||
<p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p>
|
||||
</section>
|
||||
|
||||
<!-- the interface language -->
|
||||
<section class="profile-group" aria-labelledby="profile-language">
|
||||
<div class="profile-group-head">
|
||||
<span id="profile-language" class="profile-group-title">{{ t('profile.language') }}</span>
|
||||
<span class="profile-group-hint">{{ t('profile.languageHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-switch" role="radiogroup" :aria-label="t('profile.languageAria')">
|
||||
<button
|
||||
v-for="l in APP_LANGUAGES"
|
||||
:key="l.code"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="theme-option"
|
||||
:class="{ active: language === l.code }"
|
||||
:aria-checked="language === l.code"
|
||||
:lang="l.code"
|
||||
@click="setLanguage(l.code)"
|
||||
>
|
||||
<span>{{ l.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profile-group">
|
||||
<button type="button" class="btn btn-danger btn-sm profile-signout" @click="leave()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
|
||||
225
frontend/app/components/ProposalRow.vue
Normal file
225
frontend/app/components/ProposalRow.vue
Normal 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>
|
||||
62
frontend/app/components/ProposalSentModal.vue
Normal file
62
frontend/app/components/ProposalSentModal.vue
Normal 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>
|
||||
@@ -19,7 +19,7 @@ const others = computed(() =>
|
||||
<div class="rv-title">{{ t('recent.title') }}</div>
|
||||
<ul>
|
||||
<li v-for="v in others" :key="v.slug">
|
||||
<NuxtLink :to="`/wiki/${v.slug}`">{{ v.title }}</NuxtLink>
|
||||
<NuxtLink :to="viewedHref(v)">{{ v.title }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
|
||||
/* Centered search popup: the sidebar search bar (or Ctrl/⌘+K, or "/"
|
||||
on pages without their own search box) opens it, then it live-searches
|
||||
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
|
||||
or "/" anywhere but a text field) open it, then it live-searches
|
||||
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { th } = useAppLocale()
|
||||
const { th, locale } = useAppLocale()
|
||||
const { open, show, hide, toggle } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
@@ -59,11 +59,16 @@ watch(trimmed, () => {
|
||||
async function runSearch() {
|
||||
const seq = ++requestSeq
|
||||
try {
|
||||
const res = await $fetch<ArticleMeta[]>(`${api}/articles`, {
|
||||
query: { q: trimmed.value },
|
||||
/* the popup never shows more than MAX_RESULTS, so that is what it asks
|
||||
the shelf for rather than every match it has; `prefer` asks each answer
|
||||
to stand with the reader's own language when the article speaks it, and
|
||||
`preferFirst` ranks the articles that truly speak it above the ones
|
||||
shown only through the fallback */
|
||||
const page = await $fetch<{ articles: ArticleMeta[] }>(`${api}/articles`, {
|
||||
query: { q: trimmed.value, limit: MAX_RESULTS, prefer: locale.value, preferFirst: '1' },
|
||||
})
|
||||
if (seq !== requestSeq) return // a newer search already won
|
||||
results.value = res.slice(0, MAX_RESULTS)
|
||||
results.value = page.articles
|
||||
failed.value = false
|
||||
} catch {
|
||||
if (seq !== requestSeq) return
|
||||
@@ -86,9 +91,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
/* "/" also opens the popup — except on the home page, where the key
|
||||
focuses the hero's inline search, and while typing in a field */
|
||||
if (e.key === '/' && !open.value && route.path !== '/') {
|
||||
/* "/" also opens the popup anywhere — except while typing in a field */
|
||||
if (e.key === '/' && !open.value) {
|
||||
const t = e.target
|
||||
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
|
||||
e.preventDefault()
|
||||
@@ -105,7 +109,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* where a result leads: the language address of the version it shows */
|
||||
function articleHref(a: ArticleMeta): string {
|
||||
return versionHref(identityOf(a), { language: articleLanguage(a), slug: a.slug })
|
||||
return versionHref(identityOf(a), articleLanguage(a))
|
||||
}
|
||||
|
||||
function openArticle(article: ArticleMeta) {
|
||||
@@ -250,7 +254,7 @@ function highlight(text: string): Part[] {
|
||||
<div class="search-quick-title">{{ t('recent.title') }}</div>
|
||||
<ul class="search-results">
|
||||
<li v-for="v in viewed.slice(0, 6)" :key="v.slug">
|
||||
<NuxtLink :to="`/wiki/${v.slug}`" class="search-result">
|
||||
<NuxtLink :to="viewedHref(v)" class="search-result">
|
||||
<span class="sr-title">{{ v.title }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
/* Centered settings popup, opened by the sidebar cog (and closed with esc,
|
||||
a backdrop click, or navigation). It holds the preferences — the light /
|
||||
dark theme switch, the accent colour, and the interface language — which
|
||||
drive the data-theme and data-accent attributes that the pre-paint script
|
||||
in nuxt.config.ts restores from localStorage. The language lives in the
|
||||
mathew-locale cookie and, with an account behind the session, on the
|
||||
account itself, so it follows the person across devices. */
|
||||
dark theme switch and the accent colour — which drive the data-theme and
|
||||
data-accent attributes that the pre-paint script in nuxt.config.ts
|
||||
restores from localStorage. The interface language lives in the profile
|
||||
popup instead, since it follows the person across devices. */
|
||||
const { open, hide } = useSettings()
|
||||
const { hide: hideSearch } = useSearch()
|
||||
const { hide: hideSignIn } = useSignInPopup()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { loggedIn, updateLocale } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
const { choose } = useAppLocale()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const theme = ref<'light' | 'dark'>('light')
|
||||
const accent = ref('violet')
|
||||
const language = ref('en')
|
||||
const panelEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const themes = [
|
||||
@@ -38,7 +34,7 @@ const accents = [
|
||||
{ value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' },
|
||||
] as const
|
||||
|
||||
/* read the live theme, accent + language whenever the popup opens so the
|
||||
/* read the live theme + accent whenever the popup opens so the
|
||||
switches are in sync */
|
||||
watch(
|
||||
open,
|
||||
@@ -51,7 +47,6 @@ watch(
|
||||
theme.value =
|
||||
(document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light'
|
||||
accent.value = document.documentElement.getAttribute('data-accent') || 'violet'
|
||||
language.value = locale.value
|
||||
await nextTick()
|
||||
panelEl.value?.focus()
|
||||
},
|
||||
@@ -79,23 +74,6 @@ function setAccent(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Turn the interface language. The device's own choice (the cookie setLocale
|
||||
keeps) lands at once; with an account it is saved there too, so the next
|
||||
device says the same hello. An account that won't take it — a session gone
|
||||
quiet — doesn't undo the switch made here. */
|
||||
async function setLanguage(code: string) {
|
||||
if (language.value === code) return
|
||||
language.value = code
|
||||
await choose(code)
|
||||
if (loggedIn.value) {
|
||||
try {
|
||||
await updateLocale(code)
|
||||
} catch {
|
||||
/* the device-level switch stands on its own */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- esc closes, navigation closes --- */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open.value) hide()
|
||||
@@ -198,29 +176,6 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group" aria-labelledby="settings-language">
|
||||
<div class="settings-group-head">
|
||||
<span id="settings-language" class="settings-group-title">{{ t('settings.language') }}</span>
|
||||
<span class="settings-group-hint">{{ t('settings.languageHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-switch" role="radiogroup" :aria-label="t('settings.languageAria')">
|
||||
<button
|
||||
v-for="l in APP_LANGUAGES"
|
||||
:key="l.code"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="theme-option"
|
||||
:class="{ active: language === l.code }"
|
||||
:aria-checked="language === l.code"
|
||||
:lang="l.code"
|
||||
@click="setLanguage(l.code)"
|
||||
>
|
||||
<span>{{ l.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,12 @@ function escapeHtml(text: string): string {
|
||||
}
|
||||
|
||||
export function useAppLocale() {
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
/* The global composer, not `useI18n()`: that one insists on a component
|
||||
instance, and this composable also runs from the auth plugin, which has
|
||||
none — a plugin runs in the Nuxt app's context, never in a `setup`. It is
|
||||
the same composer `useI18n()` hands to a component anyway, since no part of
|
||||
the wiki keeps messages of its own. */
|
||||
const { locale, setLocale, t } = useNuxtApp().$i18n
|
||||
|
||||
/* Translate a message that is drawn with v-html. Every interpolated value
|
||||
is escaped — tags, slugs and usernames wander into these — while the
|
||||
|
||||
@@ -44,17 +44,22 @@ export function useArticleLibrary() {
|
||||
}
|
||||
|
||||
function put(key: string, article: ReaderArticle) {
|
||||
/* the one address a version answers to, whatever asked for it: the short
|
||||
/wiki/<slug> form fetches by document and the versioned form by
|
||||
(identity, language), and both are the same page of the shelf */
|
||||
const version = versionKey(identityOf(article), articleLanguage(article))
|
||||
delete unreadable.value[key]
|
||||
delete unreadable.value[versionKey(identityOf(article), article.language ?? 'en')]
|
||||
delete unreadable.value[version]
|
||||
shelf.value[key] = article
|
||||
/* a version also answers to its document's own slug, where it was fetched
|
||||
from and where the panes keep it */
|
||||
from and where the panes keep it, and to the version it is */
|
||||
shelf.value[article.slug] = article
|
||||
shelf.value[version] = article
|
||||
}
|
||||
|
||||
function forget(slug: string) {
|
||||
const article = shelf.value[slug]
|
||||
if (article) delete shelf.value[versionKey(identityOf(article), article.language ?? 'en')]
|
||||
if (article) delete shelf.value[versionKey(identityOf(article), articleLanguage(article))]
|
||||
delete shelf.value[slug]
|
||||
delete unreadable.value[slug]
|
||||
}
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
export interface ArticleTab {
|
||||
/** the article this page is of — its canonical slug, and the page's key */
|
||||
identity: string
|
||||
/** the language this page is turned to */
|
||||
lang: string
|
||||
/** the version in view, as the library files it: its stored slug */
|
||||
slug: string
|
||||
/** and the document behind it — which a retitle keeps, so the article's page
|
||||
* follows it to its new address instead of a second page opening beside it */
|
||||
docId?: string
|
||||
title: string
|
||||
/** the language version this page holds */
|
||||
lang?: string
|
||||
/** the article this document is a version of — its canonical slug */
|
||||
identity?: string
|
||||
}
|
||||
|
||||
const MAX_TABS = 10
|
||||
|
||||
/** The address a page of the book was read from: the article's own URL for
|
||||
* the canonical version, /wiki/<lang>/<slug> for the others. */
|
||||
export function tabHref(tab: Pick<ArticleTab, 'slug' | 'lang' | 'identity'>): string {
|
||||
const identity = tab.identity ?? tab.slug
|
||||
return versionHref(identity, { language: tab.lang, slug: tab.slug })
|
||||
/** The address a page of the book was read from: the version it is turned to. */
|
||||
export function tabHref(tab: Pick<ArticleTab, 'identity' | 'lang'>): string {
|
||||
return versionHref(tab.identity, tab.lang)
|
||||
}
|
||||
|
||||
/** Which page of the stack an address points at, if any. */
|
||||
/** Which page of the stack an address points at, if any. An address that
|
||||
* names no language points at the article's page however it is turned. */
|
||||
export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null {
|
||||
const loc = parseReaderPath(path)
|
||||
if (!loc) return null
|
||||
const exact = tabs.find((t) => {
|
||||
const identity = t.identity ?? t.slug
|
||||
if (loc.lang) return identity === loc.identity && (t.lang ?? 'en') === loc.lang
|
||||
return identity === loc.identity && t.slug === loc.identity
|
||||
})
|
||||
/* an address naming a document directly (older /slug-fr form) still finds it */
|
||||
return exact ?? tabs.find((t) => t.slug === loc.identity) ?? null
|
||||
/* one article is one page of the book, in whichever language that page is
|
||||
turned to — so the address names its page by the article alone */
|
||||
return tabs.find((t) => t.identity === loc.identity) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The session stack of articles opened in the reader — the pages of the
|
||||
* book, in the order they were opened, newest at the right. The page
|
||||
* book, in the order they were opened, newest at the right. One article is one
|
||||
* page however many languages it exists in, so turning an article to another
|
||||
* language turns that page over instead of adding another to the row. The page
|
||||
* currently in view is simply wherever the route points; the stack lives in
|
||||
* shared state and survives every client navigation (reset on reload), so the
|
||||
* open pages are still there when you come back to the reading surface.
|
||||
@@ -43,26 +44,56 @@ export function useArticleTabs() {
|
||||
/* the last page seen outside the reading surface, so closing the last tab
|
||||
returns there instead of always going home */
|
||||
const origin = useState<string>('article-tabs:origin', () => '')
|
||||
/* the page last in view, so the stack can be found again from any page that
|
||||
hides the reading surface — the editor, a new article, the graph */
|
||||
const lastSlug = useState<string>('article-tabs:last', () => '')
|
||||
/* the article last in view, so the stack can be found again from any page
|
||||
that hides the reading surface — the editor, a new article, the graph */
|
||||
const lastIdentity = useState<string>('article-tabs:last', () => '')
|
||||
|
||||
function ensureTab(
|
||||
slug: string,
|
||||
title: string,
|
||||
meta?: { lang?: string; identity?: string }
|
||||
) {
|
||||
const existing = tabs.value.find((t) => t.slug === slug)
|
||||
if (existing) {
|
||||
existing.title = title // follow the tab label after a rename
|
||||
if (meta?.lang) existing.lang = meta.lang
|
||||
if (meta?.identity) existing.identity = meta.identity
|
||||
/**
|
||||
* Lay one version on the book: a fresh page at the right of the row, or —
|
||||
* when the article already has a page open — that same page turned to this
|
||||
* language. Reading an article in French is not opening a second article,
|
||||
* so the page keeps its place in the row and only its text changes.
|
||||
*/
|
||||
function openVersion(article: {
|
||||
_id?: string
|
||||
slug: string
|
||||
title: string
|
||||
language?: string
|
||||
baseSlug?: string | null
|
||||
}) {
|
||||
const identity = identityOf(article)
|
||||
const lang = articleLanguage(article)
|
||||
/* the article's own page, or — failing that — the page holding this very
|
||||
document, which is how a retitle is recognised: the article moved to a
|
||||
new address and its page follows it rather than doubling up */
|
||||
const page = tabs.value.find(
|
||||
(t) => t.identity === identity || (!!article._id && t.docId === article._id)
|
||||
)
|
||||
if (page) {
|
||||
/* the page follows the version it holds: the article's identity, the
|
||||
document its language asks for, and the title that version carries (a
|
||||
save that retitled an article moves it and relabels the page; a
|
||||
language turn relabels it again and takes it to another document) */
|
||||
page.identity = identity
|
||||
page.docId = article._id
|
||||
page.slug = article.slug
|
||||
page.lang = lang
|
||||
page.title = article.title
|
||||
return
|
||||
}
|
||||
const stack = [...tabs.value, { slug, title, lang: meta?.lang, identity: meta?.identity }]
|
||||
const stack = [
|
||||
...tabs.value,
|
||||
{
|
||||
identity,
|
||||
lang,
|
||||
slug: article.slug,
|
||||
docId: article._id,
|
||||
title: article.title,
|
||||
},
|
||||
]
|
||||
if (stack.length > MAX_TABS) {
|
||||
/* don't stack forever — retire the oldest tab that isn't in view */
|
||||
const drop = stack.findIndex((t) => t.slug !== slug)
|
||||
/* don't stack forever — retire the oldest page that isn't this one */
|
||||
const drop = stack.findIndex((t) => t.identity !== identity)
|
||||
if (drop !== -1) stack.splice(drop, 1)
|
||||
}
|
||||
tabs.value = stack
|
||||
@@ -72,17 +103,17 @@ export function useArticleTabs() {
|
||||
stack and the rest settle; closing the page in view turns back to the
|
||||
page before it — or returns to the last page outside the book once the
|
||||
last page is gone. */
|
||||
function closeTab(slug: string) {
|
||||
const at = tabs.value.findIndex((t) => t.slug === slug)
|
||||
const isActive = tabAt(tabs.value, route.path)?.slug === slug
|
||||
tabs.value = tabs.value.filter((t) => t.slug !== slug)
|
||||
function closeTab(identity: string) {
|
||||
const at = tabs.value.findIndex((t) => t.identity === identity)
|
||||
const isActive = tabAt(tabs.value, route.path)?.identity === identity
|
||||
tabs.value = tabs.value.filter((t) => t.identity !== identity)
|
||||
if (isActive) {
|
||||
const back = tabs.value[Math.max(0, Math.min(at - 1, tabs.value.length - 1))]
|
||||
void router.push(back ? tabHref(back) : origin.value || '/')
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs, origin, lastSlug, ensureTab, closeTab }
|
||||
return { tabs, origin, lastIdentity, openVersion, closeTab }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,14 +125,15 @@ export function useArticleTabs() {
|
||||
*/
|
||||
export function useArticleTabsSync() {
|
||||
const route = useRoute()
|
||||
const { tabs, lastSlug, origin } = useArticleTabs()
|
||||
const { tabs, lastIdentity, origin } = useArticleTabs()
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(to) => {
|
||||
/* filed under the tab's own slug, so a language version is found again
|
||||
as the version it was, not merely as the article */
|
||||
if (isReaderPath(to)) lastSlug.value = tabAt(tabs.value, to)?.slug ?? wikiSlugFromPath(to) ?? ''
|
||||
/* filed under the article's identity, so a version turned to another
|
||||
language is still found as the same page of the book */
|
||||
if (isReaderPath(to))
|
||||
lastIdentity.value = tabAt(tabs.value, to)?.identity ?? wikiSlugFromPath(to) ?? ''
|
||||
else if (!isReadingSurface(to)) origin.value = to
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
39
frontend/app/composables/usePendingRequests.ts
Normal file
39
frontend/app/composables/usePendingRequests.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
export interface ViewedArticle {
|
||||
/** the article read, by its identity — the canonical version's slug. An
|
||||
* entry written before versions had addresses of their own may hold a
|
||||
* document's own slug, which the reader still resolves. */
|
||||
slug: string
|
||||
title: string
|
||||
/** the language it was last read in, so a jump returns to that version.
|
||||
* Absent from entries written when one address stood for the whole article */
|
||||
lang?: string
|
||||
viewedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'mathew-recently-viewed'
|
||||
const MAX_ENTRIES = 8
|
||||
|
||||
/** where a jump leads: the version the article was last read in, or — for an
|
||||
* entry that recorded no language — the short address, which the reader turns
|
||||
* onto whichever version the slug speaks */
|
||||
export function viewedHref(viewed: Pick<ViewedArticle, 'slug' | 'lang'>): string {
|
||||
return viewed.lang
|
||||
? versionHref(viewed.slug, viewed.lang)
|
||||
: `/wiki/${encodeURIComponent(viewed.slug)}`
|
||||
}
|
||||
|
||||
let loaded = false
|
||||
|
||||
function readStored(): ViewedArticle[] {
|
||||
@@ -36,14 +51,25 @@ export function useRecentlyViewed() {
|
||||
if (stored.length) viewed.value = stored
|
||||
})
|
||||
|
||||
function record(article: { slug: string; title: string }) {
|
||||
function record(article: {
|
||||
slug: string
|
||||
title: string
|
||||
baseSlug?: string | null
|
||||
language?: string
|
||||
}) {
|
||||
/* filed by the article's identity and turned to the language it was read
|
||||
in: an article read in two languages is still one article remembered
|
||||
once, at the version it was last opened at */
|
||||
const entry = {
|
||||
slug: identityOf(article),
|
||||
lang: articleLanguage(article),
|
||||
title: article.title,
|
||||
}
|
||||
/* merge with what's already saved so recording before the list has
|
||||
loaded (e.g. deep-linking straight to an article) can't clobber it */
|
||||
const base = viewed.value.length ? viewed.value : readStored()
|
||||
viewed.value = [
|
||||
{ ...article, viewedAt: Date.now() },
|
||||
...base.filter((v) => v.slug !== article.slug),
|
||||
].slice(0, MAX_ENTRIES)
|
||||
const fresh = { ...entry, viewedAt: Date.now() }
|
||||
viewed.value = [fresh, ...base.filter((v) => v.slug !== entry.slug)].slice(0, MAX_ENTRIES)
|
||||
loaded = true
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(viewed.value))
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/* Before a reader route draws anything — /wiki/<slug> for an article,
|
||||
/wiki/<lang>/<slug> for one of its language versions — put the document it
|
||||
names on the library's shelf. On the server that is what lets the page in
|
||||
view be written out in the first HTML; on the client it is what the stack of
|
||||
open pages grows from, and the title the page is filed under. A page that is
|
||||
already on the shelf comes straight off it and is re-read in the background
|
||||
— so an edit made elsewhere still shows up when you turn to it. */
|
||||
/* Before a reader route draws anything — /wiki/<lang>/<slug> for one language
|
||||
version of an article, /wiki/<slug> for whichever version a bare slug speaks
|
||||
— put the document it names on the library's shelf. On the server that is
|
||||
what lets the page in view be written out in the first HTML; on the client it
|
||||
is what the stack of open pages grows from, and the title the page is filed
|
||||
under. A page that is already on the shelf comes straight off it and is
|
||||
re-read in the background — so an edit made elsewhere still shows up when you
|
||||
turn to it.
|
||||
|
||||
/** How a document is filed in the tab stack: which article, which language. */
|
||||
function tabMeta(article: { slug: string; language?: string; baseSlug?: string | null }) {
|
||||
return { lang: article.language ?? 'en', identity: article.baseSlug || article.slug }
|
||||
}
|
||||
This is also the one place that knows where a version lives: an article's
|
||||
identity is the canonical version's slug, and a version's address is that
|
||||
identity with its own language in front. So a short /wiki/<slug> address — a
|
||||
wiki link, an old bookmark, a graph node — and a versioned one written with a
|
||||
translation's stored slug are both turned onto /wiki/<lang>/<identity> before
|
||||
anything renders. An address naming no language is turned onto the reader's
|
||||
own version when the article speaks it — English as the wiki's common tongue
|
||||
failing that — so opening an article opens it in the language you read the
|
||||
wiki in, and only then in whatever version the slug itself speaks. An address
|
||||
that does name a language is honoured as asked: that is how the version
|
||||
switcher keeps its promise. The address being left behind never reaches the
|
||||
history: the navigation to it is abandoned, not committed, so Back still
|
||||
leads out of the reader to the page the link was pressed on. The address that
|
||||
stays put is the one naming nothing: the route says for itself that the wiki
|
||||
has no such article, and offers to write it. */
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const loc = parseReaderPath(to.path)
|
||||
@@ -18,19 +30,34 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
/* taken before any await, while the app context is certainly at hand */
|
||||
const library = useArticleLibrary()
|
||||
const tabs = useArticleTabs()
|
||||
const { locale } = useAppLocale()
|
||||
|
||||
const onShelf = loc.lang
|
||||
? library.getVersion(loc.identity, loc.lang)
|
||||
: library.get(loc.identity)
|
||||
if (onShelf) {
|
||||
tabs.ensureTab(onShelf.slug, onShelf.title, tabMeta(onShelf))
|
||||
library.revalidate(loc.identity, loc.lang ?? undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const article = loc.lang
|
||||
const article =
|
||||
onShelf ??
|
||||
(loc.lang
|
||||
? await library.loadVersion(loc.identity, loc.lang)
|
||||
: await library.load(loc.identity)
|
||||
: await library.load(loc.identity))
|
||||
|
||||
if (article) tabs.ensureTab(article.slug, article.title, tabMeta(article))
|
||||
// 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 })
|
||||
|
||||
/* a page already open turns to this version instead of another being added
|
||||
beside it: the article keeps its place in the book */
|
||||
tabs.openVersion(article)
|
||||
if (onShelf) library.revalidate(identity, lang)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
/* The wiki's back office: every account it holds, in one list, together with
|
||||
what each of them may do. The sidebar offers the door to an admin alone;
|
||||
whoever else reaches this address is turned away before anything is asked —
|
||||
a member sees the same refusal whether or not the list would have answered. */
|
||||
a member sees the same refusal whether or not the list would have answered.
|
||||
The changes members propose are kept on their own page beside this one. */
|
||||
|
||||
import type { AuthRole } from '~/composables/useAuth'
|
||||
|
||||
@@ -182,9 +183,10 @@ useSeoMeta({
|
||||
<template v-else>
|
||||
<section class="hero">
|
||||
<h1 v-html="t('admin.hero')"></h1>
|
||||
<p class="sub">{{ t('admin.sub') }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Every account the wiki holds and what each may do. The requests
|
||||
members send live on their own page, reached from the sidebar. -->
|
||||
<section class="section">
|
||||
<div v-if="failure" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
231
frontend/app/pages/admin/proposals.vue
Normal file
231
frontend/app/pages/admin/proposals.vue
Normal 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>
|
||||
@@ -5,44 +5,103 @@ import type { ArticleMeta } from '~/components/ArticleCard.vue'
|
||||
each other the way the reader does (see main.scss) */
|
||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||
|
||||
/* One batch of a folder's shelf at a time: how many cards the first draw
|
||||
brings, and how far ahead of the row beneath the grid the next batch is
|
||||
asked for. */
|
||||
const PAGE_SIZE = 24
|
||||
const REACH = 600
|
||||
|
||||
interface ShelfResponse {
|
||||
articles: ArticleMeta[]
|
||||
/* how many articles answer the filter in force, not just this batch */
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
interface ShelfFacets {
|
||||
total: number
|
||||
topics: { name: string; count: number }[]
|
||||
languages: { code: string; count: number }[]
|
||||
}
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
|
||||
`${api}/articles`,
|
||||
{ default: () => [] }
|
||||
)
|
||||
|
||||
/* topics are the tags spread across the shelf — each one keeps the
|
||||
articles that wear it; the same tag written two ways counts as one,
|
||||
and an article counts once however many languages it wears the tag in */
|
||||
const topics = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const article of articles.value ?? []) {
|
||||
const seen = new Set<string>()
|
||||
for (const doc of [article, ...(article.versions ?? [])]) {
|
||||
for (const t of doc.tags ?? []) {
|
||||
const name = t.trim()
|
||||
if (!name) continue
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b))
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
})
|
||||
|
||||
/* picking a topic filters the article list below — the address bar keeps
|
||||
the tag so the filtered view can still be shared */
|
||||
const tag = ref(String(route.query.tag ?? ''))
|
||||
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
|
||||
|
||||
/* The shelf is filed by topic: the page shows one folder per topic, and a
|
||||
folder opens onto the articles wearing that tag. The tag in the address is
|
||||
which folder stands open — while the address carries none, the folders
|
||||
themselves answer. */
|
||||
const inFolder = computed(() => !!tag.value)
|
||||
|
||||
/* The filters are the API's to apply now that a folder's shelf arrives a
|
||||
batch at a time: the same query is what the first batch and the ones after
|
||||
it ask for. `prefer` is not a filter — it never decides which articles
|
||||
answer, only which language version of each stands on its card, the
|
||||
reader's own when the article speaks it (English failing that). */
|
||||
function shelfQuery(offset: number) {
|
||||
const query: Record<string, string | number> = { limit: PAGE_SIZE, offset }
|
||||
if (tag.value) query.tag = tag.value
|
||||
if (lang.value) query.lang = lang.value
|
||||
query.prefer = locale.value
|
||||
return query
|
||||
}
|
||||
|
||||
/* How the shelf is made up — its topics, its languages, how many articles it
|
||||
holds — is counted over every article rather than over the batches on
|
||||
screen, so those counts stay true while the cards are still arriving. The
|
||||
folders themselves are drawn from this tally. */
|
||||
const { data: facets, status: facetsStatus, error: facetsError } = await useFetch<ShelfFacets>(
|
||||
`${api}/articles/facets`,
|
||||
{ default: () => ({ total: 0, topics: [], languages: [] }) }
|
||||
)
|
||||
|
||||
/* An open folder's first batch comes down with the route, so its cards are in
|
||||
the HTML the server sends. A changed filter asks for a new first batch. */
|
||||
const { data: shelf, status, error } = await useFetch<ShelfResponse>(`${api}/articles`, {
|
||||
query: computed(() => shelfQuery(0)),
|
||||
default: () => ({ articles: [], total: 0, hasMore: false }),
|
||||
})
|
||||
|
||||
/* The batches after the first, as the reader reaches the bottom of what is
|
||||
already on the paper. */
|
||||
const laterBatches = ref<ArticleMeta[]>([])
|
||||
/* what those batches brought over, twins included — how far down the API's
|
||||
shelf we have read, which is where the next batch starts */
|
||||
let readBeyond = 0
|
||||
const loadingMore = ref(false)
|
||||
/* an answer with nothing left in it: the shelf is empty from here on */
|
||||
const exhausted = ref(false)
|
||||
|
||||
const articles = computed(() => [...(shelf.value?.articles ?? []), ...laterBatches.value])
|
||||
/* how many articles the open folder answers to, and how many the whole
|
||||
shelf holds whatever the folder says */
|
||||
const matches = computed(() => shelf.value?.total ?? 0)
|
||||
const shelfSize = computed(() => facets.value?.total || matches.value)
|
||||
const hasMore = computed(() => !exhausted.value && (shelf.value?.hasMore ?? false))
|
||||
|
||||
/* the folders are real links and the Back key is a reader too: whatever the
|
||||
address says is what the refs have to answer to */
|
||||
watch(
|
||||
() => route.query,
|
||||
(q) => {
|
||||
tag.value = String(q.tag ?? '')
|
||||
lang.value = String(q.lang ?? '').trim().toLowerCase()
|
||||
}
|
||||
)
|
||||
|
||||
/* picking a folder opens it — the address bar keeps the tag so the open
|
||||
folder can still be shared; closing it back to the folders rewrites the
|
||||
address without one. Either way what was read for the old view is no use */
|
||||
watch([tag, lang], ([tv, lv]) => {
|
||||
laterBatches.value = []
|
||||
readBeyond = 0
|
||||
exhausted.value = false
|
||||
|
||||
const query: Record<string, string> = {}
|
||||
if (tv) query.tag = tv
|
||||
if (lv) query.lang = lv
|
||||
@@ -51,92 +110,96 @@ watch([tag, lang], ([tv, lv]) => {
|
||||
router.replace({ query })
|
||||
})
|
||||
|
||||
/* every language the shelf is written in, with counts — an article counts
|
||||
in each language it exists in, and only once per language */
|
||||
function languagesOf(article: ArticleMeta): string[] {
|
||||
const codes = new Set<string>([articleLanguage(article)])
|
||||
for (const v of article.versions ?? []) codes.add(v.language || 'en')
|
||||
return [...codes]
|
||||
/* every topic the shelf is filed under, with how many articles it holds, and
|
||||
every language it is written in — both counted per article, not per
|
||||
version */
|
||||
const topics = computed(() => facets.value?.topics ?? [])
|
||||
|
||||
const languages = computed(() =>
|
||||
[...(facets.value?.languages ?? [])]
|
||||
.map((l) => ({ ...l, name: languageName(l.code) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
|
||||
/* a folder's address: the tag it opens for, keeping a language filter the
|
||||
reader already holds so it rides along into the folder */
|
||||
function folderHref(topic: string) {
|
||||
const query: Record<string, string> = { tag: topic }
|
||||
if (lang.value) query.lang = lang.value
|
||||
return { query }
|
||||
}
|
||||
|
||||
const languages = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const article of articles.value ?? []) {
|
||||
for (const code of languagesOf(article)) counts.set(code, (counts.get(code) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort(([a], [b]) => languageName(a).localeCompare(languageName(b)))
|
||||
.map(([code, count]) => ({ code, name: languageName(code), count }))
|
||||
})
|
||||
/* ---------- an open folder's shelf, a batch at a time ---------- */
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
const list = (articles.value ?? []).filter((a) => {
|
||||
if (
|
||||
tag.value &&
|
||||
!([a, ...(a.versions ?? [])] as ArticleMeta[]).some((d) =>
|
||||
(d.tags ?? []).some((t) => t.trim().toLowerCase() === tag.value.toLowerCase())
|
||||
)
|
||||
)
|
||||
return false
|
||||
if (lang.value && !languagesOf(a).includes(lang.value)) return false
|
||||
return true
|
||||
/* Does this browser say when the row under the grid arrives, or does the
|
||||
shelf have to be emptied out in one go? */
|
||||
const observesScroll = typeof IntersectionObserver === 'function'
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let scrollWatch: IntersectionObserver | null = null
|
||||
|
||||
/* how far down the API's shelf the batches read so far go */
|
||||
function nextOffset() {
|
||||
return (shelf.value?.articles.length ?? 0) + readBeyond
|
||||
}
|
||||
|
||||
function sentinelInReach() {
|
||||
const el = sentinel.value
|
||||
return !!el && el.getBoundingClientRect().top <= window.innerHeight + REACH
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
/* only an open folder pages in: the folder grid arrives whole from the
|
||||
facet tally, and a closed folder has no shelf to read on */
|
||||
if (!inFolder.value || loadingMore.value || status.value === 'pending' || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
/* Pull while the row is in reach — with new cards in place before each
|
||||
round, too: a shelf short enough to fit on one screen never scrolls, so
|
||||
it has to fill itself out. */
|
||||
do {
|
||||
const batch = await $fetch<ShelfResponse>(`${api}/articles`, {
|
||||
query: shelfQuery(nextOffset()),
|
||||
})
|
||||
/* in a language view each article is shown as the version written —
|
||||
or rather translated — in that language */
|
||||
if (!lang.value) return list
|
||||
return list.map((a) => {
|
||||
const v = (a.versions ?? []).find((x) => (x.language || 'en') === lang.value)
|
||||
return v ? { ...a, ...v, versions: a.versions } : a
|
||||
})
|
||||
})
|
||||
|
||||
/* the search box doubles as a topic compass: the shelf's topics drop
|
||||
down while you search, and picking one filters the list below */
|
||||
const topicQuery = ref('')
|
||||
const dropdownOpen = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
const searchWrap = ref<HTMLElement | null>(null)
|
||||
|
||||
const visibleTopics = computed(() => {
|
||||
const needle = topicQuery.value.trim().toLowerCase()
|
||||
if (!needle) return topics.value
|
||||
return topics.value.filter((t) => t.name.toLowerCase().includes(needle))
|
||||
})
|
||||
|
||||
watch(topicQuery, () => (activeIndex.value = -1))
|
||||
|
||||
function pickTopic(topic: { name: string }) {
|
||||
tag.value = topic.name
|
||||
topicQuery.value = ''
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (!dropdownOpen.value) {
|
||||
if (e.key === 'ArrowDown') dropdownOpen.value = true
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') dropdownOpen.value = false
|
||||
else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, visibleTopics.value.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const topic = visibleTopics.value[activeIndex.value] ?? visibleTopics.value[0]
|
||||
if (topic) pickTopic(topic)
|
||||
readBeyond += batch.articles.length
|
||||
if (!batch.articles.length || !batch.hasMore) exhausted.value = true
|
||||
/* an article edited between two batches can be handed out twice: the
|
||||
folder shows each card once */
|
||||
const held = new Set(articles.value.map((a) => a._id))
|
||||
laterBatches.value.push(...batch.articles.filter((a) => !held.has(a._id)))
|
||||
await nextTick()
|
||||
} while (hasMore.value && (!observesScroll || sentinelInReach()))
|
||||
} catch {
|
||||
/* the row waits where it is: reaching it again asks once more */
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* a click anywhere outside the box closes the dropdown */
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (dropdownOpen.value && !searchWrap.value?.contains(e.target as Node))
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||
onMounted(() => {
|
||||
if (!observesScroll) return void loadMore()
|
||||
scrollWatch = new IntersectionObserver(
|
||||
(seen) => {
|
||||
if (seen.some((entry) => entry.isIntersecting)) void loadMore()
|
||||
},
|
||||
{ rootMargin: `${REACH}px 0px` }
|
||||
)
|
||||
if (sentinel.value) scrollWatch.observe(sentinel.value)
|
||||
})
|
||||
onBeforeUnmount(() => scrollWatch?.disconnect())
|
||||
|
||||
/* opening a folder is what puts its row under the grid: watch for that row
|
||||
arriving, so reaching it asks for the rest without a scroll first */
|
||||
watch(sentinel, (el) => {
|
||||
if (el) scrollWatch?.observe(el)
|
||||
})
|
||||
|
||||
/* a folder's first batch having landed, a reader still standing at the bottom
|
||||
of it is given the next */
|
||||
watch(shelf, async () => {
|
||||
await nextTick()
|
||||
void loadMore()
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('articles.metaTitle'),
|
||||
@@ -154,62 +217,20 @@ useSeoMeta({
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div v-if="error" class="banner">
|
||||
<div v-if="error || facetsError" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span v-html="t('common.apiDown')"></span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="status === 'pending'">{{ t('common.loading') }}</template>
|
||||
<template v-else-if="tag || lang">
|
||||
{{ t('articles.countOfTotal', { shown: filteredArticles.length, total: articles.length }) }}
|
||||
</template>
|
||||
<template v-else>{{ t('articles.articleCount', articles.length) }}</template>
|
||||
</h2>
|
||||
<span v-if="status !== 'pending' && topics.length" class="topic-tally">
|
||||
{{ t('articles.topicCount', topics.length) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="topics.length" ref="searchWrap" class="topic-search-wrap">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.2-3.2" />
|
||||
<!-- an open folder: the articles wearing its topic -->
|
||||
<template v-else-if="inFolder">
|
||||
<NuxtLink :to="{ query: {} }" replace class="back-link">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 12H5" />
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="topicQuery"
|
||||
class="topic-search"
|
||||
type="text"
|
||||
role="combobox"
|
||||
:aria-expanded="String(dropdownOpen)"
|
||||
aria-controls="topic-dropdown"
|
||||
aria-autocomplete="list"
|
||||
:placeholder="t('articles.topicSearchPlaceholder')"
|
||||
:aria-label="t('articles.topicSearchAria')"
|
||||
@focus="dropdownOpen = true"
|
||||
@keydown="onInputKeydown"
|
||||
/>
|
||||
<div v-if="dropdownOpen" id="topic-dropdown" class="topic-dropdown" role="listbox">
|
||||
<button
|
||||
v-for="(topic, i) in visibleTopics"
|
||||
:key="topic.name"
|
||||
type="button"
|
||||
class="topic-option"
|
||||
:class="{ active: i === activeIndex }"
|
||||
role="option"
|
||||
:aria-selected="i === activeIndex"
|
||||
@click="pickTopic(topic)"
|
||||
>
|
||||
<span>{{ topic.name }}</span>
|
||||
<span class="topic-count">{{ topic.count }}</span>
|
||||
</button>
|
||||
<p v-if="!visibleTopics.length" class="topic-dropdown-none">
|
||||
{{ t('articles.noTopic', { q: topicQuery.trim() }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ t('articles.backToTopics') }}
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="languages.length > 1" class="lang-row" role="group" :aria-label="t('articles.filterByLanguage')">
|
||||
<span class="lang-row-label">{{ t('articles.writtenIn') }}</span>
|
||||
@@ -229,20 +250,11 @@ useSeoMeta({
|
||||
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="tag && lang">{{ t('articles.headingTagLang', { tag, lang: languageName(lang) }) }}</template>
|
||||
<template v-else-if="tag">{{ t('articles.headingTag', { tag }) }}</template>
|
||||
<template v-else-if="lang">{{ t('articles.headingLang', { lang: languageName(lang) }) }}</template>
|
||||
<template v-else>{{ t('articles.headingAll') }}</template>
|
||||
<template v-if="status === 'pending'">{{ t('common.loading') }}</template>
|
||||
<template v-else-if="lang">{{ t('articles.headingTagLang', { tag, lang: languageName(lang) }) }}</template>
|
||||
<template v-else>{{ t('articles.headingTag', { tag }) }}</template>
|
||||
</h2>
|
||||
<span v-if="tag" class="filter-chip">
|
||||
{{ t('common.tagChip', { tag }) }}
|
||||
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="lang" class="filter-chip">
|
||||
<span v-if="status !== 'pending' && lang" class="filter-chip">
|
||||
{{ t('common.languageChip', { lang: languageName(lang) }) }}
|
||||
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
@@ -256,28 +268,66 @@ useSeoMeta({
|
||||
<div v-for="i in 6" :key="i" class="skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredArticles.length" class="articles-grid">
|
||||
<ArticleCard v-for="a in filteredArticles" :key="a._id" :article="a" />
|
||||
<div v-else-if="articles.length" class="articles-grid">
|
||||
<ArticleCard v-for="a in articles" :key="a._id" :article="a" />
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="glyph">∅</div>
|
||||
<template v-if="articles.length">
|
||||
<h3>
|
||||
{{ tag
|
||||
? t('articles.emptyNoTopicTitle')
|
||||
: lang
|
||||
{{ lang
|
||||
? t('articles.emptyNoLangTitle', { lang: languageName(lang) })
|
||||
: t('articles.emptyNoTopicsTitle') }}
|
||||
: t('articles.emptyNoTopicTitle') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ tag || lang
|
||||
? t('articles.emptyFilteredText')
|
||||
: t('articles.emptyUntaggedText') }}
|
||||
</p>
|
||||
<button v-if="tag || lang" type="button" class="btn btn-ghost" @click="tag = ''; lang = ''">
|
||||
<p>{{ t('articles.emptyFilteredText') }}</p>
|
||||
<button v-if="lang" type="button" class="btn btn-ghost" @click="lang = ''">
|
||||
{{ t('articles.emptyShowAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="shelf-sentinel" role="status">
|
||||
<span v-if="loadingMore">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- the shelf closed: one folder per topic -->
|
||||
<template v-else>
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="facetsStatus === 'pending'">{{ t('common.loading') }}</template>
|
||||
<template v-else>{{ t('articles.articleCount', shelfSize) }}</template>
|
||||
</h2>
|
||||
<span v-if="facetsStatus !== 'pending' && topics.length" class="topic-tally">
|
||||
{{ t('articles.topicCount', topics.length) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="facetsStatus === 'pending'" class="folder-grid">
|
||||
<div v-for="i in 8" :key="i" class="skeleton folder-skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="topics.length" class="folder-grid">
|
||||
<NuxtLink
|
||||
v-for="topic in topics"
|
||||
:key="topic.name"
|
||||
:to="folderHref(topic.name)"
|
||||
class="folder-card"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
</svg>
|
||||
<span class="folder-name">{{ topic.name }}</span>
|
||||
<span class="topic-count folder-count" :title="t('articles.articleCount', topic.count)">
|
||||
{{ topic.count }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="glyph">∅</div>
|
||||
<template v-if="shelfSize">
|
||||
<h3>{{ t('articles.emptyNoTopicsTitle') }}</h3>
|
||||
<p>{{ t('articles.emptyUntaggedText') }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3>{{ t('articles.emptyNoneTitle') }}</h3>
|
||||
|
||||
@@ -9,41 +9,12 @@ const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { show: openSearch } = useSearch()
|
||||
|
||||
const q = ref(String(route.query.q ?? ''))
|
||||
const tag = ref(String(route.query.tag ?? ''))
|
||||
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
|
||||
const langLabel = computed(() => (lang.value ? languageName(lang.value) : ''))
|
||||
|
||||
/* debounce the search text so we don't fetch on every keystroke */
|
||||
const debouncedQ = ref(q.value)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(q, (v) => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => (debouncedQ.value = v.trim()), 250)
|
||||
})
|
||||
|
||||
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
|
||||
`${api}/articles`,
|
||||
{ query: { q: debouncedQ, tag, lang }, default: () => [] }
|
||||
)
|
||||
|
||||
/* keep the address bar in sync with the search */
|
||||
watch([debouncedQ, tag, lang], ([qv, tv, lv]) => {
|
||||
const query: Record<string, string> = {}
|
||||
if (qv) query.q = qv
|
||||
if (tv) query.tag = tv
|
||||
if (lv) query.lang = lv
|
||||
const current = route.query
|
||||
const same
|
||||
= (current.q ?? '') === (query.q ?? '') && (current.tag ?? '') === (query.tag ?? '')
|
||||
&& (current.lang ?? '') === (query.lang ?? '')
|
||||
if (!same) router.replace({ query })
|
||||
})
|
||||
|
||||
function selectTag(t: string) {
|
||||
tag.value = tag.value === t ? '' : t
|
||||
}
|
||||
/* the bar opens the Ctrl/⌘+K popup rather than searching on the page itself,
|
||||
but it still reads back the query the address carries — the popup's
|
||||
"all results" link lands here with one */
|
||||
const askedQuery = computed(() => String(route.query.q ?? ''))
|
||||
|
||||
/* fetch one article from the API and open it */
|
||||
const randomPending = ref(false)
|
||||
@@ -52,7 +23,14 @@ async function showRandomArticle() {
|
||||
randomPending.value = true
|
||||
try {
|
||||
const article = await $fetch<ArticleMeta>(`${api}/articles/random`)
|
||||
await router.push(versionHref(identityOf(article), article))
|
||||
/* the canonical slug is the article's address, and the middleware there
|
||||
turns it onto the reader's language; an orphaned translation answers to
|
||||
no such address, so that one still goes straight to its own version */
|
||||
await router.push(
|
||||
article.baseSlug
|
||||
? versionHref(identityOf(article), articleLanguage(article))
|
||||
: `/wiki/${encodeURIComponent(article.slug)}`
|
||||
)
|
||||
} catch {
|
||||
// no articles yet or API unreachable — the banner below already covers API issues
|
||||
} finally {
|
||||
@@ -60,38 +38,14 @@ async function showRandomArticle() {
|
||||
}
|
||||
}
|
||||
|
||||
/* "/" focuses the search box */
|
||||
const searchEl = ref<HTMLInputElement | null>(null)
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
|
||||
if (e.key === '/' && !typing) {
|
||||
e.preventDefault()
|
||||
searchEl.value?.focus()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('home.metaTitle'),
|
||||
description: () => t('home.metaDescription'),
|
||||
})
|
||||
|
||||
/* which empty-shelf line to say, depending on the filters in force */
|
||||
const emptyKey = computed(() =>
|
||||
tag.value && lang.value
|
||||
? 'home.emptyTagLang'
|
||||
: tag.value
|
||||
? 'home.emptyTag'
|
||||
: lang.value
|
||||
? 'home.emptyLang'
|
||||
: 'home.emptyPlain'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-main">
|
||||
<div class="home-main is-home">
|
||||
<section class="hero">
|
||||
<h1 v-html="t('home.hero')"></h1>
|
||||
|
||||
@@ -100,14 +54,14 @@ const emptyKey = computed(() =>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.2-3.2" />
|
||||
</svg>
|
||||
<input
|
||||
ref="searchEl"
|
||||
v-model="q"
|
||||
class="search-input"
|
||||
type="text"
|
||||
:placeholder="t('home.searchPlaceholder')"
|
||||
<button
|
||||
type="button"
|
||||
class="search-input search-trigger"
|
||||
:aria-label="t('common.searchArticles')"
|
||||
/>
|
||||
@click="openSearch()"
|
||||
>
|
||||
{{ askedQuery || t('home.searchPlaceholder') }}
|
||||
</button>
|
||||
<span class="search-kbd">/</span>
|
||||
</div>
|
||||
|
||||
@@ -123,52 +77,5 @@ const emptyKey = computed(() =>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="debouncedQ || tag || lang">
|
||||
{{ t('home.results', articles.length) }}
|
||||
</template>
|
||||
<template v-else>{{ t('home.recentlyUpdated') }}</template>
|
||||
</h2>
|
||||
<span v-if="tag" class="filter-chip">
|
||||
{{ t('common.tagChip', { tag }) }}
|
||||
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="lang" class="filter-chip">
|
||||
{{ t('common.languageChip', { lang: langLabel }) }}
|
||||
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span v-html="t('common.apiDown')"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'pending'" class="articles-grid">
|
||||
<div v-for="i in 6" :key="i" class="skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="articles.length" class="articles-grid">
|
||||
<ArticleCard v-for="a in articles" :key="a._id" :article="a" />
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="glyph">∅</div>
|
||||
<h3>{{ t('home.emptyTitle') }}</h3>
|
||||
<p>{{ t(emptyKey, { tag, lang: langLabel }) }}</p>
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,7 @@ const { record } = useRecentlyViewed()
|
||||
watch(
|
||||
article,
|
||||
(a) => {
|
||||
if (import.meta.client && a?.slug && a?.title) record({ slug: a.slug, title: a.title })
|
||||
if (import.meta.client && a?.slug && a?.title) record(a)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -34,6 +34,14 @@ useSeoMeta({
|
||||
const missingStatus = computed(
|
||||
() => (article.value ? 0 : unreadable.value[versionKey(identity.value, lang.value)] ?? 0)
|
||||
)
|
||||
|
||||
/* this version was never laid on the book, so "back" leads to whatever else is
|
||||
open: the last of the pages still in the stack, if there are any. */
|
||||
const { tabs } = useArticleTabs()
|
||||
const backHref = computed(() => {
|
||||
const last = tabs.value[tabs.value.length - 1]
|
||||
return last ? tabHref(last) : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,7 +52,10 @@ const missingStatus = computed(
|
||||
<div class="glyph">∄</div>
|
||||
<h3>{{ t('reader.noVersionTitle', { lang: languageName(lang) }) }}</h3>
|
||||
<p v-html="th('reader.noVersionText', { path: `/wiki/${lang}/${identity}` })"></p>
|
||||
<div class="empty-actions">
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
|
||||
<NuxtLink v-if="backHref" :to="backHref" class="btn btn-ghost">{{ t('reader.backToArticles') }}</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="glyph">!</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
/* The article route is a thin host for the book that ArticleBookStack draws.
|
||||
The open-article middleware has already laid the article on the library's
|
||||
shelf — that is what the page in view is made of, and what the stack of
|
||||
open pages is filed under — so all that is left to do here is speak for the
|
||||
URL: the title, and the visitor's "recently viewed" line.
|
||||
When nothing lives at the address, this is what says so, over the top of
|
||||
whatever else is open. */
|
||||
/* The short address — /wiki/<slug>, the shape a wiki link to [[Bayes Theorem]]
|
||||
writes and an old bookmark holds — names no language. The open-article
|
||||
middleware reads which document the slug speaks and turns the address onto
|
||||
that version's own, /wiki/en/bayes-theorem, so this page is what is left of
|
||||
it: the words said when the wiki has nothing at the slug, or the API has no
|
||||
answer about it — an address worth writing, over the top of whatever else is
|
||||
open. */
|
||||
definePageMeta({ middleware: 'open-article' })
|
||||
|
||||
const route = useRoute()
|
||||
@@ -14,34 +14,33 @@ const slug = computed(() => String(route.params.slug))
|
||||
const { t } = useI18n()
|
||||
const { th } = useAppLocale()
|
||||
const { get, unreadable } = useArticleLibrary()
|
||||
const article = computed(() => get(slug.value))
|
||||
|
||||
const { record } = useRecentlyViewed()
|
||||
watch(
|
||||
article,
|
||||
(a) => {
|
||||
if (import.meta.client && a?.slug && a?.title) record({ slug: a.slug, title: a.title })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => article.value?.title ?? t('card.kickerArticle'),
|
||||
})
|
||||
|
||||
/* what the shelf could not bring in, the API said why */
|
||||
const missingStatus = computed(() => (article.value ? 0 : unreadable.value[slug.value] ?? 0))
|
||||
const missingStatus = computed(() => (get(slug.value) ? 0 : unreadable.value[slug.value] ?? 0))
|
||||
|
||||
useSeoMeta({ title: () => t('card.kickerArticle') })
|
||||
|
||||
/* a missing address was never laid on the book, so "back" leads to whatever
|
||||
else is open: the last of the pages still in the stack, if there are any. */
|
||||
const { tabs } = useArticleTabs()
|
||||
const backHref = computed(() => {
|
||||
const last = tabs.value[tabs.value.length - 1]
|
||||
return last ? tabHref(last) : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reader-host">
|
||||
<div v-if="!article" class="reader-note">
|
||||
<div class="reader-note">
|
||||
<div class="empty" style="padding-top: 64px">
|
||||
<template v-if="missingStatus === 404">
|
||||
<div class="glyph">∄</div>
|
||||
<h3>{{ t('reader.notFoundTitle') }}</h3>
|
||||
<p v-html="th('reader.notFoundText', { path: `/wiki/${slug}` })"></p>
|
||||
<div class="empty-actions">
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
|
||||
<NuxtLink v-if="backHref" :to="backHref" class="btn btn-ghost">{{ t('reader.backToArticles') }}</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="glyph">!</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
on the server as much as in the browser. So a stored session is checked here,
|
||||
once, before any page runs. With no cookie in the request it costs nothing. */
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const appLocale = useAppLocale()
|
||||
const { user, restoreSession } = useAuth()
|
||||
await restoreSession()
|
||||
|
||||
@@ -11,5 +12,5 @@ export default defineNuxtPlugin(async () => {
|
||||
follows the person — so the very first render speaks it. (setLocale keeps
|
||||
the detection cookie in step, so the next load starts there even before
|
||||
this runs.) */
|
||||
if (user.value?.locale) await useAppLocale().applyAccount(user.value.locale)
|
||||
if (user.value?.locale) await appLocale.applyAccount(user.value.locale)
|
||||
})
|
||||
|
||||
@@ -35,6 +35,22 @@ export function languageName(code: string): string {
|
||||
return LANGUAGES.find((l) => l.code === code)?.name ?? code.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Which language version to open an article in when the address asks for
|
||||
* none: the reader's own language when the article speaks it, else English
|
||||
* as the wiki's common tongue, else null — nothing to pick, so whatever
|
||||
* version already stands for the article answers.
|
||||
*/
|
||||
export function preferredLanguage(
|
||||
available: readonly (string | undefined)[],
|
||||
reader: string
|
||||
): string | null {
|
||||
const codes = new Set(available.map((c) => (c ?? '').trim().toLowerCase()))
|
||||
const wanted = reader.trim().toLowerCase()
|
||||
if (codes.has(wanted)) return wanted
|
||||
return codes.has(DEFAULT_LANGUAGE) ? DEFAULT_LANGUAGE : null
|
||||
}
|
||||
|
||||
/** The first language no version of an article has been written in yet. */
|
||||
export function nextFreeLanguage(taken: readonly string[]): string {
|
||||
const takenCodes = taken.map((c) => c.trim().toLowerCase())
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
An article route is a page of the open book drawn by ArticleBookStack —
|
||||
pages laid out side by side, older ones parked with their spines showing —
|
||||
while the article edit page and the new-article page remain plain,
|
||||
full-height pages. An article lives at /wiki/<slug>; one of its language
|
||||
versions at /wiki/<lang>/<slug>, e.g. /wiki/fr/bayes-theorem. */
|
||||
full-height pages. Every language version of an article has an address of
|
||||
its own — /wiki/<lang>/<identity>, English no different from the rest, so
|
||||
/wiki/en/bayes-theorem and /wiki/fr/bayes-theorem sit side by side. The one
|
||||
short form, /wiki/<slug> (what a wiki link to [[Bayes Theorem]] writes, and
|
||||
what an old bookmark holds), names no language: the open-article middleware
|
||||
reads which document the slug speaks and turns the address into that
|
||||
version's own. */
|
||||
|
||||
const READER_PATH = /^\/wiki\/([^/]+?)\/?$/
|
||||
const VERSION_READER_PATH = /^\/wiki\/([a-z]{2})\/([^/]+?)\/?$/
|
||||
const WIKI_SUB_PATH = /^\/wiki\/[^/]+\/[^/]+/
|
||||
|
||||
/** What a reader address names: the article's identity, and which language
|
||||
* version the address asks for (null for the article's own version). */
|
||||
* version the address asks for (null for the short form, which asks for
|
||||
* whichever version the slug itself speaks). */
|
||||
export interface ReaderLocation {
|
||||
identity: string
|
||||
lang: string | null
|
||||
@@ -37,12 +43,15 @@ export function wikiSlugFromPath(path: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The address of one language version: the canonical document lives at the
|
||||
* article's own URL, translations at /wiki/<lang>/<identity>.
|
||||
* The address one language version lives at: /wiki/<lang>/<identity>, the
|
||||
* article's identity being the canonical version's slug — so every version of
|
||||
* one article shares the last part of its address and differs only by the
|
||||
* language in front. Documents carry their own stored slug, which no address
|
||||
* needs: the wiki finds a version by (identity, language).
|
||||
*/
|
||||
export function versionHref(identity: string, version: { language?: string; slug: string }): string {
|
||||
if (version.slug === identity) return `/wiki/${encodeURIComponent(identity)}`
|
||||
return `/wiki/${encodeURIComponent(version.language ?? 'en')}/${encodeURIComponent(identity)}`
|
||||
export function versionHref(identity: string, language?: string): string {
|
||||
const code = language?.trim().toLowerCase() || DEFAULT_LANGUAGE
|
||||
return `/wiki/${encodeURIComponent(code)}/${encodeURIComponent(identity)}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Tanca",
|
||||
"done": "Fet",
|
||||
"cancel": "Cancel·la",
|
||||
"confirm": "Confirma",
|
||||
"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.",
|
||||
"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}",
|
||||
"tagChip": "etiqueta: {tag}",
|
||||
"languageChip": "idioma: {lang}",
|
||||
"clearTag": "Treu el filtre d'etiqueta",
|
||||
"clearLanguage": "Treu el filtre d'idioma"
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -34,6 +33,9 @@
|
||||
"graph": "Graf",
|
||||
"admin": "Admin",
|
||||
"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",
|
||||
"signedInAs": "Sessió de {name} — perfil i 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.",
|
||||
"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.",
|
||||
"countOfTotal": "{shown} de {total} articles",
|
||||
"backToTopics": "Torna als temes",
|
||||
"articleCount": "{n} article | {n} articles",
|
||||
"topicCount": "{n} tema | {n} temes",
|
||||
"topicSearchPlaceholder": "Cerca temes — prova «calculus», «geometry»…",
|
||||
"topicSearchAria": "Cerca temes",
|
||||
"noTopic": "Cap tema coincideix amb «{q}».",
|
||||
"filterByLanguage": "Filtra per idioma",
|
||||
"writtenIn": "Escrits en",
|
||||
"headingTagLang": "Articles amb l'etiqueta «{tag}» · en {lang}",
|
||||
"headingTag": "Articles amb l'etiqueta «{tag}»",
|
||||
"headingLang": "Articles en {lang}",
|
||||
"headingAll": "Tots els articles",
|
||||
"emptyNoTopicTitle": "No hi ha articles amb aquest tema",
|
||||
"emptyNoLangTitle": "No hi ha articles en {lang}",
|
||||
"emptyNoTopicsTitle": "Encara no hi ha temes",
|
||||
@@ -84,7 +81,7 @@
|
||||
},
|
||||
"admin": {
|
||||
"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.",
|
||||
"roleChangeFailed": "No s'ha pogut canviar aquest rol.",
|
||||
"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.",
|
||||
"backToArticles": "Torna als articles",
|
||||
"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",
|
||||
"adminTally": "{n} admin | {n} admins",
|
||||
"memberTally": "{n} membre | {n} membres",
|
||||
@@ -108,7 +104,47 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"metaTitle": "Graf",
|
||||
@@ -124,6 +160,7 @@
|
||||
"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.",
|
||||
"writeTopic": "Escriure aquest tema",
|
||||
"backToArticles": "Torna als articles oberts",
|
||||
"loadFailTitle": "No s'ha pogut carregar l'article",
|
||||
"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ó."
|
||||
@@ -190,7 +227,13 @@
|
||||
"sessionExpired": "La sessió ha caducat — inicia-la de nou.",
|
||||
"saveFailed": "No s'ha pogut desar l'article. Està en marxa el backend?",
|
||||
"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": {
|
||||
"title": "Full d'ajuda",
|
||||
@@ -240,10 +283,7 @@
|
||||
"accentForest": "bosc",
|
||||
"accentAmber": "ambre",
|
||||
"accentRose": "rosa",
|
||||
"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"
|
||||
"accentSlate": "pisarra"
|
||||
},
|
||||
"auth": {
|
||||
"signin": "Iniciar sessió",
|
||||
@@ -294,7 +334,10 @@
|
||||
"newPassword": "Contrasenya nova",
|
||||
"changePassword": "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": {
|
||||
"placeholder": "Cerca articles per títol o etiqueta…",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Schließen",
|
||||
"done": "Fertig",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"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.",
|
||||
"apiShort": "Die API war nicht zu erreichen. Starte das Backend mit <code>npm run dev</code> in <code>backend/</code>.",
|
||||
"writtenIn": "Geschrieben auf {lang}",
|
||||
"tagChip": "Stichwort: {tag}",
|
||||
"languageChip": "Sprache: {lang}",
|
||||
"clearTag": "Stichwortfilter entfernen",
|
||||
"clearLanguage": "Sprachfilter entfernen"
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -34,6 +33,9 @@
|
||||
"graph": "Graph",
|
||||
"admin": "Admin",
|
||||
"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",
|
||||
"signedInAs": "Angemeldet als {name} — Profil und 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.",
|
||||
"hero": "Alle <span class=\"gradient\">Artikel</span>",
|
||||
"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",
|
||||
"topicCount": "{n} Thema | {n} Themen",
|
||||
"topicSearchPlaceholder": "Themen durchsuchen — versuch „calculus“, „geometry“…",
|
||||
"topicSearchAria": "Themen durchsuchen",
|
||||
"noTopic": "Kein Thema passt zu „{q}“.",
|
||||
"filterByLanguage": "Nach Sprache filtern",
|
||||
"writtenIn": "Geschrieben auf",
|
||||
"headingTagLang": "Artikel mit dem Stichwort „{tag}“ · auf {lang}",
|
||||
"headingTag": "Artikel mit dem Stichwort „{tag}“",
|
||||
"headingLang": "Artikel auf {lang}",
|
||||
"headingAll": "Alle Artikel",
|
||||
"emptyNoTopicTitle": "Keine Artikel zu diesem Thema",
|
||||
"emptyNoLangTitle": "Keine Artikel auf {lang}",
|
||||
"emptyNoTopicsTitle": "Noch keine Themen",
|
||||
@@ -84,7 +81,7 @@
|
||||
},
|
||||
"admin": {
|
||||
"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.",
|
||||
"roleChangeFailed": "Diese Rolle konnte nicht geändert 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.",
|
||||
"backToArticles": "Zurück zu den Artikeln",
|
||||
"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",
|
||||
"adminTally": "{n} Admin | {n} Admins",
|
||||
"memberTally": "{n} Mitglied | {n} Mitglieder",
|
||||
@@ -108,7 +104,47 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"metaTitle": "Graph",
|
||||
@@ -124,6 +160,7 @@
|
||||
"notFoundTitle": "Dieser Artikel existiert (noch) nicht",
|
||||
"notFoundText": "Unter <code>{path}</code> ist nichts. Du kannst die erste Version selbst schreiben — es braucht nur Markdown.",
|
||||
"writeTopic": "Dieses Thema schreiben",
|
||||
"backToArticles": "Zurück zu den offenen Artikeln",
|
||||
"loadFailTitle": "Der Artikel konnte nicht geladen werden",
|
||||
"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."
|
||||
@@ -190,7 +227,13 @@
|
||||
"sessionExpired": "Diese Sitzung ist abgelaufen — bitte melde dich erneut an.",
|
||||
"saveFailed": "Der Artikel konnte nicht gespeichert werden. Läuft das Backend?",
|
||||
"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": {
|
||||
"title": "Schreib-Spickzettel",
|
||||
@@ -240,10 +283,7 @@
|
||||
"accentForest": "Waldgrün",
|
||||
"accentAmber": "Bernstein",
|
||||
"accentRose": "Rose",
|
||||
"accentSlate": "Schiefer",
|
||||
"language": "Sprache",
|
||||
"languageHint": "Die Sprache, in der Mathew dich anspricht. Beim angemeldeten Konto mit diesem gespeichert.",
|
||||
"languageAria": "Oberflächensprache"
|
||||
"accentSlate": "Schiefer"
|
||||
},
|
||||
"auth": {
|
||||
"signin": "Anmelden",
|
||||
@@ -294,7 +334,10 @@
|
||||
"newPassword": "Neues Passwort",
|
||||
"changePassword": "Passwort ändern",
|
||||
"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": {
|
||||
"placeholder": "Artikel nach Titel oder Stichwort durchsuchen…",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"done": "Done",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"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.",
|
||||
"apiShort": "Could not reach the API. Start the backend with <code>npm run dev</code> in <code>backend/</code>.",
|
||||
"writtenIn": "Written in {lang}",
|
||||
"tagChip": "tag: {tag}",
|
||||
"languageChip": "language: {lang}",
|
||||
"clearTag": "Clear tag filter",
|
||||
"clearLanguage": "Clear language filter"
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -34,6 +33,9 @@
|
||||
"graph": "Graph",
|
||||
"admin": "Admin",
|
||||
"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",
|
||||
"signedInAs": "Signed in as {name} — profile & 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.",
|
||||
"hero": "All <span class=\"gradient\">articles</span>",
|
||||
"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",
|
||||
"topicCount": "{n} topic | {n} topics",
|
||||
"topicSearchPlaceholder": "Search topics — try “calculus”, “geometry”…",
|
||||
"topicSearchAria": "Search topics",
|
||||
"noTopic": "No topic matches “{q}”.",
|
||||
"filterByLanguage": "Filter by language",
|
||||
"writtenIn": "Written in",
|
||||
"headingTagLang": "Articles tagged “{tag}” · in {lang}",
|
||||
"headingTag": "Articles tagged “{tag}”",
|
||||
"headingLang": "Articles in {lang}",
|
||||
"headingAll": "All articles",
|
||||
"emptyNoTopicTitle": "No articles with this topic",
|
||||
"emptyNoLangTitle": "No articles in {lang}",
|
||||
"emptyNoTopicsTitle": "No topics yet",
|
||||
@@ -84,7 +81,7 @@
|
||||
},
|
||||
"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.",
|
||||
"roleChangeFailed": "Could not change that role.",
|
||||
"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.",
|
||||
"backToArticles": "Back to the articles",
|
||||
"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",
|
||||
"adminTally": "{n} admin | {n} admins",
|
||||
"memberTally": "{n} member | {n} members",
|
||||
@@ -108,7 +104,47 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"metaTitle": "Graph",
|
||||
@@ -124,6 +160,7 @@
|
||||
"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.",
|
||||
"writeTopic": "Write this topic",
|
||||
"backToArticles": "Back to the open articles",
|
||||
"loadFailTitle": "Could not load the article",
|
||||
"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."
|
||||
@@ -190,7 +227,13 @@
|
||||
"sessionExpired": "That session has expired — please sign in again.",
|
||||
"saveFailed": "Could not save the article. Is the backend running?",
|
||||
"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": {
|
||||
"title": "Writing cheatsheet",
|
||||
@@ -240,10 +283,7 @@
|
||||
"accentForest": "Forest",
|
||||
"accentAmber": "Amber",
|
||||
"accentRose": "Rose",
|
||||
"accentSlate": "Slate",
|
||||
"language": "Language",
|
||||
"languageHint": "The language Mathew speaks to you. Saved with your account when you are signed in.",
|
||||
"languageAria": "Interface language"
|
||||
"accentSlate": "Slate"
|
||||
},
|
||||
"auth": {
|
||||
"signin": "Sign in",
|
||||
@@ -294,7 +334,10 @@
|
||||
"newPassword": "New password",
|
||||
"changePassword": "Change 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": {
|
||||
"placeholder": "Search articles by title or tag…",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Cerrar",
|
||||
"done": "Listo",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"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.",
|
||||
"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}",
|
||||
"tagChip": "etiqueta: {tag}",
|
||||
"languageChip": "idioma: {lang}",
|
||||
"clearTag": "Quitar el filtro de etiqueta",
|
||||
"clearLanguage": "Quitar el filtro de idioma"
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -34,6 +33,9 @@
|
||||
"graph": "Grafo",
|
||||
"admin": "Admin",
|
||||
"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",
|
||||
"signedInAs": "Sesión de {name} — perfil y 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.",
|
||||
"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.",
|
||||
"countOfTotal": "{shown} de {total} artículos",
|
||||
"backToTopics": "Volver a los temas",
|
||||
"articleCount": "{n} artículo | {n} artículos",
|
||||
"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",
|
||||
"writtenIn": "Escritos en",
|
||||
"headingTagLang": "Artículos con la etiqueta «{tag}» · en {lang}",
|
||||
"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",
|
||||
"emptyNoLangTitle": "No hay artículos en {lang}",
|
||||
"emptyNoTopicsTitle": "Aún no hay temas",
|
||||
@@ -84,7 +81,7 @@
|
||||
},
|
||||
"admin": {
|
||||
"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.",
|
||||
"roleChangeFailed": "No se pudo cambiar ese rol.",
|
||||
"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.",
|
||||
"backToArticles": "Volver a los artículos",
|
||||
"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",
|
||||
"adminTally": "{n} admin | {n} admins",
|
||||
"memberTally": "{n} miembro | {n} miembros",
|
||||
@@ -108,7 +104,47 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"metaTitle": "Grafo",
|
||||
@@ -124,6 +160,7 @@
|
||||
"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.",
|
||||
"writeTopic": "Escribir este tema",
|
||||
"backToArticles": "Volver a los artículos abiertos",
|
||||
"loadFailTitle": "No se pudo cargar el artículo",
|
||||
"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."
|
||||
@@ -190,7 +227,13 @@
|
||||
"sessionExpired": "La sesión ha caducado — inicia sesión otra vez.",
|
||||
"saveFailed": "No se pudo guardar el artículo. ¿Está el backend en marcha?",
|
||||
"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": {
|
||||
"title": "Chuleta de escritura",
|
||||
@@ -240,10 +283,7 @@
|
||||
"accentForest": "bosque",
|
||||
"accentAmber": "ámbar",
|
||||
"accentRose": "rosa",
|
||||
"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"
|
||||
"accentSlate": "pizarra"
|
||||
},
|
||||
"auth": {
|
||||
"signin": "Iniciar sesión",
|
||||
@@ -294,7 +334,10 @@
|
||||
"newPassword": "Contraseña nueva",
|
||||
"changePassword": "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": {
|
||||
"placeholder": "Busca artículos por título o etiqueta…",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Fermer",
|
||||
"done": "Terminé",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"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.",
|
||||
"apiShort": "Impossible de joindre l'API. Démarrez le backend avec <code>npm run dev</code> dans <code>backend/</code>.",
|
||||
"writtenIn": "Écrit en {lang}",
|
||||
"tagChip": "étiquette : {tag}",
|
||||
"languageChip": "langue : {lang}",
|
||||
"clearTag": "Retirer le filtre d'étiquette",
|
||||
"clearLanguage": "Retirer le filtre de langue"
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -34,6 +33,9 @@
|
||||
"graph": "Graphe",
|
||||
"admin": "Admin",
|
||||
"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",
|
||||
"signedInAs": "Connecté en tant que {name} — profil et 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.",
|
||||
"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.",
|
||||
"countOfTotal": "{shown} sur {total} articles",
|
||||
"backToTopics": "Retour aux thèmes",
|
||||
"articleCount": "{n} article | {n} articles",
|
||||
"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",
|
||||
"writtenIn": "Écrits en",
|
||||
"headingTagLang": "Articles étiquetés « {tag} » · en {lang}",
|
||||
"headingTag": "Articles étiquetés « {tag} »",
|
||||
"headingLang": "Articles en {lang}",
|
||||
"headingAll": "Tous les articles",
|
||||
"emptyNoTopicTitle": "Aucun article avec ce thème",
|
||||
"emptyNoLangTitle": "Aucun article en {lang}",
|
||||
"emptyNoTopicsTitle": "Pas encore de thèmes",
|
||||
@@ -84,7 +81,7 @@
|
||||
},
|
||||
"admin": {
|
||||
"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.",
|
||||
"roleChangeFailed": "Impossible de changer ce rôle.",
|
||||
"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.",
|
||||
"backToArticles": "Retour aux articles",
|
||||
"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",
|
||||
"adminTally": "{n} admin | {n} admins",
|
||||
"memberTally": "{n} membre | {n} membres",
|
||||
@@ -108,7 +104,47 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"metaTitle": "Graphe",
|
||||
@@ -124,6 +160,7 @@
|
||||
"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.",
|
||||
"writeTopic": "Écrire ce thème",
|
||||
"backToArticles": "Retour aux articles ouverts",
|
||||
"loadFailTitle": "Impossible de charger l'article",
|
||||
"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."
|
||||
@@ -190,7 +227,13 @@
|
||||
"sessionExpired": "Cette session a expiré — reconnectez-vous, s'il vous plaît.",
|
||||
"saveFailed": "Impossible d'enregistrer l'article. Le backend est-il en marche ?",
|
||||
"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": {
|
||||
"title": "Aide-mémoire d'écriture",
|
||||
@@ -240,10 +283,7 @@
|
||||
"accentForest": "forêt",
|
||||
"accentAmber": "ambre",
|
||||
"accentRose": "rose",
|
||||
"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"
|
||||
"accentSlate": "ardoise"
|
||||
},
|
||||
"auth": {
|
||||
"signin": "Se connecter",
|
||||
@@ -294,7 +334,10 @@
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"changePassword": "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": {
|
||||
"placeholder": "Recherchez des articles par titre ou étiquette…",
|
||||
|
||||
Reference in New Issue
Block a user