First commit

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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# dev.sh runtime artifacts
.logs/
.pids/

152
AGENTS.md Normal file
View File

@@ -0,0 +1,152 @@
# Mathew — a wiki for mathematics
A collaborative wiki of math concepts. Articles are written in Markdown with LaTeX
(KaTeX) and stored in MongoDB behind an Express API; a Nuxt 4 app renders and edits them.
```
mathew/
├── dev.sh # starts/stops both dev servers together
├── backend/ Express 5 + Mongoose API (port 5000)
└── frontend/ Nuxt 4 / Vue 3 wiki UI (port 3000)
```
## Running the project
Requires MongoDB on `localhost:27017` (database `mathew`).
```bash
./dev.sh # both dev servers in foreground (Ctrl+C stops both)
./dev.sh start|stop|restart|status
./dev.sh logs [all|backend|frontend]
```
Or per-service: `npm run dev` inside `backend/` or `frontend/`
(backend uses `node --watch`; backend has no build step).
Seed sample articles: `npm run seed` in `backend/`.
Environment: `backend/.env` (`PORT=5000`, `MONGO_URI=mongodb://127.0.0.1:27017/mathew`,
`JWT_SECRET` — signs session tokens, `AUTH_TOKEN_TTL` — how long one lasts, default `7d`;
see `.env.example`). Without `JWT_SECRET` the API warns and uses an insecure dev key.
Frontend API URL comes from `NUXT_PUBLIC_API_BASE`
(default `http://localhost:5000`, read via `runtimeConfig.public.apiBase`).
There is **no test suite, linter, or typecheck script** configured — don't invent commands.
## Backend (`backend/`)
Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` import specifiers).
- `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.
- `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),
`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
small data URL — no file storage. `User.role` is `'admin'` or `'member'`
(`ROLES`); `User.hasNone()` and `User.ensureAnAdmin()` cover who gets the
former — see the roles gotcha below.
- `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),
`POST /api/auth/login`, `GET /api/auth/me` (who a token belongs to).
Login answers the same way to a wrong name and a wrong password. Account updates,
all behind `requireAuth`: `PUT /api/auth/me` (rename), `PUT /api/auth/password`
(needs the current one), `PUT /api/auth/avatar` (data URL, `""` clears).
- `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).
- `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
the role off the **account** (403 `{ message }` for a member), so removing the
role works the next time the dashboard is asked for.
- `src/seed.js` — sample articles.
Conventions to follow:
- Every handler is `async` with `try { ... } catch (err) { next(err); }`; the central
error handler in `index.js` formats responses (`{ message }` everywhere).
- 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=`.
- Reads are public; the writes (`POST /api/articles`, `PUT`/`DELETE` by slug) run
through `requireAuth`.
## Frontend (`frontend/`)
Nuxt 4 (srcDir `app/`, TypeScript, Vue 3 `<script setup>` SFCs). No UI framework —
hand-rolled SCSS (devDep `sass`) in `app/assets/css/`: `main.scss` `@use`s one
partial per section (`_tokens`, `_layout`, `_editor`, `_book`, …); shared shapes
(popups, dropdowns, segmented controls, avatars) are mixins in `_mixins.scss`, and
the accent themes generate from a map in `_tokens.scss`. Colours stay CSS custom
properties so themes swap at runtime via `data-theme`/`data-accent` attributes
(set pre-paint by an inline script in `nuxt.config.ts`).
- `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).
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`
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/
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),
`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
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).
- `app/plugins/auth.ts` — runs `restoreSession()` once at startup on server and client,
so the sidebar's account row and the editor's gate are right in the first render.
- `app/utils/markdown.ts` — the single render pipeline: `markdown-it` (**html: false**,
raw HTML is escaped) + `markdown-it-texmath` + KaTeX (`$...$` inline, `$$...$$` display,
macros `\R \N \Z \Q \C`) + highlight.js, sanitized with DOMPurify. It also builds the
TOC. Render all article Markdown through this, not ad-hoc renderers.
- API calls use `$fetch` against `${useRuntimeConfig().public.apiBase}/api`.
## Gotchas
- Reading is open; **writing takes an account**. `POST /api/articles` and `PUT`/`DELETE`
by slug need `Authorization: Bearer <token>` from `/api/auth/login` or
`/api/auth/register`. The frontend keeps that token in the `mathew-session` cookie
(readable by the app, sent as a header — it is not an httpOnly session cookie), so a
set `JWT_SECRET` matters in production; anyone with the dev key can mint a session.
- A 401 from a write means the held session is dead; the editor keeps the draft and
opens the sign-in popup over it rather than signing out and unmounting the form.
- Anyone may register, and an account cannot edit another's article (articles store
no author). Roles are the one difference between accounts: `role` is `'admin'` or
`'member'`. **The first account ever registered is the admin** — and on a wiki
whose accounts all predate the role, `User.ensureAnAdmin()` gives it to the oldest
one at startup. Admins promote and demote from `/admin`; nobody may change their
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.
- 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).
- 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.

4
backend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
PORT=5000
MONGO_URI=mongodb://127.0.0.1:27017/mathew
JWT_SECRET=change-me-64-hex-chars
AUTH_TOKEN_TTL=7d

2
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
.env

1279
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
backend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "mathew-backend",
"version": "1.0.0",
"description": "Minimal Node backend using Express and Mongoose",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"seed": "node src/seed.js"
},
"license": "MIT",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^8.24.4"
}
}

View File

@@ -0,0 +1,15 @@
import "dotenv/config";
// Secret that signs session tokens. Anyone holding it can mint tokens that
// pass for accounts, so it belongs in the environment and never in the repo.
// The fallback keeps `./dev.sh` working out of the box — loudly.
const DEV_SECRET = "mathew-development-secret-set-JWT_SECRET-in-backend-env";
export const JWT_SECRET = process.env.JWT_SECRET || DEV_SECRET;
// How long a sign-in lasts. Long enough to survive a weekend of writing.
export const TOKEN_TTL = process.env.AUTH_TOKEN_TTL || "7d";
if (!process.env.JWT_SECRET) {
console.warn("[auth] JWT_SECRET is not set — using an insecure development key.");
}

11
backend/src/config/db.js Normal file
View File

@@ -0,0 +1,11 @@
import mongoose from "mongoose";
export async function connectDB() {
const uri = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/mathew";
await mongoose.connect(uri);
console.log(`MongoDB connected: ${mongoose.connection.host}`);
}
export async function disconnectDB() {
await mongoose.disconnect();
}

72
backend/src/index.js Normal file
View File

@@ -0,0 +1,72 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import mongoose from "mongoose";
import { connectDB, disconnectDB } from "./config/db.js";
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 User from "./models/User.js";
const app = express();
// Middleware
app.use(cors());
// Room for a profile picture sent as a data URL (see PUT /api/auth/avatar).
app.use(express.json({ limit: "1.5mb" }));
// Routes
app.get("/health", (req, res) => {
res.json({
status: "ok",
mongo: mongoose.connection.readyState === 1 ? "connected" : "disconnected",
});
});
app.use("/api/items", itemsRouter);
app.use("/api/auth", authRouter);
app.use("/api/articles", articlesRouter);
app.use("/api/admin", adminRouter);
// 404 handler
app.use((req, res) => {
res.status(404).json({ message: "Not found" });
});
// Central error handler
app.use((err, req, res, next) => {
if (err.name === "ValidationError") {
return res.status(400).json({ message: err.message });
}
if (err.name === "CastError") {
return res.status(400).json({ message: "Invalid id format" });
}
console.error(err);
res.status(err.status || 500).json({ message: err.message || "Server error" });
});
// Start server after connecting to MongoDB
const port = process.env.PORT || 5000;
try {
await connectDB();
// 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();
if (promoted) console.log(`Admin role given to the oldest account: ${promoted.username}`);
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
} catch (err) {
console.error("Failed to start server:", err.message);
process.exit(1);
}
// Graceful shutdown
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, async () => {
await disconnectDB();
process.exit(0);
});
}

View File

@@ -0,0 +1,60 @@
import jwt from "jsonwebtoken";
import { JWT_SECRET, TOKEN_TTL } from "../config/auth.js";
import User from "../models/User.js";
/** Issues a session token for an account. */
export function signToken(user) {
return jwt.sign(
{ sub: user._id.toString(), username: user.username },
JWT_SECRET,
{ expiresIn: TOKEN_TTL }
);
}
/**
* Reads the `Authorization: Bearer <token>` header and puts the account on
* `req.user`; anything else and the request stops here with 401. The message
* bodies are what the frontend keys off: a 401 means the session it holds is
* no good, so it drops it and asks the person to sign in again.
*/
export function requireAuth(req, res, next) {
const [scheme, token] = String(req.headers.authorization || "").split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ message: "Please sign in to do that." });
}
let session;
try {
session = jwt.verify(token, JWT_SECRET);
} catch {
return res
.status(401)
.json({ message: "That session has expired — please sign in again." });
}
req.user = { id: session.sub, username: session.username };
next();
}
/**
* Runs after `requireAuth` and lets an admin through alone. The role is read
* from the account rather than trusted from the token, so taking it away works
* the next time the dashboard is asked for — a token issued while the account
* was an admin is no good once it is not.
*/
export async function requireAdmin(req, res, next) {
try {
const account = await User.findById(req.user.id);
if (!account) {
return res.status(401).json({ message: "No such account any more." });
}
if (account.role !== "admin") {
return res.status(403).json({ message: "This is for administrators." });
}
req.user.role = account.role;
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,52 @@
import mongoose from "mongoose";
const articleSchema = new mongoose.Schema(
{
slug: {
type: String,
required: [true, "Slug is required"],
unique: true,
lowercase: true,
trim: true,
index: true,
},
title: {
type: String,
required: [true, "Title is required"],
trim: true,
},
content: {
type: String,
default: "",
},
tags: {
type: [String],
default: [],
},
// Language this version of the article is written in, as a lowercase
// ISO 639-1 code ("en", "fr", "de", ...). Defaults to English.
language: {
type: String,
default: "en",
lowercase: true,
trim: true,
},
// For a translation: the identity of the article it translates — the
// canonical (original) version's slug. Null on the canonical document.
baseSlug: {
type: String,
default: null,
lowercase: true,
trim: true,
index: true,
},
},
{ timestamps: true }
);
// Text index for search across title and tags
articleSchema.index({ title: "text", tags: "text" });
const Article = mongoose.model("Article", articleSchema);
export default Article;

View File

@@ -0,0 +1,21 @@
import mongoose from "mongoose";
const itemSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, "Name is required"],
trim: true,
},
description: {
type: String,
default: "",
trim: true,
},
},
{ timestamps: true }
);
const Item = mongoose.model("Item", itemSchema);
export default Item;

124
backend/src/models/User.js Normal file
View File

@@ -0,0 +1,124 @@
import mongoose from "mongoose";
import bcrypt from "bcryptjs";
export const ROLES = ["admin", "member"];
/* The interface languages the web app offers (mirrors its own catalogue); the
account stores its owner's choice so the wiki speaks their language on every
device. "" means no choice yet — the device's own language decides. */
export const SUPPORTED_LOCALES = ["en", "es", "ca", "fr", "de"];
export const USERNAME_REGEX = /^[a-z0-9_.-]{3,30}$/;
export const USERNAME_MESSAGE =
"Username must be 3-30 characters: letters, numbers, dot, dash or underscore.";
export const PASSWORD_MIN = 8;
export const PASSWORD_MESSAGE = `Password must be at least ${PASSWORD_MIN} characters.`;
/**
* The one place account rules are written down: both the schema and the routes
* check through this, so they cannot drift apart. Returns a message, or null
* when the pair is usable.
*/
export function credentialProblem({ username, password }) {
if (!USERNAME_REGEX.test(username)) return USERNAME_MESSAGE;
if (password.length < PASSWORD_MIN) return PASSWORD_MESSAGE;
if (password.length > 200) return "Password is too long.";
return null;
}
const userSchema = new mongoose.Schema(
{
username: {
type: String,
required: [true, "Username is required"],
unique: true,
lowercase: true,
trim: true,
index: true,
match: [USERNAME_REGEX, USERNAME_MESSAGE],
},
// Hashed on the way in and left out of queries by default: no route sends
// a password, hashed or otherwise, back to a client.
passwordHash: {
type: String,
required: true,
select: false,
},
// Profile picture kept as a data URL (the frontend shrinks it before
// sending), so the avatar travels in the user object itself and the API
// needs no file storage of its own. "" means no picture: an initial shows.
avatar: {
type: String,
default: "",
},
/* Two kinds of account. A member reads and writes like anyone else; an admin
additionally opens the dashboard of accounts and hands the role on. It is
stored on the account rather than carried in the session token, so taking
it away takes effect the next time a page asks. */
role: {
type: String,
enum: { values: ROLES, message: `Role is one of: ${ROLES.join(", ")}.` },
default: "member",
},
locale: {
type: String,
enum: {
values: ["", ...SUPPORTED_LOCALES],
message: `Language is one of: ${SUPPORTED_LOCALES.join(", ")}.`,
},
default: "",
lowercase: true,
trim: true,
},
},
{ timestamps: true }
);
/** Hashes the plain password into `passwordHash`. The plain one is never saved. */
userSchema.methods.setPassword = async function setPassword(plain) {
this.passwordHash = await bcrypt.hash(String(plain), 10);
return this;
};
/** Checks a plain password against the stored hash; needs that field selected. */
userSchema.methods.checkPassword = function checkPassword(plain) {
return bcrypt.compare(String(plain ?? ""), this.passwordHash);
};
/** The shape the API returns for an account: never the hash. */
userSchema.methods.toPublic = function toPublic() {
return {
id: this._id.toString(),
username: this.username,
avatar: this.avatar ?? "",
// Accounts opened before the role existed answer as plain members.
role: this.role === "admin" ? "admin" : "member",
// "" until the person chooses one in the settings popup.
locale: this.locale ?? "",
};
};
/* Someone has to be able to hand the admin role on, so an empty wiki gives it
to the first account it ever registers (see routes/auth.js). */
userSchema.statics.hasNone = async function hasNone() {
return (await this.estimatedDocumentCount()) === 0;
};
/* And a wiki whose accounts all predate the role gives it to the oldest of
them, once, at startup — otherwise the dashboard would have nobody who can
open it. Answers the account it promoted, or null when there was nothing to
do: an admin already about, or no account at all yet. */
userSchema.statics.ensureAnAdmin = async function ensureAnAdmin() {
if (await this.exists({ role: "admin" })) return null;
const oldest = await this.findOne().sort({ createdAt: 1 });
if (!oldest) return null;
oldest.role = "admin";
await oldest.save();
return oldest;
};
const User = mongoose.model("User", userSchema);
export default User;

View File

@@ -0,0 +1,79 @@
import { Router } from "express";
import User, { ROLES } from "../models/User.js";
import { requireAdmin, requireAuth } from "../middleware/auth.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
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
when it was opened. */
function accountFor(user) {
return { ...user.toPublic(), createdAt: user.createdAt };
}
// GET /api/admin/users — every account on the wiki, newest first. The pictures
// travel along as data URLs, which is what the list needs to look like the rest
// of the wiki; the wiki holds a handful of accounts, not thousands.
router.get("/users", requireAuth, requireAdmin, async (req, res, next) => {
try {
const users = await User.find().sort({ createdAt: -1 });
res.json({ accounts: users.map(accountFor) });
} catch (err) {
next(err);
}
});
// PUT /api/admin/users/:id/role — hand the admin role to an account, or take it
// back. An admin's own role is the one they cannot change: whoever else is
// already an admin does that, so the wiki is never left without one.
router.put("/users/:id/role", requireAuth, requireAdmin, async (req, res, next) => {
try {
const role = String(req.body?.role ?? "");
if (!ROLES.includes(role)) {
return res.status(400).json({ message: `Role is one of: ${ROLES.join(", ")}.` });
}
const account = await User.findById(req.params.id);
if (!account) return res.status(404).json({ message: "No such account." });
if (role !== "admin" && account._id.equals(req.user.id)) {
return res
.status(400)
.json({ message: "Another admin has to step you down from the role." });
}
if (account.role === role) return res.json({ account: accountFor(account) });
account.role = role;
await account.save();
res.json({ account: accountFor(account) });
} catch (err) {
next(err);
}
});
// DELETE /api/admin/users/:id — close an account for good. An admin's own
// account is the one they cannot close, the same guard as the role: somebody
// else has to do it, so the wiki is never left without a way back in. The
// articles outlive the account — none of them ever named an author.
router.delete("/users/:id", requireAuth, requireAdmin, async (req, res, next) => {
try {
const account = await User.findById(req.params.id);
if (!account) return res.status(404).json({ message: "No such account." });
if (account._id.equals(req.user.id)) {
return res
.status(400)
.json({ message: "Another admin has to close your own account." });
}
await account.deleteOne();
res.json({ message: "Account deleted." });
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,361 @@
import { Router } from "express";
import Article from "../models/Article.js";
import { 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.
// Convert a title (or any string) into a URL-safe slug
export function slugify(str) {
return String(str)
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
// Make a slug unique by appending -2, -3, ... if needed.
// Optionally pass `excludeSlug` when the slug is unchanged during updates.
async function uniqueSlug(base, excludeSlug = null) {
const root = slugify(base) || "article";
let candidate = root;
let n = 1;
// eslint-disable-next-line no-constant-condition
while (true) {
const existing = await Article.findOne({ slug: candidate }).select("slug").lean();
if (!existing || existing.slug === excludeSlug) return candidate;
candidate = `${root}-${++n}`;
}
}
// "FR " -> "fr"; whatever is stored on an article is what queries see.
function languageCode(value) {
const code = String(value ?? "en").trim().toLowerCase();
return code || "en";
}
// 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`.
async function versionsOf(identity) {
// not lean: hydration fills in the language default for older documents
return Article.find({ $or: [{ slug: identity }, { baseSlug: identity }] })
.select("slug title language updatedAt")
.sort({ language: 1 });
}
/**
* Resolve one article by its identity, optionally a specific language:
* without `lang` the canonical version answers (or the document whose slug
* was addressed, which also covers older `/slug-fr` addresses); with `lang`
* the version written in that language — `/wiki/fr/euler` on the front end.
*/
async function resolveVersion(slug, lang) {
const base = await Article.findOne({ slug });
if (!lang) return base;
const wanted = languageCode(lang);
if (base && languageCode(base.language) === wanted) return base;
const identity = base?.baseSlug || slug;
// a language address with no version in that language answers honestly: 404
return Article.findOne({ baseSlug: identity, language: wanted });
}
// GET /api/articles?q=&tag=&lang= - list articles (without content body).
// 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.
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 wantedLang = typeof lang === "string" && lang.trim() ? languageCode(lang) : "";
if (wantedLang) {
// articles written before the language field existed are English
filter.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 identities = [...new Set(matched.map((a) => a.baseSlug || a.slug))];
const groupDocs = await Article.find({
$or: [{ slug: { $in: identities } }, { baseSlug: { $in: identities } }],
})
.select("-content")
.sort({ updatedAt: -1 }); // not lean: hydration fills in old defaults
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);
});
entries.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
res.json(entries);
} catch (err) {
next(err);
}
});
// GET /api/articles/random - one random article. An article counts once no
// matter how many languages it exists in, so versions ride along with their
// canonical document; in a shelf of orphans any version stands in for itself.
router.get("/random", async (req, res, next) => {
try {
const [sampled] = await Article.aggregate([
{ $match: { baseSlug: null } },
{ $sample: { size: 1 } },
]);
const [article] = sampled
? [sampled]
: await Article.aggregate([{ $match: { baseSlug: { $type: "string" } } }, { $sample: { size: 1 } }]);
if (!article) return res.status(404).json({ message: "No articles yet" });
res.json(article);
} catch (err) {
next(err);
}
});
// 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.
const WIKILINK_RX = /\[\[([^\[\]|#]+)(?:#[^\[\]|]*)?(?:\|[^\[\]]*)?\]\]/g;
const WIKI_URL_RX = /\]\((?:https?:\/\/[^/\s)]+)?\/?wiki\/([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
// edge per linked pair. Code spans never draw links; self-links and links to
// articles that don't exist drop out, and repeated links between the same pair
// collapse into a single edge.
router.get("/graph", async (req, res, next) => {
try {
const articles = await Article.find()
.select("slug baseSlug title tags content")
.lean();
const identityOf = (a) => a.baseSlug || a.slug;
const known = new Set(articles.map(identityOf));
const degree = new Map(articles.map((a) => [identityOf(a), 0]));
const edges = [];
const seen = new Set();
const addEdge = (from, to) => {
if (!to || from === to || !known.has(to)) return;
const key = from < to ? `${from}\u0000${to}` : `${to}\u0000${from}`;
if (seen.has(key)) return;
seen.add(key);
edges.push({ source: from, target: to });
degree.set(from, degree.get(from) + 1);
degree.set(to, degree.get(to) + 1);
};
for (const article of articles) {
const content = (article.content ?? "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`\n]*`/g, " ");
for (const m of content.matchAll(WIKILINK_RX)) {
addEdge(identityOf(article), slugify(m[1]));
}
for (const m of content.matchAll(WIKI_URL_RX)) {
addEdge(identityOf(article), m[1].toLowerCase());
}
}
// the node speaks with its canonical article's title, if it still has one
const nodeByIdentity = new Map();
for (const article of articles) {
const identity = identityOf(article);
const node = nodeByIdentity.get(identity) ?? {
slug: identity,
title: article.title,
tags: [],
links: degree.get(identity) ?? 0,
};
if (!article.baseSlug) node.title = article.title;
for (const t of article.tags ?? []) if (!node.tags.includes(t)) node.tags.push(t);
nodeByIdentity.set(identity, node);
}
res.json({ nodes: [...nodeByIdentity.values()], edges });
} catch (err) {
next(err);
}
});
// GET /api/articles/slug/:slug - get one article by slug, together with the
// language versions the same article exists in (including itself). With
// ?lang=fr the version written in that language answers, falling back to the
// canonical one when it exists in no such language.
router.get("/slug/:slug", async (req, res, next) => {
try {
const lang = typeof req.query.lang === "string" ? req.query.lang.trim() : "";
const article = await resolveVersion(req.params.slug, lang);
if (!article) return res.status(404).json({ message: "Article not found" });
const versions = await versionsOf(article.baseSlug || article.slug);
res.json({ ...article.toObject(), versions });
} catch (err) {
next(err);
}
});
// 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" });
}
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" });
}
identity = base.baseSlug || base.slug;
} else {
// Two articles must not share a name in different languages: when the
// slug is taken by an article written in another language, the document
// joins it as a version rather than standing beside it as a twin. (The
// same name in the same language stays the old `-2` suffixed sibling.)
const byName = await Article.findOne({
slug: slugify(req.body.slug || title),
});
if (byName && languageCode(byName.language) !== language) {
identity = byName.baseSlug || byName.slug;
}
}
if (identity) {
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
language,
});
if (clash) {
return res
.status(400)
.json({ message: `This article already has a version in ${language}` });
}
}
const requested = req.body.slug
? slugify(req.body.slug)
: identity
? `${identity}-${language}` // versions sit side by side: slug-fr, slug-de, ...
: slugify(title);
const slug = await uniqueSlug(requested);
const article = await Article.create({
title,
content,
tags,
language,
slug,
baseSlug: identity,
});
res.status(201).json(article);
} catch (err) {
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 || {};
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 update = {};
if (title !== undefined) update.title = title;
if (content !== undefined) update.content = content;
if (tags !== undefined) update.tags = tags;
// Moving this version to another language must not collide with a
// version the article already has in that language.
if (language !== undefined) {
const code = languageCode(language);
if (code !== article.language) {
const identity = article.baseSlug || article.slug;
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
slug: { $ne: article.slug },
language: code,
});
if (clash) {
return res
.status(400)
.json({ message: `This article already has a version in ${code}` });
}
update.language = code;
}
}
// Re-slug when the title changes and no explicit slug was provided
if (title !== undefined) {
const requested = req.body.slug ? slugify(req.body.slug) : slugify(title);
if (requested && requested !== article.slug) {
update.slug = await uniqueSlug(requested, article.slug);
}
}
const updated = await Article.findOneAndUpdate(
{ slug: req.params.slug },
update,
{ new: true, runValidators: true }
);
// The version group's identity is the canonical article's slug: when the
// canonical is renamed, its translations follow it to the new slug.
if (updated && !article.baseSlug && update.slug) {
await Article.updateMany({ baseSlug: article.slug }, { baseSlug: update.slug });
}
res.json(updated);
} catch (err) {
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) => {
try {
const deleted = await Article.findOneAndDelete({ slug: req.params.slug });
if (!deleted) return res.status(404).json({ message: "Article not found" });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

189
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,189 @@
import { Router } from "express";
import User, {
SUPPORTED_LOCALES,
USERNAME_REGEX,
USERNAME_MESSAGE,
PASSWORD_MIN,
PASSWORD_MESSAGE,
credentialProblem,
} from "../models/User.js";
import { requireAuth, signToken } from "../middleware/auth.js";
const router = Router();
/* A profile picture arrives as a data URL the frontend shrank. The cap sits
under the JSON body limit in index.js: anything that slips past the
frontend's own resizing still cannot fill a request whole. */
const AVATAR_MAX_CHARS = 1_000_000;
const AVATAR_REGEX = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/;
/* Register and login share a shape: a token for whoever the account turns out
to be, plus the few public fields the frontend displays. */
function sessionFor(user) {
return { token: signToken(user), user: user.toPublic() };
}
function readCredentials(body) {
return {
username: String(body?.username ?? "").trim().toLowerCase(),
// A password is taken exactly as typed — only the username is normalised.
password: String(body?.password ?? ""),
};
}
// POST /api/auth/register — open an account and sign straight into it
router.post("/register", async (req, res, next) => {
try {
const credentials = readCredentials(req.body);
const problem = credentialProblem(credentials);
if (problem) return res.status(400).json({ message: problem });
const user = new User({ username: credentials.username });
/* The wiki's very first account is its admin: somebody has to be able to
open the dashboard and hand the role to anyone else. (Two registrations
racing on an empty wiki could both see it empty; the name is still decided
by the unique index, and another admin can undo the extra role at once.) */
if (await User.hasNone()) user.role = "admin";
await user.setPassword(credentials.password);
try {
await user.save();
} catch (err) {
// Two registrations for the same name at once: the unique index decides.
if (err?.code === 11000) {
return res.status(409).json({ message: "That username is taken." });
}
throw err;
}
res.status(201).json(sessionFor(user));
} catch (err) {
next(err);
}
});
// POST /api/auth/login — swap a username and password for a session token
router.post("/login", async (req, res, next) => {
try {
const { username, password } = readCredentials(req.body);
const user = await User.findOne({ username }).select("+passwordHash");
// Wrong name and wrong password answer alike, so the form gives nothing
// away about which accounts exist.
if (!user || !(await user.checkPassword(password))) {
return res.status(401).json({ message: "Wrong username or password." });
}
res.json(sessionFor(user));
} catch (err) {
next(err);
}
});
// GET /api/auth/me — who a token belongs to; the frontend asks this on load
router.get("/me", requireAuth, async (req, res, next) => {
try {
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/me — change the username on the account the token belongs to
router.put("/me", requireAuth, async (req, res, next) => {
try {
const username = String(req.body?.username ?? "").trim().toLowerCase();
if (!USERNAME_REGEX.test(username)) {
return res.status(400).json({ message: USERNAME_MESSAGE });
}
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
if (user.username === username) return res.json({ user: user.toPublic() });
user.username = username;
try {
await user.save();
} catch (err) {
// Someone else claimed the name while this was in flight.
if (err?.code === 11000) {
return res.status(409).json({ message: "That username is taken." });
}
throw err;
}
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/password — swap the current password for a new one. The
// current one is asked for, so a stolen token alone cannot lock its owner out.
router.put("/password", requireAuth, async (req, res, next) => {
try {
const currentPassword = String(req.body?.currentPassword ?? "");
const newPassword = String(req.body?.newPassword ?? "");
if (newPassword.length < PASSWORD_MIN) {
return res.status(400).json({ message: PASSWORD_MESSAGE });
}
if (newPassword.length > 200) {
return res.status(400).json({ message: "Password is too long." });
}
const user = await User.findById(req.user.id).select("+passwordHash");
if (!user) return res.status(401).json({ message: "No such account any more." });
if (!(await user.checkPassword(currentPassword))) {
return res.status(401).json({ message: "That is not your current password." });
}
await user.setPassword(newPassword);
await user.save();
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/locale — the interface language saved on the account, so the
// choice follows the person across devices; "" hands the decision back to the
// browser. Only the supported languages are accepted.
router.put("/locale", requireAuth, async (req, res, next) => {
try {
const locale = String(req.body?.locale ?? "").trim().toLowerCase();
if (locale && !SUPPORTED_LOCALES.includes(locale)) {
return res.status(400).json({ message: `Language is one of: ${SUPPORTED_LOCALES.join(", ")}.` });
}
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
user.locale = locale;
await user.save();
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/avatar — a new profile picture as a data URL; "" removes it
router.put("/avatar", requireAuth, async (req, res, next) => {
try {
const avatar = String(req.body?.avatar ?? "");
if (avatar) {
if (avatar.length > AVATAR_MAX_CHARS) {
return res.status(413).json({ message: "That picture is too large." });
}
if (!AVATAR_REGEX.test(avatar)) {
return res.status(400).json({ message: "Send a PNG, JPEG, WebP or GIF image." });
}
}
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
user.avatar = avatar;
await user.save();
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,62 @@
import { Router } from "express";
import Item from "../models/Item.js";
const router = Router();
// GET /api/items - list all items
router.get("/", async (req, res, next) => {
try {
const items = await Item.find().sort({ createdAt: -1 });
res.json(items);
} catch (err) {
next(err);
}
});
// GET /api/items/:id - get one item
router.get("/:id", async (req, res, next) => {
try {
const item = await Item.findById(req.params.id);
if (!item) return res.status(404).json({ message: "Item not found" });
res.json(item);
} catch (err) {
next(err);
}
});
// POST /api/items - create an item
router.post("/", async (req, res, next) => {
try {
const item = await Item.create(req.body);
res.status(201).json(item);
} catch (err) {
next(err);
}
});
// PUT /api/items/:id - update an item
router.put("/:id", async (req, res, next) => {
try {
const item = await Item.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!item) return res.status(404).json({ message: "Item not found" });
res.json(item);
} catch (err) {
next(err);
}
});
// DELETE /api/items/:id - delete an item
router.delete("/:id", async (req, res, next) => {
try {
const item = await Item.findByIdAndDelete(req.params.id);
if (!item) return res.status(404).json({ message: "Item not found" });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

410
backend/src/seed.js Normal file
View File

@@ -0,0 +1,410 @@
import "dotenv/config";
import mongoose from "mongoose";
import { connectDB, disconnectDB } from "./config/db.js";
import Article from "./models/Article.js";
const articles = [
{
slug: "pythagorean-theorem",
title: "Pythagorean theorem",
tags: ["geometry", "euclidean", "triangles"],
content: `# Pythagorean theorem
In Euclidean geometry, the **Pythagorean theorem** states that in a right-angled triangle, the area of the square on the hypotenuse is equal to the sum of the areas of the squares on the other two sides:
$$
a^2 + b^2 = c^2
$$
where $c$ denotes the length of the hypotenuse and $a$ and $b$ the lengths of the other two sides.
## Examples
- A $(3, 4, 5)$ triangle: $3^2 + 4^2 = 9 + 16 = 25 = 5^2$ ✓
- A $(5, 12, 13)$ triangle: $5^2 + 12^2 = 25 + 144 = 169 = 13^2$ ✓
## A proof by rearrangement
Consider a square of side $a+b$. Its area can be computed two ways:
$$
(a+b)^2 = 4 \\cdot \\frac{1}{2}ab + c^2
$$
Expanding the left side gives $a^2 + 2ab + b^2 = 2ab + c^2$, and subtracting $2ab$ from both sides yields $a^2 + b^2 = c^2$. $\\blacksquare$
## Generalizations
| Theorem | Setting | Formula |
| --- | --- | --- |
| Law of cosines | Euclidean | $c^2 = a^2 + b^2 - 2ab\\cos\\gamma$ |
| de Gua's theorem | 3D (tetrahedron) | $V_0^2 = V_1^2 + V_2^2 + V_3^2$ |
> The theorem is named after Pythagoras of Samos (c. 570 – c. 495 BC), although Babylonian tablets such as Plimpton 322 predate him by a millennium.
## Distance formula
The theorem underpins the Euclidean distance between two points $P_1=(x_1, y_1)$ and $P_2=(x_2, y_2)$:
$$
d = \\sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}
$$
`,
},
{
slug: "eulers-identity",
title: "Euler's identity",
tags: ["complex analysis", "constants", "analysis"],
content: `# Euler's identity
**Euler's identity** is the equality
$$
e^{i\\pi} + 1 = 0
$$
It is a special case of Euler's formula $e^{i\\theta} = \\cos\\theta + i\\sin\\theta$ evaluated at $\\theta = \\pi$.
## Why it is celebrated
The identity links five fundamental constants:
| Constant | Significance |
| --- | --- |
| $e$ | base of the natural logarithm |
| $i$ | the imaginary unit, $i^2 = -1$ |
| $\\pi$ | ratio of circumference to diameter |
| $1$ | multiplicative identity |
| $0$ | additive identity |
...using only the operations of exponentiation, multiplication (implicit), and addition.
## Derivation from Taylor series
These series are Taylor expansions at $0$, assembled from [[Derivative|derivatives]]:
$$
e^x = \\sum_{n=0}^{\\infty} \\frac{x^n}{n!}, \\quad
\\cos x = \\sum_{n=0}^{\\infty} \\frac{(-1)^n x^{2n}}{(2n)!}, \\quad
\\sin x = \\sum_{n=0}^{\\infty} \\frac{(-1)^n x^{2n+1}}{(2n+1)!}
$$
substituting $x = i\\theta$ and separating real and imaginary parts gives Euler's formula. Setting $\\theta = \\pi$:
$$
e^{i\\pi} = \\cos\\pi + i\\sin\\pi = -1 + 0i = -1
$$
## General case
For any integer $k$, the $n$-th roots of unity satisfy
$$
z_k = e^{2\\pi i k / n}, \\qquad k = 0, 1, \\dots, n-1
$$
and Euler's identity is the case $n = 2$, $k = 1$. Split the fifth roots ($n = 5$) and the [[Golden ratio|golden ratio]] turns up: $\\cos 72^\\circ = (\\sqrt{5} - 1)/4$.
> "Like a Shakespearean sonnet that captures the very essence of love, or a painting that brings out the beauty of the human form, Euler's equation reaches down into the very depths of existence." — Keith Devlin
`,
},
{
slug: "derivative",
title: "Derivative",
tags: ["calculus", "analysis", "foundations"],
content: `# Derivative
In mathematics, the **derivative** of a function of one variable at a point is the rate of change of the function near that point — the slope of the tangent line to the graph of the function at that point.
## Definition
The derivative of $f$ at $x$ is defined as the limit of the difference quotient:
$$
f'(x) = \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}
$$
if this limit exists. An equivalent formulation:
$$
f'(a) = \\lim_{x \\to a} \\frac{f(x) - f(a)}{x - a}
$$
## Basic rules
Let $u$ and $v$ be differentiable functions. Then:
| Rule | Formula |
| --- | --- |
| Sum | $(u+v)' = u' + v'$ |
| Product | $(uv)' = u'v + uv'$ |
| Quotient | $\\left(\\frac{u}{v}\\right)' = \\frac{u'v - uv'}{v^2}$ |
| Chain | $(u \\circ v)'(x) = u'(v(x)) \\cdot v'(x)$ |
## Common derivatives
$$
\\frac{d}{dx} x^n = n x^{n-1}, \\qquad
\\frac{d}{dx} e^x = e^x, \\qquad
\\frac{d}{dx} \\ln x = \\frac{1}{x}
$$
$$
\\frac{d}{dx} \\sin x = \\cos x, \\qquad
\\frac{d}{dx} \\cos x = -\\sin x
$$
## Example
For $f(x) = x^3$, the difference quotient is
$$
\\frac{(x+h)^3 - x^3}{h} = \\frac{3x^2h + 3xh^2 + h^3}{h} = 3x^2 + 3xh + h^2 \\xrightarrow{h \\to 0} 3x^2
$$
so $f'(x) = 3x^2$.
## Numerical check
\`\`\`python
def derivative(f, x, h=1e-8):
return (f(x + h) - f(x)) / h
f = lambda x: x**3
print(derivative(f, 2.0)) # ~12.0
\`\`\`
## Related concepts
- The **integral** is, roughly, the inverse operation (see [[Fundamental theorem of calculus]]).
- A function whose derivative exists everywhere is called *differentiable*; differentiability implies *continuity*, but not conversely (e.g. $f(x) = |x|$ at $x = 0$).
`,
},
{
slug: "bayes-theorem",
title: "Bayes' theorem",
tags: ["probability", "statistics", "machine learning"],
content: `# Bayes' theorem
**Bayes' theorem** describes how the probability of an event should be updated given prior knowledge related to the event. It is the foundation of Bayesian statistics.
## Statement
For events $A$ and $B$ with $P(B) \\neq 0$:
$$
P(A \\mid B) = \\frac{P(B \\mid A)\\, P(A)}{P(B)}
$$
In terms of a *hypothesis* $H$ and *evidence* $E$:
$$
\\underbrace{P(H \\mid E)}_{\\text{posterior}} =
\\frac{\\overbrace{P(E \\mid H)}^{\\text{likelihood}} \\cdot \\overbrace{P(H)}^{\\text{prior}}}
{\\underbrace{P(E)}_{\\text{evidence}}}
$$
## The law of total probability
The denominator can be expanded when $H_1, \\dots, H_n$ partition the sample space:
$$
P(E) = \\sum_{i=1}^{n} P(E \\mid H_i) P(H_i)
$$
## Example: medical testing
A disease affects 1% of the population ($P(D) = 0.01$). A test is 99% sensitive and 95% specific. What is the probability that a positive test result means the patient has the disease?
$$
P(D \\mid +) = \\frac{P(+ \\mid D) P(D)}{P(+ \\mid D)P(D) + P(+ \\mid D^c)P(D^c)}
= \\frac{0.99 \\cdot 0.01}{0.99 \\cdot 0.01 + 0.05 \\cdot 0.99} \\approx 0.167
$$
Even a positive result implies only a **16.7%** chance of disease — the base rate dominates. This is the *base rate fallacy*.
## Conjugate priors
For a Bernoulli likelihood with $\\mathrm{Beta}(\\alpha, \\beta)$ prior:
$$
\\theta \\mid \\text{data} \\sim \\mathrm{Beta}\\!\\left(\\alpha + \\sum_i x_i, \\; \\beta + n - \\sum_i x_i\\right)
$$
the posterior remains in the same family — a computationally convenient property. When the data are noisy measurements rather than counts, the likelihood is usually a [[Normal distribution]], and the same machinery keeps the posterior normal.
`,
},
{
slug: "golden-ratio",
title: "Golden ratio",
tags: ["number theory", "geometry", "sequences"],
content: `# Golden ratio
The **golden ratio** $\\varphi$ (phi) is the positive number satisfying
$$
\\varphi = 1 + \\frac{1}{\\varphi}
$$
which gives the closed form
$$
\\varphi = \\frac{1 + \\sqrt{5}}{2} = 1.6180339887\\ldots
$$
The $\\sqrt{5}$ is no accident — by the [[Pythagorean theorem]] it is the hypotenuse of a right triangle with legs $1$ and $2$.
## Geometric definition
Two quantities $a$ and $b$ (with $a > b > 0$) are in the golden ratio when
$$
\\frac{a+b}{a} = \\frac{a}{b} = \\varphi
$$
## Connection to Fibonacci
The ratio of consecutive Fibonacci numbers converges to $\\varphi$:
$$
\\lim_{n \\to \\infty} \\frac{F_{n+1}}{F_n} = \\varphi
$$
Using Binet's formula,
$$
F_n = \\frac{\\varphi^n - \\psi^n}{\\sqrt{5}}, \\qquad \\psi = \\frac{1 - \\sqrt{5}}{2} = -\\frac{1}{\\varphi}
$$
## Properties
- $\\varphi^2 = \\varphi + 1$, so $\\varphi^3 = 2\\varphi + 1$, etc.
- $\\dfrac{1}{\\varphi} = \\varphi - 1 = 0.6180\\ldots$
- Continued fraction: $\\varphi = 1 + \\cfrac{1}{1 + \\cfrac{1}{1 + \\cfrac{1}{\\ddots}}}$ — the "most irrational" number, since all partial quotients are 1.
## In geometry
The diagonal of a regular pentagon is $\\varphi$ times its side. In a regular pentagon with side $s$:
$$
d = \\varphi s
$$
The *golden spiral* is formed by quarter-circles inscribed in golden rectangles.
> Beware: many claimed appearances of $\\varphi$ in art and nature (the Parthenon, the nautilus shell) are folklore rather than measurement.
`,
},
{
slug: "fundamental-theorem-of-calculus",
title: "Fundamental theorem of calculus",
tags: ["calculus", "analysis", "foundations"],
content: `# Fundamental theorem of calculus
The **fundamental theorem of calculus** (FTC) links the two central operations of calculus: [[Derivative|differentiation]] and integration.
## Part I
If $f$ is continuous on $[a, b]$ and
$$
F(x) = \\int_a^x f(t)\\, dt
$$
then $F$ is differentiable on $(a, b)$ and
$$
F'(x) = f(x)
$$
## Part II
If $f$ is continuous on $[a, b]$ and $G$ is any antiderivative of $f$, then
$$
\\int_a^b f(x)\\, dx = G(b) - G(a)
$$
## Example
Compute $\\displaystyle\\int_0^1 x^2\\, dx$:
$$
\\int_0^1 x^2\\, dx = \\left[ \\frac{x^3}{3} \\right]_0^1 = \\frac{1}{3} - 0 = \\frac{1}{3}
$$
## Why it matters
- It turns *area computations* into *anti-differentiation* — a purely algebraic task.
- Part I guarantees antiderivatives exist for every continuous function.
- In numerical analysis, the error of quadrature rules is typically expressed through derivatives of the integrand — e.g. the trapezoidal rule error $\\displaystyle \\frac{(b-a)^3}{12} f''(\\xi)$.
## Sketch of the proof of Part I
For $h > 0$:
$$
\\frac{F(x+h) - F(x)}{h} = \\frac{1}{h}\\int_x^{x+h} f(t)\\,dt
$$
Since $f$ is continuous, the average value of $f$ over $[x, x+h]$ lies between $\\min_{[x,x+h]} f$ and $\\max_{[x,x+h]} f$, both of which tend to $f(x)$ as $h \\to 0$. $\\blacksquare$
`,
},
{
slug: "normal-distribution",
title: "Normal distribution",
tags: ["probability", "statistics", "distributions"],
content: `# Normal distribution
The **normal distribution** (or *Gaussian*) with mean $\\mu$ and variance $\\sigma^2$ has density
$$
f(x) = \\frac{1}{\\sigma\\sqrt{2\\pi}} \\, e^{-\\frac{(x-\\mu)^2}{2\\sigma^2}}
$$
## Total probability
A density must integrate to one — the [[Fundamental theorem of calculus]] at work, with the Gaussian integral $\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}$ supplying the normalising constant.
## In Bayesian inference
For noisy measurements the normal is the standard likelihood, and the conjugate-prior machinery of [[Bayes' theorem]] keeps the posterior normal whenever the prior is:
$$
\\mu \\mid \\text{data} \\sim \\mathcal{N}\\!\\left(\\frac{n\\bar{x}/\\sigma^2 + \\mu_0/\\tau^2}{n/\\sigma^2 + 1/\\tau^2},\\; \\left(\\frac{n}{\\sigma^2} + \\frac{1}{\\tau^2}\\right)^{-1}\\right)
$$
## Why it is everywhere
The central limit theorem explains the ubiquity: sums of many small independent effects drift toward the bell curve, whatever the individual effects look like.
`,
},
];
async function seed() {
await connectDB();
let created = 0;
let updated = 0;
for (const a of articles) {
const existing = await Article.findOne({ slug: a.slug });
if (existing) {
// only fill content if article is empty, never clobber user edits
if (!existing.content) {
Object.assign(existing, a);
await existing.save();
updated++;
}
continue;
}
await Article.create(a);
created++;
}
console.log(`Seed complete: ${created} created, ${updated} filled, ${articles.length - created - updated} skipped`);
await disconnectDB();
}
seed().catch(async (err) => {
console.error("Seed failed:", err.message);
await mongoose.disconnect().catch(() => {});
process.exit(1);
});

158
dev.sh Executable file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# dev.sh — start/stop the frontend (Nuxt) and backend (Express) together.
#
# Usage:
# ./dev.sh Start both in the foreground (Ctrl+C stops both)
# ./dev.sh start Start both dev servers in the background
# ./dev.sh stop Stop both dev servers
# ./dev.sh restart Stop then start
# ./dev.sh status Show which services are running
# ./dev.sh logs [service] Follow logs (all, backend, or frontend)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PID_DIR="$ROOT/.pids"
LOG_DIR="$ROOT/.logs"
SERVICES=("backend" "frontend")
pid_of() {
local pid_file="$PID_DIR/$1.pid"
[[ -f "$pid_file" ]] && cat "$pid_file" || true
}
is_running() {
local pid
pid="$(pid_of "$1")"
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
}
start_service() {
local name="$1"
if is_running "$name"; then
echo "$name is already running (PID $(pid_of "$name"))"
return
fi
mkdir -p "$PID_DIR" "$LOG_DIR"
# setsid puts each service in its own process group so `stop` can kill
# npm and all of its child processes (node --watch, nuxt, ...) cleanly.
setsid bash -c 'cd "$1" && exec npm run dev' _ "$ROOT/$name" \
> "$LOG_DIR/$name.log" 2>&1 &
local pid=$!
echo "$pid" > "$PID_DIR/$name.pid"
echo "started $name (PID $pid) — log: $LOG_DIR/$name.log"
}
stop_service() {
local name="$1"
local pid
pid="$(pid_of "$name")"
if ! is_running "$name"; then
echo "$name is not running"
rm -f "$PID_DIR/$name.pid"
return
fi
# Negative PID kills the whole process group (npm + its children).
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
# Wait up to 5s for a graceful shutdown, then force-kill.
for _ in {1..50}; do
kill -0 "$pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
fi
rm -f "$PID_DIR/$name.pid"
echo "stopped $name (PID $pid)"
}
status() {
for name in "${SERVICES[@]}"; do
if is_running "$name"; then
echo "$name: running (PID $(pid_of "$name"))"
else
echo "$name: stopped"
fi
done
}
FG_PIDS=()
SHUTTING=0
# Stop all foreground services (and their process groups). A second call
# (e.g. pressing Ctrl+C again while shutting down) escalates to SIGKILL.
fg_stop_all() {
local name pid sig=TERM
if [[ $SHUTTING -eq 1 ]]; then sig=KILL; else
SHUTTING=1
echo
echo "stopping frontend + backend..."
fi
for pid in "${FG_PIDS[@]:-}"; do
kill -"$sig" -- "-$pid" 2>/dev/null || kill -"$sig" "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
for name in "${SERVICES[@]}"; do rm -f "$PID_DIR/$name.pid"; done
exit 0
}
foreground() {
local name
for name in "${SERVICES[@]}"; do
if is_running "$name"; then
echo "$name is already running (PID $(pid_of "$name")); run '$0 stop' first" >&2
exit 1
fi
done
mkdir -p "$PID_DIR"
for name in "${SERVICES[@]}"; do
# setsid puts each service in its own process group (PID == PGID) so the
# output prefix pipeline survives the shutdown; sed -u tags every line.
setsid bash -c 'cd "$1" && exec npm run dev' _ "$ROOT/$name" \
> >(sed -u "s/^/[$name] /") 2>&1 &
local pid=$!
FG_PIDS+=("$pid")
echo "$pid" > "$PID_DIR/$name.pid"
done
trap 'fg_stop_all' INT TERM
echo "frontend + backend running — press Ctrl+C to stop both"
# Exit as soon as either service dies, so they always run/stop together.
wait -n
echo "a dev server exited — stopping the other one..."
fg_stop_all
}
case "${1:-dev}" in
dev)
foreground
;;
start)
for name in "${SERVICES[@]}"; do start_service "$name"; done
echo
echo "backend: http://localhost:5000"
echo "frontend: http://localhost:3000"
;;
stop)
for name in "${SERVICES[@]}"; do stop_service "$name"; done
;;
restart)
for name in "${SERVICES[@]}"; do stop_service "$name"; done
for name in "${SERVICES[@]}"; do start_service "$name"; done
;;
status)
status
;;
logs)
case "${2:-all}" in
all) tail -f "$LOG_DIR"/*.log ;;
backend|frontend) tail -f "$LOG_DIR/$2.log" ;;
*) echo "usage: $0 logs [all|backend|frontend]"; exit 1 ;;
esac
;;
*)
echo "usage: $0 [dev|start|stop|restart|status|logs [all|backend|frontend]] (default: dev)"
exit 1
;;
esac

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

67
frontend/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Mathew — a wiki for mathematics
A modern math-concept wiki. Articles are written in **Markdown** (with **LaTeX** via KaTeX)
and stored in MongoDB through an Express API.
```
mathew/
├── backend/ Express 5 + Mongoose API (port 5000)
└── frontend/ Nuxt 4 wiki UI (port 3000/3001)
```
## Quick start
```bash
# 1. API (requires MongoDB on localhost:27017)
cd backend
npm install
npm run seed # optional: 6 sample articles (LaTeX included)
npm run dev # → http://localhost:5000
# 2. UI
cd ../frontend
npm install
npm run dev # → http://localhost:3000
```
## Pages
| Route | What it does |
| --- | --- |
| `/` | Search + recently updated articles |
| `/wiki/<slug>` | Rendered article with table of contents |
| `/wiki/<slug>/edit` | Markdown editor with live preview |
| `/new` | Create a new article |
## Markdown + LaTeX
Articles support GitHub-ish Markdown (headings, tables, lists, blockquotes, fenced
code with highlighting) plus LaTeX: `$E = mc^2$` inline and `$$ ... $$` in display
mode. Rendered server-side with `markdown-it` + `markdown-it-texmath` + KaTeX and
sanitized with DOMPurify (raw HTML in articles is escaped).
Handy macros: `\R`, `\N`, `\Z`, `\Q`, `\C` for the number sets.
## API
| Method | Endpoint |
| --- | --- |
| GET | `/api/articles?q=&tag=` — search / filter, no content body |
| GET | `/api/articles/slug/:slug` — one article |
| POST | `/api/articles` — create (`title` required; slug auto-generated & uniquified) |
| PUT | `/api/articles/slug/:slug` — update |
| DELETE | `/api/articles/slug/:slug` — delete |
## Configuration
| Where | Variable | Default | Meaning |
| --- | --- | --- | --- |
| `backend/.env` | `PORT` | `5000` | API port |
| `backend/.env` | `MONGO_URI` | `mongodb://127.0.0.1:27017/mathew` | Mongo connection |
| `frontend` env | `NUXT_PUBLIC_API_BASE` | `http://localhost:5000` | Where the UI calls the API |
## Editing tips
- `/` focuses the search box on the home page; `Ctrl/Cmd+S` saves in the editor.
- The editor has Write / Split / Preview modes; LaTeX and code highlight update live.
- Changing an article's title may change its slug (old links 404 — redirect not implemented).

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

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
@use 'mixins' as *;
// ---------- Articles index ----------
.topic-tally { font-size: 0.85rem; color: var(--muted); }
.topic-dropdown {
@include dropdown-panel;
left: 0;
right: 0;
max-height: 280px;
}
.topic-option {
@include menu-row(8px 12px, 12px, 0.88rem);
justify-content: space-between;
text-align: left;
&:hover, &.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
}
.topic-dropdown-none {
padding: 8px 12px;
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.topic-search-wrap {
position: relative;
margin-bottom: 14px;
svg {
position: absolute;
left: 14px;
top: 50%;
translate: 0 -50%;
color: var(--faint);
pointer-events: none;
}
}
.topic-search {
width: 100%;
height: 40px;
padding: 0 14px 0 40px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
font-size: 0.9rem;
transition: border-color 0.15s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
}
.topic-count {
padding: 1px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: 0.72rem;
font-weight: 700;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
/* Before a reader route draws anything — /wiki/<slug> for an article,
/wiki/<lang>/<slug> for one of its language versions — put the document it
names on the library's shelf. On the server that is what lets the page in
view be written out in the first HTML; on the client it is what the stack of
open pages grows from, and the title the page is filed under. A page that is
already on the shelf comes straight off it and is re-read in the background
— so an edit made elsewhere still shows up when you turn to it. */
/** How a document is filed in the tab stack: which article, which language. */
function tabMeta(article: { slug: string; language?: string; baseSlug?: string | null }) {
return { lang: article.language ?? 'en', identity: article.baseSlug || article.slug }
}
export default defineNuxtRouteMiddleware(async (to) => {
const loc = parseReaderPath(to.path)
if (!loc) return
/* taken before any await, while the app context is certainly at hand */
const library = useArticleLibrary()
const tabs = useArticleTabs()
const onShelf = loc.lang
? library.getVersion(loc.identity, loc.lang)
: library.get(loc.identity)
if (onShelf) {
tabs.ensureTab(onShelf.slug, onShelf.title, tabMeta(onShelf))
library.revalidate(loc.identity, loc.lang ?? undefined)
return
}
const article = loc.lang
? await library.loadVersion(loc.identity, loc.lang)
: await library.load(loc.identity)
if (article) tabs.ensureTab(article.slug, article.title, tabMeta(article))
})

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
/* The wiki's back office: every account it holds, in one list, together with
what each of them may do. The sidebar offers the door to an admin alone;
whoever else reaches this address is turned away before anything is asked —
a member sees the same refusal whether or not the list would have answered. */
import type { AuthRole } from '~/composables/useAuth'
export interface AdminAccount {
id: string
username: string
avatar: string
role: AuthRole
createdAt: string
}
/* matches the home page's transition so the two surfaces slide past
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const { user, loggedIn, isAdmin, authHeaders, signOut } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t, locale } = useI18n()
/* What the last request could not do, in the words to show for it. '' when
there is nothing to report: a dead session is signed out of instead of
explained, the same way the profile popup handles one. Shared state rather
than a ref, so the first draw in the browser agrees with the one the server
already wrote out. */
const failure = useState('admin:failure', () => '')
function describeFailure(err: any): string {
const code = err?.statusCode ?? err?.response?.status
if (code === 401) {
signOut()
if (import.meta.client) openSignIn({ notice: t('admin.sessionExpired') })
return ''
}
return err?.data?.message || t('common.apiUnreachable')
}
/* Only an admin's session is answered here, so nothing is asked until the
session is known to be one — signing in over this page brings the list with
it, which is what the watch below is for. */
const {
data: accounts,
status,
refresh,
} = await useAsyncData(
'admin-accounts',
async () => {
failure.value = ''
if (!isAdmin.value) return []
try {
const { accounts: list } = await $fetch<{ accounts: AdminAccount[] }>(
`${api}/admin/users`,
{ headers: authHeaders() }
)
return list
} catch (err: any) {
failure.value = describeFailure(err)
return []
}
},
{ default: () => [] }
)
watch(isAdmin, (admin) => {
if (admin) void refresh()
})
const loading = computed(() => status.value === 'pending')
const adminCount = computed(() => accounts.value.filter((a) => a.role === 'admin').length)
/* --- handing the role on, taking it back, closing an account ---
One change at a time: while a request is in flight the menu waits, so two
clicks cannot disagree about what an account is. */
const changingId = ref('')
const removing = ref(false)
const actionError = ref('')
async function setRole(account: AdminAccount, role: AuthRole) {
if (changingId.value || removing.value) return
actionError.value = ''
changingId.value = account.id
try {
const { account: updated } = await $fetch<{ account: AdminAccount }>(
`${api}/admin/users/${account.id}/role`,
{ method: 'PUT', headers: authHeaders(), body: { role } }
)
/* the whole list is written again rather than the one row changed in
place: useAsyncData keeps it shallow, so a row would not notice */
accounts.value = accounts.value.map((a) => (a.id === updated.id ? updated : a))
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.roleChangeFailed')
} finally {
changingId.value = ''
}
}
/* Deleting is the one thing on this page that does not come back, so the menu
hands it to the yes/no popup first: the account awaiting an answer, null
when nothing is. */
const removalTarget = ref<AdminAccount | null>(null)
async function removeAccount() {
const account = removalTarget.value
if (!account || removing.value || changingId.value) return
actionError.value = ''
removing.value = true
try {
await $fetch(`${api}/admin/users/${account.id}`, {
method: 'DELETE',
headers: authHeaders()
})
accounts.value = accounts.value.filter((a) => a.id !== account.id)
removalTarget.value = null
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.deleteFailed')
} finally {
removing.value = false
}
}
/* esc answers the yes/no popup the way its Cancel button does: the dashboard has
no other use for the key. */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !removalTarget.value || removing.value) return
removalTarget.value = null
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
/* "12 Mar 2026", in the interface language and read in UTC — the server and
the browser may run on different clocks, and the first render has to say the
same day on both. */
function joined(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(locale.value, {
timeZone: 'UTC',
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
useSeoMeta({
title: () => t('admin.metaTitle'),
description: () => t('admin.metaDescription'),
})
</script>
<template>
<div class="home-main">
<!-- Nobody but an admin is given the list: a visitor without an account is
offered the way in, a signed-in member is told the room is not theirs. -->
<AuthGate
v-if="!loggedIn"
:heading="t('admin.gateHeading')"
:text="t('admin.gateText')"
/>
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="admin-no-access">
<span class="auth-gate-mark" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
</span>
<h1 id="admin-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
<p class="auth-gate-text">
{{ t('admin.forAdminsText') }}
</p>
<div class="auth-gate-actions">
<NuxtLink to="/articles" class="btn btn-ghost">{{ t('admin.backToArticles') }}</NuxtLink>
</div>
</section>
<template v-else>
<section class="hero">
<h1 v-html="t('admin.hero')"></h1>
<p class="sub">{{ t('admin.sub') }}</p>
</section>
<section class="section">
<div v-if="failure" class="banner">
<span class="glyph">!</span>
<span>{{ failure }}</span>
</div>
<template v-else>
<div class="results-bar">
<h2>
<template v-if="loading">{{ t('common.loading') }}</template>
<template v-else>
{{ t('admin.accountCount', accounts.length) }}
</template>
</h2>
<span v-if="!loading && accounts.length" class="topic-tally">
{{ t('admin.adminTally', adminCount) }} ·
{{ t('admin.memberTally', accounts.length - adminCount) }}
</span>
</div>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<div v-if="loading" class="account-list">
<div v-for="i in 4" :key="i" class="skeleton skeleton-row" />
</div>
<ul v-else-if="accounts.length" class="account-list">
<li v-for="account in accounts" :key="account.id" class="account-entry">
<span class="account-avatar" aria-hidden="true">
<img v-if="account.avatar" :src="account.avatar" alt="" />
<template v-else>{{ account.username.charAt(0).toUpperCase() }}</template>
</span>
<span class="account-entry-name">
{{ account.username }}
<span v-if="account.id === user?.id" class="account-entry-you">{{ t('admin.you') }}</span>
</span>
<span class="role-pill" :class="{ 'is-admin': account.role === 'admin' }">
{{ account.role === 'admin' ? t('admin.roleAdmin') : t('admin.roleMember') }}
</span>
<span class="account-entry-since">{{ t('admin.joined', { date: joined(account.createdAt) }) }}</span>
<!-- an admin changing their own role, or closing their own account,
is what the API will not do — so the row says it where the ⋮
would go, instead of offering a menu that could only fail -->
<span v-if="account.id === user?.id" class="account-entry-note">
{{ t('admin.selfNote') }}
</span>
<AccountActionsMenu
v-else
class="account-entry-kebab"
:account-id="account.id"
:username="account.username"
:role="account.role"
:busy="changingId === account.id || removing"
@set-role="setRole(account, $event)"
@remove="removalTarget = account"
/>
</li>
</ul>
<div v-else class="empty">
<div class="glyph">∅</div>
<h3>{{ t('admin.emptyTitle') }}</h3>
<p>{{ t('admin.emptyText') }}</p>
</div>
</template>
</section>
</template>
<ConfirmModal
:open="removalTarget !== null"
:title="t('admin.confirmDeleteTitle')"
:message="removalTarget
? t('admin.confirmDeleteMessage', { username: removalTarget.username })
: ''"
:confirm-label="t('admin.confirmDeleteButton')"
:cancel-label="t('admin.confirmKeepButton')"
@confirm="removeAccount"
@cancel="removalTarget = null"
/>
</div>
</template>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
/* matches the home page's transition so the two surfaces slide past
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
`${api}/articles`,
{ default: () => [] }
)
/* topics are the tags spread across the shelf — each one keeps the
articles that wear it; the same tag written two ways counts as one,
and an article counts once however many languages it wears the tag in */
const topics = computed(() => {
const counts = new Map<string, number>()
for (const article of articles.value ?? []) {
const seen = new Set<string>()
for (const doc of [article, ...(article.versions ?? [])]) {
for (const t of doc.tags ?? []) {
const name = t.trim()
if (!name) continue
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
}
}
return [...counts.entries()]
.sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b))
.map(([name, count]) => ({ name, count }))
})
/* picking a topic filters the article list below — the address bar keeps
the tag so the filtered view can still be shared */
const tag = ref(String(route.query.tag ?? ''))
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
watch([tag, lang], ([tv, lv]) => {
const query: Record<string, string> = {}
if (tv) query.tag = tv
if (lv) query.lang = lv
const current = route.query
if ((current.tag ?? '') === (query.tag ?? '') && (current.lang ?? '') === (query.lang ?? '')) return
router.replace({ query })
})
/* every language the shelf is written in, with counts — an article counts
in each language it exists in, and only once per language */
function languagesOf(article: ArticleMeta): string[] {
const codes = new Set<string>([articleLanguage(article)])
for (const v of article.versions ?? []) codes.add(v.language || 'en')
return [...codes]
}
const languages = computed(() => {
const counts = new Map<string, number>()
for (const article of articles.value ?? []) {
for (const code of languagesOf(article)) counts.set(code, (counts.get(code) ?? 0) + 1)
}
return [...counts.entries()]
.sort(([a], [b]) => languageName(a).localeCompare(languageName(b)))
.map(([code, count]) => ({ code, name: languageName(code), count }))
})
const filteredArticles = computed(() => {
const list = (articles.value ?? []).filter((a) => {
if (
tag.value &&
!([a, ...(a.versions ?? [])] as ArticleMeta[]).some((d) =>
(d.tags ?? []).some((t) => t.trim().toLowerCase() === tag.value.toLowerCase())
)
)
return false
if (lang.value && !languagesOf(a).includes(lang.value)) return false
return true
})
/* in a language view each article is shown as the version written —
or rather translated — in that language */
if (!lang.value) return list
return list.map((a) => {
const v = (a.versions ?? []).find((x) => (x.language || 'en') === lang.value)
return v ? { ...a, ...v, versions: a.versions } : a
})
})
/* the search box doubles as a topic compass: the shelf's topics drop
down while you search, and picking one filters the list below */
const topicQuery = ref('')
const dropdownOpen = ref(false)
const activeIndex = ref(-1)
const searchWrap = ref<HTMLElement | null>(null)
const visibleTopics = computed(() => {
const needle = topicQuery.value.trim().toLowerCase()
if (!needle) return topics.value
return topics.value.filter((t) => t.name.toLowerCase().includes(needle))
})
watch(topicQuery, () => (activeIndex.value = -1))
function pickTopic(topic: { name: string }) {
tag.value = topic.name
topicQuery.value = ''
dropdownOpen.value = false
}
function onInputKeydown(e: KeyboardEvent) {
if (!dropdownOpen.value) {
if (e.key === 'ArrowDown') dropdownOpen.value = true
return
}
if (e.key === 'Escape') dropdownOpen.value = false
else if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = Math.min(activeIndex.value + 1, visibleTopics.value.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
const topic = visibleTopics.value[activeIndex.value] ?? visibleTopics.value[0]
if (topic) pickTopic(topic)
}
}
/* a click anywhere outside the box closes the dropdown */
function onDocClick(e: MouseEvent) {
if (dropdownOpen.value && !searchWrap.value?.contains(e.target as Node))
dropdownOpen.value = false
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
useSeoMeta({
title: () => t('articles.metaTitle'),
description: () => t('articles.metaDescription'),
})
</script>
<template>
<div class="home-main">
<section class="hero">
<h1 v-html="t('articles.hero')"></h1>
<p class="sub">
{{ t('articles.sub') }}
</p>
</section>
<section class="section">
<div v-if="error" class="banner">
<span class="glyph">!</span>
<span v-html="t('common.apiDown')"></span>
</div>
<template v-else>
<div class="results-bar">
<h2>
<template v-if="status === 'pending'">{{ t('common.loading') }}</template>
<template v-else-if="tag || lang">
{{ t('articles.countOfTotal', { shown: filteredArticles.length, total: articles.length }) }}
</template>
<template v-else>{{ t('articles.articleCount', articles.length) }}</template>
</h2>
<span v-if="status !== 'pending' && topics.length" class="topic-tally">
{{ t('articles.topicCount', topics.length) }}
</span>
</div>
<div v-if="topics.length" ref="searchWrap" class="topic-search-wrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" />
</svg>
<input
v-model="topicQuery"
class="topic-search"
type="text"
role="combobox"
:aria-expanded="String(dropdownOpen)"
aria-controls="topic-dropdown"
aria-autocomplete="list"
:placeholder="t('articles.topicSearchPlaceholder')"
:aria-label="t('articles.topicSearchAria')"
@focus="dropdownOpen = true"
@keydown="onInputKeydown"
/>
<div v-if="dropdownOpen" id="topic-dropdown" class="topic-dropdown" role="listbox">
<button
v-for="(topic, i) in visibleTopics"
:key="topic.name"
type="button"
class="topic-option"
:class="{ active: i === activeIndex }"
role="option"
:aria-selected="i === activeIndex"
@click="pickTopic(topic)"
>
<span>{{ topic.name }}</span>
<span class="topic-count">{{ topic.count }}</span>
</button>
<p v-if="!visibleTopics.length" class="topic-dropdown-none">
{{ t('articles.noTopic', { q: topicQuery.trim() }) }}
</p>
</div>
</div>
<div v-if="languages.length > 1" class="lang-row" role="group" :aria-label="t('articles.filterByLanguage')">
<span class="lang-row-label">{{ t('articles.writtenIn') }}</span>
<button
v-for="l in languages"
:key="l.code"
type="button"
class="lang-chip"
:class="{ active: lang === l.code }"
:aria-pressed="lang === l.code"
@click="lang = lang === l.code ? '' : l.code"
>
<span :lang="l.code">{{ l.name }}</span>
<span class="topic-count">{{ l.count }}</span>
</button>
</div>
<div class="results-bar">
<h2>
<template v-if="tag && lang">{{ t('articles.headingTagLang', { tag, lang: languageName(lang) }) }}</template>
<template v-else-if="tag">{{ t('articles.headingTag', { tag }) }}</template>
<template v-else-if="lang">{{ t('articles.headingLang', { lang: languageName(lang) }) }}</template>
<template v-else>{{ t('articles.headingAll') }}</template>
</h2>
<span v-if="tag" class="filter-chip">
{{ t('common.tagChip', { tag }) }}
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
<span v-if="lang" class="filter-chip">
{{ t('common.languageChip', { lang: languageName(lang) }) }}
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
</div>
<div v-if="status === 'pending'" class="articles-grid">
<div v-for="i in 6" :key="i" class="skeleton" />
</div>
<div v-else-if="filteredArticles.length" class="articles-grid">
<ArticleCard v-for="a in filteredArticles" :key="a._id" :article="a" />
</div>
<div v-else class="empty">
<div class="glyph">∅</div>
<template v-if="articles.length">
<h3>
{{ tag
? t('articles.emptyNoTopicTitle')
: lang
? t('articles.emptyNoLangTitle', { lang: languageName(lang) })
: t('articles.emptyNoTopicsTitle') }}
</h3>
<p>
{{ tag || lang
? t('articles.emptyFilteredText')
: t('articles.emptyUntaggedText') }}
</p>
<button v-if="tag || lang" type="button" class="btn btn-ghost" @click="tag = ''; lang = ''">
{{ t('articles.emptyShowAll') }}
</button>
</template>
<template v-else>
<h3>{{ t('articles.emptyNoneTitle') }}</h3>
<p>{{ t('articles.emptyNoneText') }}</p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
</template>
</div>
</template>
</section>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { GraphEdgeDto, GraphNodeDto } from '~/components/GraphView.vue'
export interface GraphDto {
nodes: GraphNodeDto[]
edges: GraphEdgeDto[]
}
/* matches the home page's transition so the two surfaces slide past
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const { t } = useI18n()
const { data, status, error } = await useFetch<GraphDto>(`${api}/articles/graph`, {
default: () => ({ nodes: [], edges: [] }),
})
useSeoMeta({
title: () => t('graph.metaTitle'),
description: () => t('graph.metaDescription'),
})
</script>
<template>
<div class="graph-stage">
<div v-if="error" class="graph-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="t('common.apiDown')"></span>
</div>
</div>
<GraphView
v-else-if="status !== 'pending' && data.nodes.length"
:nodes="data.nodes"
:edges="data.edges"
/>
<div v-else-if="status !== 'pending'" class="graph-state">
<div class="empty">
<div class="glyph">∅</div>
<h3>{{ t('graph.emptyTitle') }}</h3>
<p>{{ t('graph.emptyText') }}</p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
/* matches the article page's transition so opening/closing the reader
slides one surface away as the next arrives (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const q = ref(String(route.query.q ?? ''))
const tag = ref(String(route.query.tag ?? ''))
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
const langLabel = computed(() => (lang.value ? languageName(lang.value) : ''))
/* debounce the search text so we don't fetch on every keystroke */
const debouncedQ = ref(q.value)
let debounceTimer: ReturnType<typeof setTimeout> | undefined
watch(q, (v) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => (debouncedQ.value = v.trim()), 250)
})
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
`${api}/articles`,
{ query: { q: debouncedQ, tag, lang }, default: () => [] }
)
/* keep the address bar in sync with the search */
watch([debouncedQ, tag, lang], ([qv, tv, lv]) => {
const query: Record<string, string> = {}
if (qv) query.q = qv
if (tv) query.tag = tv
if (lv) query.lang = lv
const current = route.query
const same
= (current.q ?? '') === (query.q ?? '') && (current.tag ?? '') === (query.tag ?? '')
&& (current.lang ?? '') === (query.lang ?? '')
if (!same) router.replace({ query })
})
function selectTag(t: string) {
tag.value = tag.value === t ? '' : t
}
/* fetch one article from the API and open it */
const randomPending = ref(false)
async function showRandomArticle() {
if (randomPending.value) return
randomPending.value = true
try {
const article = await $fetch<ArticleMeta>(`${api}/articles/random`)
await router.push(versionHref(identityOf(article), article))
} catch {
// no articles yet or API unreachable — the banner below already covers API issues
} finally {
randomPending.value = false
}
}
/* "/" focuses the search box */
const searchEl = ref<HTMLInputElement | null>(null)
function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
if (e.key === '/' && !typing) {
e.preventDefault()
searchEl.value?.focus()
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
useSeoMeta({
title: () => t('home.metaTitle'),
description: () => t('home.metaDescription'),
})
/* which empty-shelf line to say, depending on the filters in force */
const emptyKey = computed(() =>
tag.value && lang.value
? 'home.emptyTagLang'
: tag.value
? 'home.emptyTag'
: lang.value
? 'home.emptyLang'
: 'home.emptyPlain'
)
</script>
<template>
<div class="home-main">
<section class="hero">
<h1 v-html="t('home.hero')"></h1>
<div class="search-wrap">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" />
</svg>
<input
ref="searchEl"
v-model="q"
class="search-input"
type="text"
:placeholder="t('home.searchPlaceholder')"
:aria-label="t('common.searchArticles')"
/>
<span class="search-kbd">/</span>
</div>
<div class="hero-actions">
<NuxtLink to="/new" class="btn btn-primary">{{ t('home.writeNew') }}</NuxtLink>
<button
type="button"
class="btn btn-ghost"
:disabled="randomPending"
@click="showRandomArticle"
>
{{ t('home.random') }}
</button>
</div>
</section>
<section class="section">
<div class="results-bar">
<h2>
<template v-if="debouncedQ || tag || lang">
{{ t('home.results', articles.length) }}
</template>
<template v-else>{{ t('home.recentlyUpdated') }}</template>
</h2>
<span v-if="tag" class="filter-chip">
{{ t('common.tagChip', { tag }) }}
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
<span v-if="lang" class="filter-chip">
{{ t('common.languageChip', { lang: langLabel }) }}
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
</div>
<div v-if="error" class="banner">
<span class="glyph">!</span>
<span v-html="t('common.apiDown')"></span>
</div>
<div v-else-if="status === 'pending'" class="articles-grid">
<div v-for="i in 6" :key="i" class="skeleton" />
</div>
<div v-else-if="articles.length" class="articles-grid">
<ArticleCard v-for="a in articles" :key="a._id" :article="a" />
</div>
<div v-else class="empty">
<div class="glyph">∅</div>
<h3>{{ t('home.emptyTitle') }}</h3>
<p>{{ t(emptyKey, { tag, lang: langLabel }) }}</p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
</div>
</section>
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
/* The article route is a thin host for the book that ArticleBookStack draws.
The open-article middleware has already laid the article on the library's
shelf — that is what the page in view is made of, and what the stack of
open pages is filed under — so all that is left to do here is speak for the
URL: the title, and the visitor's "recently viewed" line.
When nothing lives at the address, this is what says so, over the top of
whatever else is open. */
definePageMeta({ middleware: 'open-article' })
const route = useRoute()
const slug = computed(() => String(route.params.slug))
const { t } = useI18n()
const { th } = useAppLocale()
const { get, unreadable } = useArticleLibrary()
const article = computed(() => get(slug.value))
const { record } = useRecentlyViewed()
watch(
article,
(a) => {
if (import.meta.client && a?.slug && a?.title) record({ slug: a.slug, title: a.title })
},
{ immediate: true }
)
useSeoMeta({
title: () => article.value?.title ?? t('card.kickerArticle'),
})
/* what the shelf could not bring in, the API said why */
const missingStatus = computed(() => (article.value ? 0 : unreadable.value[slug.value] ?? 0))
</script>
<template>
<div class="reader-host">
<div v-if="!article" class="reader-note">
<div class="empty" style="padding-top: 64px">
<template v-if="missingStatus === 404">
<div class="glyph">∄</div>
<h3>{{ t('reader.notFoundTitle') }}</h3>
<p v-html="th('reader.notFoundText', { path: `/wiki/${slug}` })"></p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
</template>
<template v-else>
<div class="glyph">!</div>
<h3>{{ t('reader.loadFailTitle') }}</h3>
<p v-html="t('common.apiShort')"></p>
<NuxtLink to="/" class="btn btn-ghost">{{ t('common.backHome') }}</NuxtLink>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
/* The wiki is open to readers, but writing takes an account, and the page must
know which side of that line it is on before it draws anything: the sidebar's
account row and the editor's sign-in gate are both part of the first render,
on the server as much as in the browser. So a stored session is checked here,
once, before any page runs. With no cookie in the request it costs nothing. */
export default defineNuxtPlugin(async () => {
const { user, restoreSession } = useAuth()
await restoreSession()
/* The account's interface language outranks the device's own — the choice
follows the person — so the very first render speaks it. (setLocale keeps
the detection cookie in step, so the next load starts there even before
this runs.) */
if (user.value?.locale) await useAppLocale().applyAccount(user.value.locale)
})

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
/* Route-shape helpers for the reader experience.
An article route is a page of the open book drawn by ArticleBookStack —
pages laid out side by side, older ones parked with their spines showing —
while the article edit page and the new-article page remain plain,
full-height pages. An article lives at /wiki/<slug>; one of its language
versions at /wiki/<lang>/<slug>, e.g. /wiki/fr/bayes-theorem. */
const READER_PATH = /^\/wiki\/([^/]+?)\/?$/
const VERSION_READER_PATH = /^\/wiki\/([a-z]{2})\/([^/]+?)\/?$/
const WIKI_SUB_PATH = /^\/wiki\/[^/]+\/[^/]+/
/** What a reader address names: the article's identity, and which language
* version the address asks for (null for the article's own version). */
export interface ReaderLocation {
identity: string
lang: string | null
}
export function parseReaderPath(path: string): ReaderLocation | null {
const version = VERSION_READER_PATH.exec(path)
// /wiki/<x>/edit is the editor whatever <x> is — never a version address
if (version && version[2]!.toLowerCase() !== 'edit') {
return { identity: decodeURIComponent(version[2]!), lang: version[1]! }
}
const plain = READER_PATH.exec(path)
if (plain) return { identity: decodeURIComponent(plain[1]!), lang: null }
return null
}
export function isReaderPath(path: string): boolean {
return parseReaderPath(path) !== null
}
export function wikiSlugFromPath(path: string): string | null {
return parseReaderPath(path)?.identity ?? null
}
/**
* The address of one language version: the canonical document lives at the
* article's own URL, translations at /wiki/<lang>/<identity>.
*/
export function versionHref(identity: string, version: { language?: string; slug: string }): string {
if (version.slug === identity) return `/wiki/${encodeURIComponent(identity)}`
return `/wiki/${encodeURIComponent(version.language ?? 'en')}/${encodeURIComponent(identity)}`
}
/**
* The "reading surface": the reader itself plus the edit/new-article pages —
* the pages that belong to the open book. The stack of open pages survives the
* whole session either way; this marks the pages outside it (the list, the
* graph, home), which are the places worth returning to once the last page is
* closed.
*/
export function isReadingSurface(path: string): boolean {
return isReaderPath(path) || WIKI_SUB_PATH.test(path) || path === '/new'
}

View File

@@ -0,0 +1,8 @@
/* Vue I18n options for the wiki's interface text. The locale list, the
message files and the detection (cookie `mathew-locale`) are configured
in nuxt.config.ts under `i18n`. */
export default defineI18nConfig(() => ({
legacy: false,
// An untranslated key falls back to English rather than echoing itself.
fallbackLocale: 'en',
}))

View File

@@ -0,0 +1,357 @@
{
"app": {
"title": "Mathew · L'enciclopèdia de matemàtiques"
},
"common": {
"close": "Tanca",
"cancel": "Cancel·la",
"confirm": "Confirma",
"delete": "Elimina",
"edit": "Edita",
"loading": "Carregant…",
"signOut": "Tanca la sessió",
"backHome": "Torna a l'inici",
"startWriting": "Comença a escriure",
"searchArticles": "Cerca articles",
"closeTab": "Tanca la pestanya",
"areYouSure": "N'estàs segur?",
"apiUnreachable": "No s'ha pogut connectar amb la wiki. Està en marxa el backend?",
"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": {
"home": "Inici de Mathew",
"search": "Cerca…",
"open": "Oberts",
"openAria": "Article obert ({n}) | Articles oberts ({n})",
"openTitle": "{n} article obert — ensenya'ls | {n} articles oberts — ensenya'ls",
"articles": "Articles",
"graph": "Graf",
"admin": "Admin",
"adminHint": "Administració — els comptes d'aquesta wiki",
"newArticle": "Article nou",
"signedInAs": "Sessió de {name} — perfil i compte",
"signInHint": "Iniciar sessió — llegir és lliure, escriure necessita un compte",
"collapse": "Controu la barra lateral",
"expand": "Expandeix la barra lateral",
"collapseLabel": "Controu"
},
"home": {
"metaTitle": "L'enciclopèdia de matemàtiques",
"metaDescription": "Una wiki moderna i col·laborativa de conceptes matemàtics — escrita en Markdown i renderitzada amb LaTeX.",
"hero": "La wiki de les <span class=\"gradient\">matemàtiques</span>",
"searchPlaceholder": "Cerca conceptes — prova «euler», «calculus», «probability»…",
"writeNew": "Escriure un article nou",
"random": "Mostrar un article a l'atzar",
"results": "{n} resultat | {n} resultats",
"recentlyUpdated": "Actualitzats recentment",
"emptyTitle": "No s'han trobat articles",
"emptyPlain": "Res no coincideix amb la teva cerca. Sigues el primer — escriu l'article.",
"emptyTag": "Res no coincideix amb la teva cerca de «{tag}». Sigues el primer — escriu l'article.",
"emptyLang": "Res no coincideix amb la teva cerca en {lang}. Sigues el primer — escriu l'article.",
"emptyTagLang": "Res no coincideix amb la teva cerca de «{tag}» en {lang}. Sigues el primer — escriu l'article."
},
"articles": {
"metaTitle": "Articles",
"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",
"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",
"emptyFilteredText": "Aquest racó de la prestatgeria és ara entre articles.",
"emptyUntaggedText": "Cap article porta etiqueta. N'afegeix una a l'editor i apareixerà aquí.",
"emptyShowAll": "Mostra tots els articles",
"emptyNoneTitle": "Encara no hi ha articles",
"emptyNoneText": "La prestatgeria és buida. Sigues el primer — escriu l'article."
},
"admin": {
"metaTitle": "Administració",
"metaDescription": "Tots els comptes de la wiki — qui hi és i què pot fer, cadascun.",
"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.",
"gateHeading": "Inicia sessió per administrar",
"gateText": "Aquesta pàgina enumera tots els comptes de la wiki, per això es reserva a qui l'administra. Inicia sessió amb un compte que tingui aquest rol.",
"forAdmins": "Només per a administradors",
"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",
"you": "tu",
"roleAdmin": "Admin",
"roleMember": "Membre",
"joined": "Alta: {date}",
"selfNote": "un altre administrador et canvia el rol o et elimina",
"emptyTitle": "Encara no hi ha comptes",
"emptyText": "Ningú ha obert un compte a la wiki. El primer serà el seu administrador.",
"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"
},
"graph": {
"metaTitle": "Graf",
"metaDescription": "Tota la wiki com un mapa — cada article un node, cada enllaç entre ells una aresta.",
"emptyTitle": "Res a connectar encara",
"emptyText": "El graf necessita almenys un article. Escriu el primer — i mira com apareix.",
"fit": "Ajusta el graf a la pantalla",
"zoomIn": "Acosta",
"zoomOut": "Allunya",
"relayout": "Recalcula la disposició"
},
"reader": {
"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",
"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ó."
},
"editor": {
"gateStart": "Inicia sessió per començar un article",
"gateEdit": "Inicia sessió per editar «{title}»",
"newTitle": "Article nou",
"translateTitle": "Traduir l'article",
"editTitle": "Editar l'article",
"metaTranslate": "Traduir {title}",
"metaEditing": "Editant {title}",
"nothingToEdit": "Res a editar aquí",
"nothingToTranslate": "Res a traduir",
"noArticleAt": "No hi ha cap article a <code>{path}</code>.",
"createInstead": "Crear-lo en canvi",
"backToSearch": "Torna a la cerca",
"discardHint": "Desfés totes les edicions des de l'últim desat i surt de l'editor",
"discard": "Descarta els canvis",
"discardConfirm": "Vols descartar els canvis?",
"discardNew": "Es perdrà tot el que has escrit fins ara.",
"discardVersions": "Es perdran les edicions de {n} versions lingüèstiques fetes des de l'últim desat.",
"discardOne": "Es perdran les edicions de «{title}» fetes des de l'últim desat.",
"keepEditing": "Continua editant",
"saving": "Desant…",
"publishTranslation": "Publicar la traducció",
"publishArticle": "Publicar l'article",
"saveVersions": "Desar {n} versions",
"saveChanges": "Desar els canvis",
"translating": "Traduint",
"translatingTail": "— l'original ja és a l'editor; escriu-hi la teva versió a sobre.",
"languages": "Idiomes",
"editVersion": "Edita la versió en {name}",
"unsavedChanges": "canvis sense desar",
"addLanguage": "+ Idioma",
"addLanguageHint": "Afegeix l'article en un altre idioma",
"titleLabel": "Títol",
"titlePlaceholder": "p. ex. La desigualtat de Cauchy–Schwarz",
"urlLabel": "URL:",
"tagsLabel": "Etiquetes",
"tagsHint": "— separades per comes",
"tagsPlaceholder": "àlgebra lineal, desigualtats, demostracions",
"languageLabel": "Idioma",
"languageHint": "— l'idioma d'aquesta versió",
"alreadyWritten": "— ja escrit",
"languageTaken": "Aquest article ja existeix en aquest idioma",
"contentLabel": "Contingut",
"contentHint": "— Markdown amb LaTeX ($inline$ i $$display$$)",
"seedHeading": "Comença amb un títol",
"seedTerms": "Defineix els termes i enuncia el resultat:",
"modeWrite": "Escriure",
"modeSplit": "Partit",
"modePreview": "Vista prèvia",
"cheatsheet": "Full d'ajuda",
"cheatsheetHint": "Full d'ajuda — LaTeX, enllaços, Markdown",
"fullscreen": "Pantalla completa",
"exitFullscreenHint": "Sortir de pantalla completa (Esc)",
"enterFullscreen": "Entrar en pantalla completa",
"exitFullscreen": "Sortir de pantalla completa",
"livePreview": "Vista prèvia en directe",
"noPreview": "Res a previsualitzar encara.",
"versionNeedsTitle": "La versió en {name} necessita un títol.",
"titleRequired": "Cal un títol.",
"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"
},
"cheatsheet": {
"title": "Full d'ajuda",
"closeAria": "Tanca el full d'ajuda",
"links": "Enllaços",
"linkByTitle": "enllaç a un article per títol",
"linkOwnWording": "enllaç amb les teves paraules",
"linkWebPage": "enllaç a una pàgina web",
"bareUrl": "un URL solt esdevé un enllaç",
"math": "Matemàtiques (LaTeX)",
"inline": "en línia",
"display": "fórmula en bloc, a la seva pròpia línia",
"numberSets": "conjunts numèrics",
"sumsEtc": "sumes, integrals, fraccions, arrels",
"text": "Text",
"boldItalic": "<strong>negreta</strong> i <em>cursiva</em>",
"struckOut": "<s>ratllat</s>",
"shadedCode": "fragment de codi ombrejat",
"fencedBlock": "bloc de codi amb ressaltat",
"blocks": "Blocs",
"headings": "encapçalaments — ## i ### omplen l'índex",
"bulletList": "llista amb pics",
"numberedList": "llista numerada",
"blockquote": "cita",
"tableRows": "files de taula, amb una regla --- de capçalera",
"hr": "regla horitzontal",
"shortcuts": "Dreceres",
"saveShortcut": "desa l'article",
"tabIndent": "sagna a l'editor",
"escHelp": "tanca aquesta ajuda i després surt de pantalla completa"
},
"settings": {
"title": "Configuració",
"closeAria": "Tanca la configuració",
"appearance": "Aparença",
"appearanceHint": "Tria com es veu Mathew per a tu.",
"themeAria": "Tema de color",
"themeLight": "Clar",
"themeDark": "Fosc",
"accent": "Color d'accente",
"accentHint": "S'usa a enllaços, botons i realçats.",
"accentName": "accente {name}",
"accentViolet": "violeta",
"accentPurple": "porpra",
"accentOcean": "oceà",
"accentTeal": "verd blavós",
"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"
},
"auth": {
"signin": "Iniciar sessió",
"signup": "Crear un compte",
"signupTab": "Crear compte",
"signupSubmit": "Crear el compte i iniciar sessió",
"busy": "Un moment…",
"tabsAria": "Inici de sessió",
"username": "Nom d'usuari",
"usernamePlaceholder": "p. ex. emmy_noether",
"password": "Contrasenya",
"passwordHint": "— com a mínim 8 caràcters",
"haveAccount": "Ja tens compte?",
"newToMathew": "Nou a Mathew?",
"expiredProfile": "La sessió ha caducat — inicia-la de nou per canviar el teu compte."
},
"gate": {
"heading": "Inicia sessió per escriure",
"text": "La wiki és de lectura lliure per a tothom. Un compte és el que et permet afegir-hi coses.",
"fine": "Encara no tens compte? Crear-ne un només necessita un nom i una contrasenya."
},
"profile": {
"title": "Perfil",
"closeAria": "Tanca el perfil",
"picture": "Foto de perfil",
"pictureHint": "Es mostra al costat del teu nom. Retallada en cercle.",
"uploading": "Pujant…",
"changePicture": "Canviar la foto",
"choosePicture": "Triar foto",
"remove": "Treure",
"readFileFailed": "No s'ha pogut llegir aquest fitxer.",
"notReadable": "Aquest fitxer no és una imatge que el navegador pugui llegir.",
"drawFailed": "El navegador no ha pogut dibuixar aquesta imatge.",
"notAnImage": "Això no és un fitxer d'imatge.",
"uploadFailed": "No s'ha pogut pujar aquesta imatge.",
"readFailed": "No s'ha pogut llegir aquesta imatge.",
"removeFailed": "No s'ha pogut treure la imatge.",
"username": "Nom d'usuari",
"usernameHint": "Lletres, números, punt, guió o guió baix — de 3 a 30 caràcters.",
"saving": "Desant…",
"save": "Desa",
"nameRequired": "Cal un nom.",
"nameFailed": "No s'ha pogut canviar el nom.",
"nameSaved": "Nom desat.",
"password": "Contrasenya",
"passwordHint": "Com a mínim 8 caràcters.",
"currentPassword": "Contrasenya actual",
"newPassword": "Contrasenya nova",
"changePassword": "Canviar la contrasenya",
"passwordFailed": "No s'ha pogut canviar la contrasenya.",
"passwordChanged": "Contrasenya canviada."
},
"search": {
"placeholder": "Cerca articles per títol o etiqueta…",
"clear": "Esborra la cerca",
"apiDown": "No s'ha pogut connectar amb l'API — està en marxa el backend?",
"noMatch": "Cap article coincideix amb <strong>«{q}»</strong>.",
"idle": "Comença a escriure per cercar a la wiki.",
"navigate": "navega",
"open": "obre",
"close": "tanca",
"allResults": "Tots els resultats de «{q}»"
},
"recent": {
"title": "Vists recentment",
"aria": "Articles vistos recentment"
},
"toc": {
"title": "Contingut",
"aria": "Índex de continguts"
},
"card": {
"kickerArticle": "Article",
"kickerTranslation": "Traducció",
"existsIn": "Existeix en: {names}",
"updated": "actualitzat el {date}"
},
"pane": {
"aria": "Article: {title}",
"spineHint": "{title} · fes clic per tornar a aquesta pàgina · esc per tancar",
"readFailTitle": "Aquesta pàgina no s'ha pogut llegir",
"readFailText": "L'article a <code>{path}</code> no ha respost. Està en marxa el backend?",
"lastEdited": "Última edició: {date}",
"topicsLabel": "Temes:"
},
"book": {
"openAria": "Articles oberts",
"chipHint": "{title} · fes clic per obrir · esc per tancar",
"dockAria": "Pàgines que esperen al llibre",
"dockHint": "{title} · fes clic per tornar a aquesta pàgina",
"deleteConfirm": "Vols eliminar «{title}» per sempre? Això no es pot desfer.",
"deleteFailed": "No s'ha pogut eliminar l'article. Està en marxa el backend?"
},
"articleActions": {
"aria": "Accions de l'article"
},
"accountActions": {
"aria": "Accions del compte",
"forUser": "Accions de {username}",
"removeAdmin": "Treure admin",
"makeAdmin": "Fer admin"
},
"langMenu": {
"hintSignedIn": "Llegir aquest article en un altre idioma — o escriure'n un",
"hint": "Llegir aquest article en un altre idioma",
"aria": "Idiomes",
"readIn": "Llegir en",
"writeIn": "Escriure en",
"writeArticleIn": "Escriure aquest article en {name}"
}
}

View File

@@ -0,0 +1,357 @@
{
"app": {
"title": "Mathew · Das Mathematik-Lexikon"
},
"common": {
"close": "Schließen",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"delete": "Löschen",
"edit": "Bearbeiten",
"loading": "Lädt…",
"signOut": "Abmelden",
"backHome": "Zurück zur Startseite",
"startWriting": "Fang an zu schreiben",
"searchArticles": "Artikel durchsuchen",
"closeTab": "Tab schließen",
"areYouSure": "Bist du sicher?",
"apiUnreachable": "Die Wiki war nicht zu erreichen. Läuft das Backend?",
"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": {
"home": "Mathew-Startseite",
"search": "Suchen…",
"open": "Offen",
"openAria": "Geöffneter Artikel ({n}) | Geöffnete Artikel ({n})",
"openTitle": "{n} geöffneter Artikel — zeigen | {n} geöffnete Artikel — zeigen",
"articles": "Artikel",
"graph": "Graph",
"admin": "Admin",
"adminHint": "Verwaltung — die Konten in dieser Wiki",
"newArticle": "Neuer Artikel",
"signedInAs": "Angemeldet als {name} — Profil und Konto",
"signInHint": "Anmelden — Lesen ist frei, Schreiben braucht ein Konto",
"collapse": "Seitenleiste einklappen",
"expand": "Seitenleiste ausklappen",
"collapseLabel": "Einklappen"
},
"home": {
"metaTitle": "Das Mathematik-Lexikon",
"metaDescription": "Ein modernes, kollaboratives Wiki mathematischer Konzepte — in Markdown geschrieben und mit LaTeX gerendert.",
"hero": "Das Wiki der <span class=\"gradient\">Mathematik</span>",
"searchPlaceholder": "Suche Konzepte — versuch „euler“, „calculus“, “probability”…",
"writeNew": "Neuen Artikel schreiben",
"random": "Zufälligen Artikel zeigen",
"results": "{n} Ergebnis | {n} Ergebnisse",
"recentlyUpdated": "Kürzlich aktualisiert",
"emptyTitle": "Keine Artikel gefunden",
"emptyPlain": "Nichts passt zu deiner Suche. Sei der Erste — schreib den Artikel.",
"emptyTag": "Nichts passt zu deiner Suche nach „{tag}“. Sei der Erste — schreib den Artikel.",
"emptyLang": "Nichts passt zu deiner Suche auf {lang}. Sei der Erste — schreib den Artikel.",
"emptyTagLang": "Nichts passt zu deiner Suche nach „{tag}“ auf {lang}. Sei der Erste — schreib den Artikel."
},
"articles": {
"metaTitle": "Artikel",
"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",
"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",
"emptyFilteredText": "Diese Ecke des Regals ist gerade zwischen Artikeln.",
"emptyUntaggedText": "Kein Artikel trägt ein Stichwort. Füge eins im Artikel-Editor hinzu, dann erscheint es hier.",
"emptyShowAll": "Alle Artikel zeigen",
"emptyNoneTitle": "Noch keine Artikel",
"emptyNoneText": "Das Regal ist leer. Sei der Erste — schreib den Artikel."
},
"admin": {
"metaTitle": "Verwaltung",
"metaDescription": "Alle Konten der Wiki — wer hier ist und was jeder darf.",
"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.",
"gateHeading": "Anmelden zum Verwalten",
"gateText": "Diese Seite listet alle Konten der Wiki und bleibt deshalb den Verwaltern vorbehalten. Melde dich mit einem Konto an, das diese Rolle hat.",
"forAdmins": "Nur für Administratoren",
"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",
"you": "du",
"roleAdmin": "Admin",
"roleMember": "Mitglied",
"joined": "Beigetreten am {date}",
"selfNote": "ein anderer Administrator ändert deine Rolle oder entfernt dich",
"emptyTitle": "Noch keine Konten",
"emptyText": "Niemand hat bisher ein Konto in der Wiki eröffnet. Das erste wird ihr Administrator.",
"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"
},
"graph": {
"metaTitle": "Graph",
"metaDescription": "Die ganze Wiki als Karte — jeder Artikel ein Knoten, jede Verlinkung eine Kante.",
"emptyTitle": "Noch nichts zu verbinden",
"emptyText": "Der Graph braucht mindestens einen Artikel. Schreib den ersten — und sieh ihn entstehen.",
"fit": "Graph an den Bildschirm anpassen",
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern",
"relayout": "Anordnung neu berechnen"
},
"reader": {
"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",
"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."
},
"editor": {
"gateStart": "Melde dich an, um einen Artikel zu beginnen",
"gateEdit": "Melde dich an, um „{title}“ zu bearbeiten",
"newTitle": "Neuer Artikel",
"translateTitle": "Artikel übersetzen",
"editTitle": "Artikel bearbeiten",
"metaTranslate": "{title} übersetzen",
"metaEditing": "Bearbeitung von {title}",
"nothingToEdit": "Hier gibt es nichts zu bearbeiten",
"nothingToTranslate": "Nichts zu übersetzen",
"noArticleAt": "Unter <code>{path}</code> ist kein Artikel.",
"createInstead": "Ihn stattdessen anlegen",
"backToSearch": "Zurück zur Suche",
"discardHint": "Alle Änderungen seit der letzten Speicherung rückgängig machen und den Editor verlassen",
"discard": "Änderungen verwerfen",
"discardConfirm": "Änderungen verwerfen?",
"discardNew": "Alles, was du bisher geschrieben hast, geht verloren.",
"discardVersions": "Die Änderungen an {n} Sprachversionen seit der letzten Speicherung gehen verloren.",
"discardOne": "Die Änderungen an „{title}“ seit der letzten Speicherung gehen verloren.",
"keepEditing": "Weiter bearbeiten",
"saving": "Speichert…",
"publishTranslation": "Übersetzung veröffentlichen",
"publishArticle": "Artikel veröffentlichen",
"saveVersions": "{n} Versionen speichern",
"saveChanges": "Änderungen speichern",
"translating": "Übersetzung von",
"translatingTail": "— das Original liegt schon im Editor; schreib deine Version darüber.",
"languages": "Sprachen",
"editVersion": "Die Version auf {name} bearbeiten",
"unsavedChanges": "ungespeicherte Änderungen",
"addLanguage": "+ Sprache",
"addLanguageHint": "Den Artikel in einer weiteren Sprache anlegen",
"titleLabel": "Titel",
"titlePlaceholder": "z. B. Die Cauchy–Schwarz-Ungleichung",
"urlLabel": "URL:",
"tagsLabel": "Stichwörter",
"tagsHint": "— durch Kommas getrennt",
"tagsPlaceholder": "lineare algebra, ungleichungen, beweise",
"languageLabel": "Sprache",
"languageHint": "— die Sprache dieser Version",
"alreadyWritten": "— schon geschrieben",
"languageTaken": "Dieser Artikel existiert schon in dieser Sprache",
"contentLabel": "Inhalt",
"contentHint": "— Markdown mit LaTeX ($inline$ und $$display$$)",
"seedHeading": "Beginne mit einer Überschrift",
"seedTerms": "Definiere die Begriffe, dann formuliere das Ergebnis:",
"modeWrite": "Schreiben",
"modeSplit": "Geteilt",
"modePreview": "Vorschau",
"cheatsheet": "Schreib-Spickzettel",
"cheatsheetHint": "Schreib-Spickzettel — LaTeX, Links, Markdown",
"fullscreen": "Vollbild",
"exitFullscreenHint": "Vollbild verlassen (Esc)",
"enterFullscreen": "In den Vollbildmodus",
"exitFullscreen": "Vollbild verlassen",
"livePreview": "Live-Vorschau",
"noPreview": "Noch nichts zum Voranschauen.",
"versionNeedsTitle": "Die Version auf {name} braucht einen Titel.",
"titleRequired": "Ein Titel ist erforderlich.",
"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"
},
"cheatsheet": {
"title": "Schreib-Spickzettel",
"closeAria": "Spickzettel schließen",
"links": "Links",
"linkByTitle": "Link zu einem Artikel über den Titel",
"linkOwnWording": "Link mit eigenen Worten",
"linkWebPage": "Link zu einer Webseite",
"bareUrl": "eine nackte URL wird zum Link",
"math": "Mathematik (LaTeX)",
"inline": "inline",
"display": "Formel im Blocksatz, in eigener Zeile",
"numberSets": "Zahlenmengen",
"sumsEtc": "Summen, Integrale, Brüche, Wurzeln",
"text": "Text",
"boldItalic": "<strong>fett</strong> und <em>kursiv</em>",
"struckOut": "<s>durchgestrichen</s>",
"shadedCode": "schattierter Codeausschnitt",
"fencedBlock": "Codeblock mit Hervorhebung",
"blocks": "Blöcke",
"headings": "Überschriften — ## und ### füllen das Inhaltsverzeichnis",
"bulletList": "Aufzählung",
"numberedList": "nummerierte Liste",
"blockquote": "Zitatblock",
"tableRows": "Tabellenzeilen, mit einer --- Kopfzeilenregel",
"hr": "horizontale Linie",
"shortcuts": "Tastenkürzel",
"saveShortcut": "den Artikel speichern",
"tabIndent": "im Editor einrücken",
"escHelp": "diese Hilfe schließen, dann den Vollbildmodus verlassen"
},
"settings": {
"title": "Einstellungen",
"closeAria": "Einstellungen schließen",
"appearance": "Erscheinungsbild",
"appearanceHint": "Wähle, wie Mathew für dich aussieht.",
"themeAria": "Farbthema",
"themeLight": "Hell",
"themeDark": "Dunkel",
"accent": "Akzentfarbe",
"accentHint": "Für Links, Buttons und Hervorhebungen.",
"accentName": "Akzent {name}",
"accentViolet": "Violett",
"accentPurple": "Purpur",
"accentOcean": "Ozean",
"accentTeal": "Petrolia",
"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"
},
"auth": {
"signin": "Anmelden",
"signup": "Konto erstellen",
"signupTab": "Konto erstellen",
"signupSubmit": "Konto erstellen und anmelden",
"busy": "Einen Moment…",
"tabsAria": "Anmeldung",
"username": "Benutzername",
"usernamePlaceholder": "z. B. emmy_noether",
"password": "Passwort",
"passwordHint": "— mindestens 8 Zeichen",
"haveAccount": "Hast du schon ein Konto?",
"newToMathew": "Neu bei Mathew?",
"expiredProfile": "Diese Sitzung ist abgelaufen — melde dich erneut an, um dein Konto zu ändern."
},
"gate": {
"heading": "Anmelden zum Schreiben",
"text": "Die Wiki steht allen zum Lesen offen. Ein Konto ist das, was dich beitragen lässt.",
"fine": "Noch kein Konto? Eines zu brauchen nur einen Benutzernamen und ein Passwort."
},
"profile": {
"title": "Profil",
"closeAria": "Profil schließen",
"picture": "Profilbild",
"pictureHint": "Neben deinem Namen. Auf einen Kreis beschnitten.",
"uploading": "Lädt hoch…",
"changePicture": "Bild ändern",
"choosePicture": "Bild wählen",
"remove": "Entfernen",
"readFileFailed": "Diese Datei konnte nicht gelesen werden.",
"notReadable": "Diese Datei ist kein Bild, das der Browser lesen kann.",
"drawFailed": "Der Browser konnte dieses Bild nicht zeichnen.",
"notAnImage": "Das ist keine Bilddatei.",
"uploadFailed": "Dieses Bild konnte nicht hochgeladen werden.",
"readFailed": "Dieses Bild konnte nicht gelesen werden.",
"removeFailed": "Das Bild konnte nicht entfernt werden.",
"username": "Benutzername",
"usernameHint": "Buchstaben, Ziffern, Punkt, Bindestrich oder Unterstrich — 3 bis 30 Zeichen.",
"saving": "Speichert…",
"save": "Speichern",
"nameRequired": "Ein Name ist erforderlich.",
"nameFailed": "Der Name konnte nicht geändert werden.",
"nameSaved": "Name gespeichert.",
"password": "Passwort",
"passwordHint": "Mindestens 8 Zeichen.",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"changePassword": "Passwort ändern",
"passwordFailed": "Das Passwort konnte nicht geändert werden.",
"passwordChanged": "Passwort geändert."
},
"search": {
"placeholder": "Artikel nach Titel oder Stichwort durchsuchen…",
"clear": "Suche löschen",
"apiDown": "Die API war nicht zu erreichen — läuft das Backend?",
"noMatch": "Kein Artikel passt zu <strong>„{q}“</strong>.",
"idle": "Fang an zu tippen, um die Wiki zu durchsuchen.",
"navigate": "navigieren",
"open": "öffnen",
"close": "schließen",
"allResults": "Alle Ergebnisse für „{q}“"
},
"recent": {
"title": "Zuletzt angesehen",
"aria": "Zuletzt angesehene Artikel"
},
"toc": {
"title": "Inhaltsverzeichnis",
"aria": "Inhaltsverzeichnis"
},
"card": {
"kickerArticle": "Artikel",
"kickerTranslation": "Übersetzung",
"existsIn": "Existiert auf: {names}",
"updated": "aktualisiert am {date}"
},
"pane": {
"aria": "Artikel: {title}",
"spineHint": "{title} · klicken, um zu dieser Seite zu blättern · esc zum Schließen",
"readFailTitle": "Diese Seite konnte nicht gelesen werden",
"readFailText": "Der Artikel unter <code>{path}</code> hat nicht geantwortet. Läuft das Backend?",
"lastEdited": "Zuletzt bearbeitet am {date}",
"topicsLabel": "Themen:"
},
"book": {
"openAria": "Geöffnete Artikel",
"chipHint": "{title} · klicken zum Öffnen · esc zum Schließen",
"dockAria": "Wartende Seiten im Buch",
"dockHint": "{title} · klicken, um zu dieser Seite zu blättern",
"deleteConfirm": "„{title}“ endgültig löschen? Das lässt sich nicht rückgängig machen.",
"deleteFailed": "Der Artikel konnte nicht gelöscht werden. Läuft das Backend?"
},
"articleActions": {
"aria": "Artikelaktionen"
},
"accountActions": {
"aria": "Kontoaktionen",
"forUser": "Aktionen für {username}",
"removeAdmin": "Admin entfernen",
"makeAdmin": "Zum Admin machen"
},
"langMenu": {
"hintSignedIn": "Diesen Artikel in einer anderen Sprache lesen — oder eine schreiben",
"hint": "Diesen Artikel in einer anderen Sprache lesen",
"aria": "Sprachen",
"readIn": "Lesen auf",
"writeIn": "Schreiben auf",
"writeArticleIn": "Diesen Artikel auf {name} schreiben"
}
}

View File

@@ -0,0 +1,357 @@
{
"app": {
"title": "Mathew · The Mathematics Wiki"
},
"common": {
"close": "Close",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"loading": "Loading…",
"signOut": "Sign out",
"backHome": "Back home",
"startWriting": "Start writing",
"searchArticles": "Search articles",
"closeTab": "Close tab",
"areYouSure": "Are you sure?",
"apiUnreachable": "Could not reach the wiki. Is the backend running?",
"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": {
"home": "Mathew home",
"search": "Search…",
"open": "Open",
"openAria": "Open article ({n}) | Open articles ({n})",
"openTitle": "{n} open article — show them | {n} open articles — show them",
"articles": "Articles",
"graph": "Graph",
"admin": "Admin",
"adminHint": "Admin — the accounts on this wiki",
"newArticle": "New article",
"signedInAs": "Signed in as {name} — profile & account",
"signInHint": "Sign in — reading is open, writing takes an account",
"collapse": "Collapse sidebar",
"expand": "Expand sidebar",
"collapseLabel": "Collapse"
},
"home": {
"metaTitle": "The Mathematics Wiki",
"metaDescription": "A modern, collaborative wiki of mathematical concepts — written in Markdown and rendered with LaTeX.",
"hero": "The wiki of <span class=\"gradient\">mathematics</span>",
"searchPlaceholder": "Search concepts — try “euler”, “calculus”, “probability”…",
"writeNew": "Write a new article",
"random": "Show a random article",
"results": "{n} result | {n} results",
"recentlyUpdated": "Recently updated",
"emptyTitle": "No articles found",
"emptyPlain": "Nothing matches your search. Be the first — write the article.",
"emptyTag": "Nothing matches your search for “{tag}”. Be the first — write the article.",
"emptyLang": "Nothing matches your search in {lang}. Be the first — write the article.",
"emptyTagLang": "Nothing matches your search for “{tag}” in {lang}. Be the first — write the article."
},
"articles": {
"metaTitle": "Articles",
"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",
"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",
"emptyFilteredText": "This corner of the shelf is between articles right now.",
"emptyUntaggedText": "None of the articles carry a tag. Add one in the article editor and it will show up here.",
"emptyShowAll": "Show all articles",
"emptyNoneTitle": "No articles yet",
"emptyNoneText": "The shelf is empty. Be the first — write the article."
},
"admin": {
"metaTitle": "Admin",
"metaDescription": "Every account on the wiki — who is here, and what each of them may do.",
"sessionExpired": "That session has expired — sign in again to open the dashboard.",
"roleChangeFailed": "Could not change that role.",
"deleteFailed": "Could not delete that account.",
"gateHeading": "Sign in to administer",
"gateText": "This page lists every account on the wiki, so it is kept for the people who administer it. Sign in with an account that holds that role.",
"forAdmins": "For administrators",
"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",
"you": "you",
"roleAdmin": "Admin",
"roleMember": "Member",
"joined": "Joined {date}",
"selfNote": "another admin changes your role, or removes you",
"emptyTitle": "No accounts yet",
"emptyText": "Nobody has opened an account on the wiki. The first one becomes its admin.",
"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"
},
"graph": {
"metaTitle": "Graph",
"metaDescription": "The whole wiki as a map — every article a node, every link between them an edge.",
"emptyTitle": "Nothing to connect yet",
"emptyText": "The graph needs at least one article. Write the first — then watch it appear.",
"fit": "Fit the graph to the screen",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"relayout": "Re-run the layout"
},
"reader": {
"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",
"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."
},
"editor": {
"gateStart": "Sign in to start an article",
"gateEdit": "Sign in to edit “{title}”",
"newTitle": "New article",
"translateTitle": "Translate article",
"editTitle": "Edit article",
"metaTranslate": "Translate {title}",
"metaEditing": "Editing {title}",
"nothingToEdit": "Nothing to edit here",
"nothingToTranslate": "Nothing to translate",
"noArticleAt": "No article lives at <code>{path}</code>.",
"createInstead": "Create it instead",
"backToSearch": "Back to search",
"discardHint": "Undo every edit made since the last save and leave the editor",
"discard": "Discard changes",
"discardConfirm": "Discard changes?",
"discardNew": "Everything you have written so far will be lost.",
"discardVersions": "Edits to {n} language versions made since the last save will be lost.",
"discardOne": "Edits to “{title}” made since the last save will be lost.",
"keepEditing": "Keep editing",
"saving": "Saving…",
"publishTranslation": "Publish translation",
"publishArticle": "Publish article",
"saveVersions": "Save {n} versions",
"saveChanges": "Save changes",
"translating": "Translating",
"translatingTail": "— the original is already in the editor; write your version over it.",
"languages": "Languages",
"editVersion": "Edit the {name} version",
"unsavedChanges": "unsaved changes",
"addLanguage": "+ Language",
"addLanguageHint": "Add the article in another language",
"titleLabel": "Title",
"titlePlaceholder": "e.g. The Cauchy–Schwarz inequality",
"urlLabel": "URL:",
"tagsLabel": "Tags",
"tagsHint": "— comma separated",
"tagsPlaceholder": "linear algebra, inequalities, proofs",
"languageLabel": "Language",
"languageHint": "— the language of this version",
"alreadyWritten": "— already written",
"languageTaken": "This article already exists in this language",
"contentLabel": "Content",
"contentHint": "— Markdown with LaTeX ($inline$ and $$display$$)",
"seedHeading": "Start with a heading",
"seedTerms": "Define terms, then state the result:",
"modeWrite": "Write",
"modeSplit": "Split",
"modePreview": "Preview",
"cheatsheet": "Writing cheatsheet",
"cheatsheetHint": "Writing cheatsheet — LaTeX, links, Markdown",
"fullscreen": "Fullscreen",
"exitFullscreenHint": "Exit fullscreen (Esc)",
"enterFullscreen": "Enter fullscreen",
"exitFullscreen": "Exit fullscreen",
"livePreview": "Live preview",
"noPreview": "Nothing to preview yet.",
"versionNeedsTitle": "The {name} version needs a title.",
"titleRequired": "A title is required.",
"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"
},
"cheatsheet": {
"title": "Writing cheatsheet",
"closeAria": "Close cheatsheet",
"links": "Links",
"linkByTitle": "link to an article by title",
"linkOwnWording": "link with your own wording",
"linkWebPage": "link to a web page",
"bareUrl": "a bare URL becomes a link",
"math": "Math (LaTeX)",
"inline": "inline",
"display": "display maths, on its own line",
"numberSets": "number sets",
"sumsEtc": "sums, integrals, fractions, roots",
"text": "Text",
"boldItalic": "<strong>bold</strong> and <em>italic</em>",
"struckOut": "<s>struck out</s>",
"shadedCode": "shaded code fragment",
"fencedBlock": "fenced block with highlighting",
"blocks": "Blocks",
"headings": "headings — ## and ### fill the TOC",
"bulletList": "bulleted list",
"numberedList": "numbered list",
"blockquote": "block quote",
"tableRows": "table rows, with a --- header rule",
"hr": "horizontal rule",
"shortcuts": "Shortcuts",
"saveShortcut": "save the article",
"tabIndent": "indent in the editor",
"escHelp": "close this help, then leave fullscreen"
},
"settings": {
"title": "Settings",
"closeAria": "Close settings",
"appearance": "Appearance",
"appearanceHint": "Choose how Mathew looks to you.",
"themeAria": "Color theme",
"themeLight": "Light",
"themeDark": "Dark",
"accent": "Accent colour",
"accentHint": "Used for links, buttons and highlights.",
"accentName": "{name} accent",
"accentViolet": "Violet",
"accentPurple": "Purple",
"accentOcean": "Ocean",
"accentTeal": "Teal",
"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"
},
"auth": {
"signin": "Sign in",
"signup": "Create an account",
"signupTab": "Create account",
"signupSubmit": "Create account & sign in",
"busy": "One moment…",
"tabsAria": "Signing in",
"username": "Username",
"usernamePlaceholder": "e.g. emmy_noether",
"password": "Password",
"passwordHint": "— at least 8 characters",
"haveAccount": "Already have an account?",
"newToMathew": "New to Mathew?",
"expiredProfile": "That session has expired — sign in again to change your account."
},
"gate": {
"heading": "Sign in to write",
"text": "The wiki is open to everyone to read. An account is what lets you add to it.",
"fine": "No account yet? Creating one takes a username and a password."
},
"profile": {
"title": "Profile",
"closeAria": "Close profile",
"picture": "Profile picture",
"pictureHint": "Shown beside your name. Cropped to a circle.",
"uploading": "Uploading…",
"changePicture": "Change picture",
"choosePicture": "Choose picture",
"remove": "Remove",
"readFileFailed": "Could not read that file.",
"notReadable": "That file is not a picture the browser can read.",
"drawFailed": "The browser could not draw that picture.",
"notAnImage": "That is not an image file.",
"uploadFailed": "Could not upload that picture.",
"readFailed": "Could not read that picture.",
"removeFailed": "Could not remove the picture.",
"username": "Username",
"usernameHint": "Letters, numbers, dot, dash or underscore — 3 to 30 characters.",
"saving": "Saving…",
"save": "Save",
"nameRequired": "A name is required.",
"nameFailed": "Could not change the name.",
"nameSaved": "Name saved.",
"password": "Password",
"passwordHint": "At least 8 characters.",
"currentPassword": "Current password",
"newPassword": "New password",
"changePassword": "Change password",
"passwordFailed": "Could not change the password.",
"passwordChanged": "Password changed."
},
"search": {
"placeholder": "Search articles by title or tag…",
"clear": "Clear search",
"apiDown": "Could not reach the API — is the backend running?",
"noMatch": "No articles match <strong>“{q}”</strong>.",
"idle": "Start typing to search the wiki.",
"navigate": "navigate",
"open": "open",
"close": "close",
"allResults": "All results for “{q}”"
},
"recent": {
"title": "Recently viewed",
"aria": "Recently viewed articles"
},
"toc": {
"title": "Contents",
"aria": "Table of contents"
},
"card": {
"kickerArticle": "Article",
"kickerTranslation": "Translation",
"existsIn": "Exists in: {names}",
"updated": "updated {date}"
},
"pane": {
"aria": "Article: {title}",
"spineHint": "{title} · click to turn to this page · esc to close",
"readFailTitle": "This page could not be read",
"readFailText": "The article at <code>{path}</code> did not answer. Is the backend running?",
"lastEdited": "Last edited {date}",
"topicsLabel": "Topics:"
},
"book": {
"openAria": "Open articles",
"chipHint": "{title} · click to open · esc to close",
"dockAria": "Pages waiting in the book",
"dockHint": "{title} · click to turn to this page",
"deleteConfirm": "Delete “{title}” permanently? This cannot be undone.",
"deleteFailed": "Could not delete the article. Is the backend running?"
},
"articleActions": {
"aria": "Article actions"
},
"accountActions": {
"aria": "Account actions",
"forUser": "Actions for {username}",
"removeAdmin": "Remove admin",
"makeAdmin": "Make admin"
},
"langMenu": {
"hintSignedIn": "Read this article in another language — or write one",
"hint": "Read this article in another language",
"aria": "Languages",
"readIn": "Read in",
"writeIn": "Write in",
"writeArticleIn": "Write this article in {name}"
}
}

View File

@@ -0,0 +1,357 @@
{
"app": {
"title": "Mathew · La enciclopedia de matemáticas"
},
"common": {
"close": "Cerrar",
"cancel": "Cancelar",
"confirm": "Confirmar",
"delete": "Eliminar",
"edit": "Editar",
"loading": "Cargando…",
"signOut": "Cerrar sesión",
"backHome": "Volver al inicio",
"startWriting": "Empieza a escribir",
"searchArticles": "Buscar artículos",
"closeTab": "Cerrar pestaña",
"areYouSure": "¿Seguro?",
"apiUnreachable": "No se pudo conectar con la wiki. ¿Está el backend en marcha?",
"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": {
"home": "Inicio de Mathew",
"search": "Buscar…",
"open": "Abiertos",
"openAria": "Artículo abierto ({n}) | Artículos abiertos ({n})",
"openTitle": "{n} artículo abierto — mostrarlos | {n} artículos abiertos — mostrarlos",
"articles": "Artículos",
"graph": "Grafo",
"admin": "Admin",
"adminHint": "Administración — las cuentas de esta wiki",
"newArticle": "Artículo nuevo",
"signedInAs": "Sesión de {name} — perfil y cuenta",
"signInHint": "Iniciar sesión — leer es libre, escribir necesita una cuenta",
"collapse": "Contraer la barra lateral",
"expand": "Expandir la barra lateral",
"collapseLabel": "Contraer"
},
"home": {
"metaTitle": "La enciclopedia de matemáticas",
"metaDescription": "Una wiki moderna y colaborativa de conceptos matemáticos — escrita en Markdown y renderizada con LaTeX.",
"hero": "La wiki de las <span class=\"gradient\">matemáticas</span>",
"searchPlaceholder": "Busca conceptos — prueba «euler», «calculus», «probability»…",
"writeNew": "Escribir un artículo nuevo",
"random": "Mostrar un artículo al azar",
"results": "{n} resultado | {n} resultados",
"recentlyUpdated": "Actualizados recientemente",
"emptyTitle": "No se han encontrado artículos",
"emptyPlain": "Nada coincide con tu búsqueda. Sé el primero — escribe el artículo.",
"emptyTag": "Nada coincide con tu búsqueda de «{tag}». Sé el primero — escribe el artículo.",
"emptyLang": "Nada coincide con tu búsqueda en {lang}. Sé el primero — escribe el artículo.",
"emptyTagLang": "Nada coincide con tu búsqueda de «{tag}» en {lang}. Sé el primero — escribe el artículo."
},
"articles": {
"metaTitle": "Artículos",
"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",
"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",
"emptyFilteredText": "Este rincón de la estantería está ahora entre artículos.",
"emptyUntaggedText": "Ningún artículo lleva etiqueta. Añade una en el editor y aparecerá aquí.",
"emptyShowAll": "Mostrar todos los artículos",
"emptyNoneTitle": "Todavía no hay artículos",
"emptyNoneText": "La estantería está vacía. Sé el primero — escribe el artículo."
},
"admin": {
"metaTitle": "Administración",
"metaDescription": "Todas las cuentas de la wiki — quién está aquí y qué puede hacer cada una.",
"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.",
"gateHeading": "Inicia sesión para administrar",
"gateText": "Esta página enumera todas las cuentas de la wiki, así que está reservada a quienes la administran. Inicia sesión con una cuenta que tenga ese rol.",
"forAdmins": "Solo para administradores",
"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",
"you": "tú",
"roleAdmin": "Admin",
"roleMember": "Miembro",
"joined": "Alta: {date}",
"selfNote": "otro administrador cambia tu rol o te elimina",
"emptyTitle": "Todavía no hay cuentas",
"emptyText": "Nadie ha abierto una cuenta en la wiki. La primera será su administradora.",
"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"
},
"graph": {
"metaTitle": "Grafo",
"metaDescription": "Toda la wiki como un mapa — cada artículo un nodo, cada enlace entre ellos una arista.",
"emptyTitle": "Nada que conectar todavía",
"emptyText": "El grafo necesita al menos un artículo. Escribe el primero — y míralo aparecer.",
"fit": "Ajustar el grafo a la pantalla",
"zoomIn": "Acercar",
"zoomOut": "Alejar",
"relayout": "Recalcular la disposición"
},
"reader": {
"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",
"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."
},
"editor": {
"gateStart": "Inicia sesión para empezar un artículo",
"gateEdit": "Inicia sesión para editar «{title}»",
"newTitle": "Artículo nuevo",
"translateTitle": "Traducir artículo",
"editTitle": "Editar artículo",
"metaTranslate": "Traducir {title}",
"metaEditing": "Editando {title}",
"nothingToEdit": "Nada que editar aquí",
"nothingToTranslate": "Nada que traducir",
"noArticleAt": "No hay ningún artículo en <code>{path}</code>.",
"createInstead": "Crearlo en su lugar",
"backToSearch": "Volver a la búsqueda",
"discardHint": "Deshacer todas las ediciones desde el último guardado y salir del editor",
"discard": "Descartar cambios",
"discardConfirm": "¿Descartar los cambios?",
"discardNew": "Se perderá todo lo que has escrito hasta ahora.",
"discardVersions": "Se perderán las ediciones de {n} versiones idiomáticas hechas desde el último guardado.",
"discardOne": "Se perderán las ediciones de «{title}» hechas desde el último guardado.",
"keepEditing": "Seguir editando",
"saving": "Guardando…",
"publishTranslation": "Publicar la traducción",
"publishArticle": "Publicar el artículo",
"saveVersions": "Guardar {n} versiones",
"saveChanges": "Guardar cambios",
"translating": "Traduciendo",
"translatingTail": "— el original ya está en el editor; escribe tu versión encima.",
"languages": "Idiomas",
"editVersion": "Editar la versión en {name}",
"unsavedChanges": "cambios sin guardar",
"addLanguage": "+ Idioma",
"addLanguageHint": "Añadir el artículo en otro idioma",
"titleLabel": "Título",
"titlePlaceholder": "p. ej. La desigualdad de Cauchy–Schwarz",
"urlLabel": "URL:",
"tagsLabel": "Etiquetas",
"tagsHint": "— separadas por comas",
"tagsPlaceholder": "álgebra lineal, desigualdades, demostraciones",
"languageLabel": "Idioma",
"languageHint": "— el idioma de esta versión",
"alreadyWritten": "— ya escrito",
"languageTaken": "Este artículo ya existe en este idioma",
"contentLabel": "Contenido",
"contentHint": "— Markdown con LaTeX ($inline$ y $$display$$)",
"seedHeading": "Empieza con un título",
"seedTerms": "Define los términos y enuncia el resultado:",
"modeWrite": "Escribir",
"modeSplit": "Dividido",
"modePreview": "Vista previa",
"cheatsheet": "Chuleta de escritura",
"cheatsheetHint": "Chuleta de escritura — LaTeX, enlaces, Markdown",
"fullscreen": "Pantalla completa",
"exitFullscreenHint": "Salir de pantalla completa (Esc)",
"enterFullscreen": "Entrar en pantalla completa",
"exitFullscreen": "Salir de pantalla completa",
"livePreview": "Vista previa en vivo",
"noPreview": "Nada que previsualizar todavía.",
"versionNeedsTitle": "La versión en {name} necesita un título.",
"titleRequired": "Se necesita un título.",
"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"
},
"cheatsheet": {
"title": "Chuleta de escritura",
"closeAria": "Cerrar la chuleta",
"links": "Enlaces",
"linkByTitle": "enlace a un artículo por título",
"linkOwnWording": "enlace con tus propias palabras",
"linkWebPage": "enlace a una página web",
"bareUrl": "una URL suelta se convierte en enlace",
"math": "Matemáticas (LaTeX)",
"inline": "en línea",
"display": "fórmula en bloque, en su propia línea",
"numberSets": "conjuntos numéricos",
"sumsEtc": "sumas, integrales, fracciones, raíces",
"text": "Texto",
"boldItalic": "<strong>negrita</strong> y <em>cursiva</em>",
"struckOut": "<s>tachado</s>",
"shadedCode": "fragmento de código sombreado",
"fencedBlock": "bloque de código con resaltado",
"blocks": "Bloques",
"headings": "encabezados — ## y ### llenan el índice",
"bulletList": "lista con viñetas",
"numberedList": "lista numerada",
"blockquote": "cita",
"tableRows": "filas de tabla, con una regla --- de cabecera",
"hr": "regla horizontal",
"shortcuts": "Atajos",
"saveShortcut": "guardar el artículo",
"tabIndent": "sangrar en el editor",
"escHelp": "cerrar esta ayuda y luego salir de pantalla completa"
},
"settings": {
"title": "Ajustes",
"closeAria": "Cerrar los ajustes",
"appearance": "Apariencia",
"appearanceHint": "Elige cómo se ve Mathew para ti.",
"themeAria": "Tema de color",
"themeLight": "Claro",
"themeDark": "Oscuro",
"accent": "Color de acento",
"accentHint": "Se usa en enlaces, botones y resaltados.",
"accentName": "acento {name}",
"accentViolet": "violeta",
"accentPurple": "púrpura",
"accentOcean": "océano",
"accentTeal": "verde azulado",
"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"
},
"auth": {
"signin": "Iniciar sesión",
"signup": "Crear una cuenta",
"signupTab": "Crear cuenta",
"signupSubmit": "Crear cuenta e iniciar sesión",
"busy": "Un momento…",
"tabsAria": "Inicio de sesión",
"username": "Nombre de usuario",
"usernamePlaceholder": "p. ej. emmy_noether",
"password": "Contraseña",
"passwordHint": "— al menos 8 caracteres",
"haveAccount": "¿Ya tienes cuenta?",
"newToMathew": "¿Nuevo en Mathew?",
"expiredProfile": "La sesión ha caducado — inicia sesión otra vez para cambiar tu cuenta."
},
"gate": {
"heading": "Inicia sesión para escribir",
"text": "La wiki es de lectura libre para todo el mundo. Una cuenta es lo que permite añadirle cosas.",
"fine": "¿Aún no tienes cuenta? Crear una solo necesita un nombre y una contraseña."
},
"profile": {
"title": "Perfil",
"closeAria": "Cerrar el perfil",
"picture": "Foto de perfil",
"pictureHint": "Se muestra junto a tu nombre. Recortada en círculo.",
"uploading": "Subiendo…",
"changePicture": "Cambiar la foto",
"choosePicture": "Elegir foto",
"remove": "Quitar",
"readFileFailed": "No se pudo leer ese archivo.",
"notReadable": "Ese archivo no es una imagen que el navegador pueda leer.",
"drawFailed": "El navegador no ha podido dibujar esa imagen.",
"notAnImage": "Ese no es un archivo de imagen.",
"uploadFailed": "No se pudo subir esa imagen.",
"readFailed": "No se pudo leer esa imagen.",
"removeFailed": "No se pudo quitar la imagen.",
"username": "Nombre de usuario",
"usernameHint": "Letras, números, punto, guion o guion bajo — de 3 a 30 caracteres.",
"saving": "Guardando…",
"save": "Guardar",
"nameRequired": "Se necesita un nombre.",
"nameFailed": "No se pudo cambiar el nombre.",
"nameSaved": "Nombre guardado.",
"password": "Contraseña",
"passwordHint": "Al menos 8 caracteres.",
"currentPassword": "Contraseña actual",
"newPassword": "Contraseña nueva",
"changePassword": "Cambiar la contraseña",
"passwordFailed": "No se pudo cambiar la contraseña.",
"passwordChanged": "Contraseña cambiada."
},
"search": {
"placeholder": "Busca artículos por título o etiqueta…",
"clear": "Borrar la búsqueda",
"apiDown": "No se pudo conectar con la API — ¿está el backend en marcha?",
"noMatch": "Ningún artículo coincide con <strong>«{q}»</strong>.",
"idle": "Empieza a escribir para buscar en la wiki.",
"navigate": "navegar",
"open": "abrir",
"close": "cerrar",
"allResults": "Todos los resultados de «{q}»"
},
"recent": {
"title": "Vistos recientemente",
"aria": "Artículos vistos recientemente"
},
"toc": {
"title": "Contenido",
"aria": "Índice de contenidos"
},
"card": {
"kickerArticle": "Artículo",
"kickerTranslation": "Traducción",
"existsIn": "Existe en: {names}",
"updated": "actualizado el {date}"
},
"pane": {
"aria": "Artículo: {title}",
"spineHint": "{title} · haz clic para volver a esta página · esc para cerrar",
"readFailTitle": "Esta página no se pudo leer",
"readFailText": "El artículo en <code>{path}</code> no respondió. ¿Está el backend en marcha?",
"lastEdited": "Última edición: {date}",
"topicsLabel": "Temas:"
},
"book": {
"openAria": "Artículos abiertos",
"chipHint": "{title} · haz clic para abrir · esc para cerrar",
"dockAria": "Páginas que esperan en el libro",
"dockHint": "{title} · haz clic para volver a esta página",
"deleteConfirm": "¿Eliminar «{title}» definitivamente? Esto no se puede deshacer.",
"deleteFailed": "No se pudo eliminar el artículo. ¿Está el backend en marcha?"
},
"articleActions": {
"aria": "Acciones del artículo"
},
"accountActions": {
"aria": "Acciones de la cuenta",
"forUser": "Acciones de {username}",
"removeAdmin": "Quitar admin",
"makeAdmin": "Hacer admin"
},
"langMenu": {
"hintSignedIn": "Leer este artículo en otro idioma — o escribir uno",
"hint": "Leer este artículo en otro idioma",
"aria": "Idiomas",
"readIn": "Leer en",
"writeIn": "Escribir en",
"writeArticleIn": "Escribir este artículo en {name}"
}
}

View File

@@ -0,0 +1,357 @@
{
"app": {
"title": "Mathew · L'encyclopédie des mathématiques"
},
"common": {
"close": "Fermer",
"cancel": "Annuler",
"confirm": "Confirmer",
"delete": "Supprimer",
"edit": "Modifier",
"loading": "Chargement…",
"signOut": "Se déconnecter",
"backHome": "Retour à l'accueil",
"startWriting": "Commencer à écrire",
"searchArticles": "Rechercher des articles",
"closeTab": "Fermer l'onglet",
"areYouSure": "Êtes-vous sûr ?",
"apiUnreachable": "Impossible de joindre le wiki. Le backend est-il en marche ?",
"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": {
"home": "Accueil de Mathew",
"search": "Rechercher…",
"open": "Ouverts",
"openAria": "Article ouvert ({n}) | Articles ouverts ({n})",
"openTitle": "{n} article ouvert — les afficher | {n} articles ouverts — les afficher",
"articles": "Articles",
"graph": "Graphe",
"admin": "Admin",
"adminHint": "Administration — les comptes de ce wiki",
"newArticle": "Nouvel article",
"signedInAs": "Connecté en tant que {name} — profil et compte",
"signInHint": "Se connecter — la lecture est libre, l'écriture demande un compte",
"collapse": "Replier la barre latérale",
"expand": "Déplier la barre latérale",
"collapseLabel": "Replier"
},
"home": {
"metaTitle": "L'encyclopédie des mathématiques",
"metaDescription": "Un wiki moderne et collaboratif de concepts mathématiques — écrit en Markdown et rendu avec LaTeX.",
"hero": "Le wiki des <span class=\"gradient\">mathématiques</span>",
"searchPlaceholder": "Cherchez des concepts — essayez « euler », « calculus », « probability »…",
"writeNew": "Écrire un nouvel article",
"random": "Afficher un article au hasard",
"results": "{n} résultat | {n} résultats",
"recentlyUpdated": "Mis à jour récemment",
"emptyTitle": "Aucun article trouvé",
"emptyPlain": "Rien ne correspond à votre recherche. Soyez le premier — écrivez l'article.",
"emptyTag": "Rien ne correspond à votre recherche pour « {tag} ». Soyez le premier — écrivez l'article.",
"emptyLang": "Rien ne correspond à votre recherche en {lang}. Soyez le premier — écrivez l'article.",
"emptyTagLang": "Rien ne correspond à votre recherche pour « {tag} » en {lang}. Soyez le premier — écrivez l'article."
},
"articles": {
"metaTitle": "Articles",
"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",
"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",
"emptyFilteredText": "Ce coin de la bibliothèque est entre deux articles pour l'instant.",
"emptyUntaggedText": "Aucun article ne porte d'étiquette. Ajoutez-en une dans l'éditeur et elle apparaîtra ici.",
"emptyShowAll": "Afficher tous les articles",
"emptyNoneTitle": "Pas encore d'articles",
"emptyNoneText": "La bibliothèque est vide. Soyez le premier — écrivez l'article."
},
"admin": {
"metaTitle": "Administration",
"metaDescription": "Tous les comptes du wiki — qui est là, et ce que chacun peut faire.",
"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.",
"gateHeading": "Connectez-vous pour administrer",
"gateText": "Cette page liste tous les comptes du wiki ; elle est donc réservée à ceux qui l'administrent. Connectez-vous avec un compte titulaire de ce rôle.",
"forAdmins": "Réservé aux administrateurs",
"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",
"you": "vous",
"roleAdmin": "Admin",
"roleMember": "Membre",
"joined": "Inscrit le {date}",
"selfNote": "un autre administrateur change votre rôle, ou vous retire",
"emptyTitle": "Pas encore de comptes",
"emptyText": "Personne n'a encore ouvert de compte sur le wiki. Le premier en devient l'administrateur.",
"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"
},
"graph": {
"metaTitle": "Graphe",
"metaDescription": "Tout le wiki comme une carte — chaque article un nœud, chaque lien entre eux une arête.",
"emptyTitle": "Rien à relier pour l'instant",
"emptyText": "Le graphe a besoin d'au moins un article. Écrivez le premier — puis regardez-le apparaître.",
"fit": "Ajuster le graphe à l'écran",
"zoomIn": "Zoomer",
"zoomOut": "Dézoomer",
"relayout": "Recalculer la disposition"
},
"reader": {
"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",
"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."
},
"editor": {
"gateStart": "Connectez-vous pour commencer un article",
"gateEdit": "Connectez-vous pour modifier « {title} »",
"newTitle": "Nouvel article",
"translateTitle": "Traduire l'article",
"editTitle": "Modifier l'article",
"metaTranslate": "Traduire {title}",
"metaEditing": "Modification de {title}",
"nothingToEdit": "Rien à modifier ici",
"nothingToTranslate": "Rien à traduire",
"noArticleAt": "Aucun article ne vit à <code>{path}</code>.",
"createInstead": "Le créer à la place",
"backToSearch": "Retour à la recherche",
"discardHint": "Annuler toutes les modifications depuis la dernière sauvegarde et quitter l'éditeur",
"discard": "Annuler les modifications",
"discardConfirm": "Annuler les modifications ?",
"discardNew": "Tout ce que vous avez écrit jusqu'ici sera perdu.",
"discardVersions": "Les modifications apportées à {n} versions linguistiques depuis la dernière sauvegarde seront perdues.",
"discardOne": "Les modifications apportées à « {title} » depuis la dernière sauvegarde seront perdues.",
"keepEditing": "Continuer d'éditer",
"saving": "Enregistrement…",
"publishTranslation": "Publier la traduction",
"publishArticle": "Publier l'article",
"saveVersions": "Enregistrer {n} versions",
"saveChanges": "Enregistrer les modifications",
"translating": "Traduction de",
"translatingTail": "— l'original est déjà dans l'éditeur ; écrivez votre version par-dessus.",
"languages": "Langues",
"editVersion": "Modifier la version en {name}",
"unsavedChanges": "modifications non enregistrées",
"addLanguage": "+ Langue",
"addLanguageHint": "Ajouter l'article dans une autre langue",
"titleLabel": "Titre",
"titlePlaceholder": "p. ex. L'inégalité de Cauchy–Schwarz",
"urlLabel": "URL :",
"tagsLabel": "Étiquettes",
"tagsHint": "— séparées par des virgules",
"tagsPlaceholder": "algèbre linéaire, inégalités, démonstrations",
"languageLabel": "Langue",
"languageHint": "— la langue de cette version",
"alreadyWritten": "— déjà écrit",
"languageTaken": "Cet article existe déjà dans cette langue",
"contentLabel": "Contenu",
"contentHint": "— Markdown avec LaTeX ($inline$ et $$display$$)",
"seedHeading": "Commencez par un titre",
"seedTerms": "Définissez les termes, puis énoncez le résultat :",
"modeWrite": "Écrire",
"modeSplit": "Scindé",
"modePreview": "Aperçu",
"cheatsheet": "Aide-mémoire d'écriture",
"cheatsheetHint": "Aide-mémoire — LaTeX, liens, Markdown",
"fullscreen": "Plein écran",
"exitFullscreenHint": "Quitter le plein écran (Esc)",
"enterFullscreen": "Passer en plein écran",
"exitFullscreen": "Quitter le plein écran",
"livePreview": "Aperçu en direct",
"noPreview": "Rien à prévisualiser pour l'instant.",
"versionNeedsTitle": "La version en {name} a besoin d'un titre.",
"titleRequired": "Un titre est requis.",
"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"
},
"cheatsheet": {
"title": "Aide-mémoire d'écriture",
"closeAria": "Fermer l'aide-mémoire",
"links": "Liens",
"linkByTitle": "lien vers un article par son titre",
"linkOwnWording": "lien avec votre propre formulation",
"linkWebPage": "lien vers une page web",
"bareUrl": "une URL nue devient un lien",
"math": "Maths (LaTeX)",
"inline": "en ligne",
"display": "formule affichée, sur sa propre ligne",
"numberSets": "ensembles de nombres",
"sumsEtc": "sommes, intégrales, fractions, racines",
"text": "Texte",
"boldItalic": "<strong>gras</strong> et <em>italique</em>",
"struckOut": "<s>barré</s>",
"shadedCode": "fragment de code ombré",
"fencedBlock": "bloc de code avec coloration",
"blocks": "Blocs",
"headings": "titres — ## et ### remplissent la table des matières",
"bulletList": "liste à puces",
"numberedList": "liste numérotée",
"blockquote": "citation",
"tableRows": "lignes de tableau, avec une règle --- d'en-tête",
"hr": "règle horizontale",
"shortcuts": "Raccourcis",
"saveShortcut": "enregistrer l'article",
"tabIndent": "indenter dans l'éditeur",
"escHelp": "fermer cette aide, puis quitter le plein écran"
},
"settings": {
"title": "Réglages",
"closeAria": "Fermer les réglages",
"appearance": "Apparence",
"appearanceHint": "Choisissez à quoi ressemble Mathew pour vous.",
"themeAria": "Thème de couleurs",
"themeLight": "Clair",
"themeDark": "Sombre",
"accent": "Couleur d'accent",
"accentHint": "Utilisée pour les liens, les boutons et les surlignages.",
"accentName": "accent {name}",
"accentViolet": "violet",
"accentPurple": "pourpre",
"accentOcean": "océan",
"accentTeal": "sarcelle",
"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"
},
"auth": {
"signin": "Se connecter",
"signup": "Créer un compte",
"signupTab": "Créer un compte",
"signupSubmit": "Créer le compte et se connecter",
"busy": "Un instant…",
"tabsAria": "Connexion",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "p. ex. emmy_noether",
"password": "Mot de passe",
"passwordHint": "— au moins 8 caractères",
"haveAccount": "Vous avez déjà un compte ?",
"newToMathew": "Nouveau sur Mathew ?",
"expiredProfile": "Cette session a expiré — reconnectez-vous pour modifier votre compte."
},
"gate": {
"heading": "Connectez-vous pour écrire",
"text": "Le wiki est ouvert à tous pour la lecture. Un compte est ce qui permet d'y ajouter quelque chose.",
"fine": "Pas encore de compte ? En créer un ne demande qu'un nom et un mot de passe."
},
"profile": {
"title": "Profil",
"closeAria": "Fermer le profil",
"picture": "Photo de profil",
"pictureHint": "Affichée à côté de votre nom. Rognée en cercle.",
"uploading": "Téléversement…",
"changePicture": "Changer la photo",
"choosePicture": "Choisir une photo",
"remove": "Retirer",
"readFileFailed": "Impossible de lire ce fichier.",
"notReadable": "Ce fichier n'est pas une image que le navigateur peut lire.",
"drawFailed": "Le navigateur n'a pas pu dessiner cette image.",
"notAnImage": "Ceci n'est pas un fichier image.",
"uploadFailed": "Impossible de téléverser cette image.",
"readFailed": "Impossible de lire cette image.",
"removeFailed": "Impossible de retirer l'image.",
"username": "Nom d'utilisateur",
"usernameHint": "Lettres, chiffres, point, tiret ou tiret bas — de 3 à 30 caractères.",
"saving": "Enregistrement…",
"save": "Enregistrer",
"nameRequired": "Un nom est requis.",
"nameFailed": "Impossible de changer le nom.",
"nameSaved": "Nom enregistré.",
"password": "Mot de passe",
"passwordHint": "Au moins 8 caractères.",
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"changePassword": "Changer le mot de passe",
"passwordFailed": "Impossible de changer le mot de passe.",
"passwordChanged": "Mot de passe changé."
},
"search": {
"placeholder": "Recherchez des articles par titre ou étiquette…",
"clear": "Effacer la recherche",
"apiDown": "Impossible de joindre l'API — le backend est-il en marche ?",
"noMatch": "Aucun article ne correspond à <strong>« {q} »</strong>.",
"idle": "Commencez à taper pour chercher dans le wiki.",
"navigate": "naviguer",
"open": "ouvrir",
"close": "fermer",
"allResults": "Tous les résultats pour « {q} »"
},
"recent": {
"title": "Consultés récemment",
"aria": "Articles consultés récemment"
},
"toc": {
"title": "Sommaire",
"aria": "Table des matières"
},
"card": {
"kickerArticle": "Article",
"kickerTranslation": "Traduction",
"existsIn": "Existe en : {names}",
"updated": "mis à jour le {date}"
},
"pane": {
"aria": "Article : {title}",
"spineHint": "{title} · cliquez pour tourner à cette page · esc pour fermer",
"readFailTitle": "Cette page n'a pas pu être lue",
"readFailText": "L'article à <code>{path}</code> n'a pas répondu. Le backend est-il en marche ?",
"lastEdited": "Dernière modification le {date}",
"topicsLabel": "Thèmes :"
},
"book": {
"openAria": "Articles ouverts",
"chipHint": "{title} · cliquez pour ouvrir · esc pour fermer",
"dockAria": "Pages en attente dans le livre",
"dockHint": "{title} · cliquez pour tourner à cette page",
"deleteConfirm": "Supprimer « {title} » définitivement ? Cette action est irréversible.",
"deleteFailed": "Impossible de supprimer l'article. Le backend est-il en marche ?"
},
"articleActions": {
"aria": "Actions sur l'article"
},
"accountActions": {
"aria": "Actions sur le compte",
"forUser": "Actions pour {username}",
"removeAdmin": "Retirer admin",
"makeAdmin": "Nommer admin"
},
"langMenu": {
"hintSignedIn": "Lire cet article dans une autre langue — ou en écrire une",
"hint": "Lire cet article dans une autre langue",
"aria": "Langues",
"readIn": "Lire en",
"writeIn": "Écrire en",
"writeArticleIn": "Écrire cet article en {name}"
}
}

67
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,67 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxtjs/i18n'],
css: ['katex/dist/katex.min.css', '~/assets/css/main.scss'],
/* The interface language (app -> t('...'), catalogues in i18n/locales/).
It is a preference, never part of the address — /wiki/<lang>/<slug>
speaks of the *article's* language, so the UI stays unprefixed.
On a first load the saved choice comes from the `mathew-locale` cookie
(which the server can read, so pages render in the right language before
any script runs), then the browser's own. A signed-in account's stored
choice is applied over these at startup — see app/plugins/auth.ts. */
i18n: {
strategy: 'no_prefix',
defaultLocale: 'en',
locales: [
{ code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
{ code: 'es', language: 'es-ES', name: 'Español', file: 'es.json' },
{ code: 'ca', language: 'ca-ES', name: 'Català', file: 'ca.json' },
{ code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' },
{ code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json' },
],
// The catalogues are small; bundling them keeps locale switches instant
lazy: false,
// Messages carry their own markup (<code> in the API banner, the hero
// gradient, demo words in the cheatsheet), rendered with v-html
compilation: { strictMessage: false },
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'mathew-locale',
fallbackLocale: 'en',
},
},
runtimeConfig: {
public: {
// Override with NUXT_PUBLIC_API_BASE in production
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:5000',
},
},
app: {
head: {
meta: [
{
name: 'description',
content: 'A modern, collaborative wiki for mathematical concepts — written in Markdown, rendered with LaTeX.',
},
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#f7f8fb' },
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#0a0c12' },
],
script: [
{
// Apply saved theme & accent before first paint to avoid a flash
tagPosition: 'head',
innerHTML:
"(function(){try{var d=document.documentElement;var s=localStorage.getItem('mathew-theme');var t=s||(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');d.setAttribute('data-theme',t);var a=localStorage.getItem('mathew-accent');if(a&&a!=='violet')d.setAttribute('data-accent',a);}catch(e){}})();",
},
],
},
},
})

13050
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "mathew-frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/i18n": "^10.6.0",
"highlight.js": "^11.12.0",
"isomorphic-dompurify": "^4.4.0",
"katex": "^0.18.9",
"markdown-it": "^15.0.2",
"markdown-it-texmath": "^1.0.0",
"nuxt": "^4.5.2",
"vue": "^3.5.43",
"vue-router": "^5.3.1"
},
"devDependencies": {
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.2.0",
"sass": "^1.105.0"
}
}

Some files were not shown because too many files have changed in this diff Show More