First commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# dev.sh runtime artifacts
|
||||||
|
.logs/
|
||||||
|
.pids/
|
||||||
152
AGENTS.md
Normal file
152
AGENTS.md
Normal 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
4
backend/.env.example
Normal 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
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
1279
backend/package-lock.json
generated
Normal file
1279
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
backend/package.json
Normal file
21
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/config/auth.js
Normal file
15
backend/src/config/auth.js
Normal 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
11
backend/src/config/db.js
Normal 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
72
backend/src/index.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
60
backend/src/middleware/auth.js
Normal file
60
backend/src/middleware/auth.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/src/models/Article.js
Normal file
52
backend/src/models/Article.js
Normal 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;
|
||||||
21
backend/src/models/Item.js
Normal file
21
backend/src/models/Item.js
Normal 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
124
backend/src/models/User.js
Normal 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;
|
||||||
79
backend/src/routes/admin.js
Normal file
79
backend/src/routes/admin.js
Normal 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;
|
||||||
361
backend/src/routes/articles.js
Normal file
361
backend/src/routes/articles.js
Normal 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
189
backend/src/routes/auth.js
Normal 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;
|
||||||
62
backend/src/routes/items.js
Normal file
62
backend/src/routes/items.js
Normal 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
410
backend/src/seed.js
Normal 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
158
dev.sh
Executable 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
24
frontend/.gitignore
vendored
Normal 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
67
frontend/README.md
Normal 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
34
frontend/app/app.vue
Normal 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>
|
||||||
78
frontend/app/assets/css/_admin.scss
Normal file
78
frontend/app/assets/css/_admin.scss
Normal 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; }
|
||||||
225
frontend/app/assets/css/_article.scss
Normal file
225
frontend/app/assets/css/_article.scss
Normal 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; }
|
||||||
67
frontend/app/assets/css/_articles.scss
Normal file
67
frontend/app/assets/css/_articles.scss
Normal 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;
|
||||||
|
}
|
||||||
119
frontend/app/assets/css/_auth.scss
Normal file
119
frontend/app/assets/css/_auth.scss
Normal 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); }
|
||||||
65
frontend/app/assets/css/_base.scss
Normal file
65
frontend/app/assets/css/_base.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
300
frontend/app/assets/css/_book.scss
Normal file
300
frontend/app/assets/css/_book.scss
Normal 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; }
|
||||||
|
}
|
||||||
62
frontend/app/assets/css/_buttons.scss
Normal file
62
frontend/app/assets/css/_buttons.scss
Normal 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); }
|
||||||
|
}
|
||||||
80
frontend/app/assets/css/_cards.scss
Normal file
80
frontend/app/assets/css/_cards.scss
Normal 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); }
|
||||||
295
frontend/app/assets/css/_editor.scss
Normal file
295
frontend/app/assets/css/_editor.scss
Normal 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; }
|
||||||
19
frontend/app/assets/css/_fonts.scss
Normal file
19
frontend/app/assets/css/_fonts.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
frontend/app/assets/css/_graph.scss
Normal file
68
frontend/app/assets/css/_graph.scss
Normal 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; }
|
||||||
|
}
|
||||||
152
frontend/app/assets/css/_home.scss
Normal file
152
frontend/app/assets/css/_home.scss
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
193
frontend/app/assets/css/_langs.scss
Normal file
193
frontend/app/assets/css/_langs.scss
Normal 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); }
|
||||||
|
}
|
||||||
283
frontend/app/assets/css/_layout.scss
Normal file
283
frontend/app/assets/css/_layout.scss
Normal 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; }
|
||||||
211
frontend/app/assets/css/_mixins.scss
Normal file
211
frontend/app/assets/css/_mixins.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
frontend/app/assets/css/_profile.scss
Normal file
91
frontend/app/assets/css/_profile.scss
Normal 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
|
||||||
|
);
|
||||||
167
frontend/app/assets/css/_prose.scss
Normal file
167
frontend/app/assets/css/_prose.scss
Normal 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; }
|
||||||
182
frontend/app/assets/css/_search.scss
Normal file
182
frontend/app/assets/css/_search.scss
Normal 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
|
||||||
|
);
|
||||||
73
frontend/app/assets/css/_settings.scss
Normal file
73
frontend/app/assets/css/_settings.scss
Normal 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
|
||||||
|
);
|
||||||
46
frontend/app/assets/css/_states.scss
Normal file
46
frontend/app/assets/css/_states.scss
Normal 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%; }
|
||||||
|
}
|
||||||
110
frontend/app/assets/css/_tokens.scss
Normal file
110
frontend/app/assets/css/_tokens.scss
Normal 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; }
|
||||||
|
}
|
||||||
10
frontend/app/assets/css/_transitions.scss
Normal file
10
frontend/app/assets/css/_transitions.scss
Normal 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; }
|
||||||
28
frontend/app/assets/css/main.scss
Normal file
28
frontend/app/assets/css/main.scss
Normal 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
|
||||||
123
frontend/app/components/AccountActionsMenu.vue
Normal file
123
frontend/app/components/AccountActionsMenu.vue
Normal 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>
|
||||||
193
frontend/app/components/AppHeader.vue
Normal file
193
frontend/app/components/AppHeader.vue
Normal 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>
|
||||||
127
frontend/app/components/ArticleActionsMenu.vue
Normal file
127
frontend/app/components/ArticleActionsMenu.vue
Normal 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>
|
||||||
257
frontend/app/components/ArticleBookStack.vue
Normal file
257
frontend/app/components/ArticleBookStack.vue
Normal 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>
|
||||||
77
frontend/app/components/ArticleCard.vue
Normal file
77
frontend/app/components/ArticleCard.vue
Normal 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>
|
||||||
786
frontend/app/components/ArticleForm.vue
Normal file
786
frontend/app/components/ArticleForm.vue
Normal 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>
|
||||||
143
frontend/app/components/ArticleLanguageMenu.vue
Normal file
143
frontend/app/components/ArticleLanguageMenu.vue
Normal 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>
|
||||||
146
frontend/app/components/ArticlePane.vue
Normal file
146
frontend/app/components/ArticlePane.vue
Normal 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>
|
||||||
44
frontend/app/components/AuthGate.vue
Normal file
44
frontend/app/components/AuthGate.vue
Normal 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>
|
||||||
194
frontend/app/components/AuthModal.vue
Normal file
194
frontend/app/components/AuthModal.vue
Normal 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>
|
||||||
62
frontend/app/components/ConfirmModal.vue
Normal file
62
frontend/app/components/ConfirmModal.vue
Normal 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>
|
||||||
145
frontend/app/components/EditorCheatsheet.vue
Normal file
145
frontend/app/components/EditorCheatsheet.vue
Normal 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>> 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>
|
||||||
574
frontend/app/components/GraphView.vue
Normal file
574
frontend/app/components/GraphView.vue
Normal 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>
|
||||||
14
frontend/app/components/KatexSpan.vue
Normal file
14
frontend/app/components/KatexSpan.vue
Normal 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>
|
||||||
37
frontend/app/components/MarkdownView.vue
Normal file
37
frontend/app/components/MarkdownView.vue
Normal 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>
|
||||||
358
frontend/app/components/ProfileModal.vue
Normal file
358
frontend/app/components/ProfileModal.vue
Normal 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>
|
||||||
26
frontend/app/components/RecentlyViewed.vue
Normal file
26
frontend/app/components/RecentlyViewed.vue
Normal 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>
|
||||||
284
frontend/app/components/SearchModal.vue
Normal file
284
frontend/app/components/SearchModal.vue
Normal 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>
|
||||||
229
frontend/app/components/SettingsModal.vue
Normal file
229
frontend/app/components/SettingsModal.vue
Normal 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>
|
||||||
73
frontend/app/components/TocNav.vue
Normal file
73
frontend/app/components/TocNav.vue
Normal 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>
|
||||||
15
frontend/app/composables/useAccountActionsMenu.ts
Normal file
15
frontend/app/composables/useAccountActionsMenu.ts
Normal 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 }
|
||||||
|
}
|
||||||
60
frontend/app/composables/useAppLocale.ts
Normal file
60
frontend/app/composables/useAppLocale.ts
Normal 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) =>
|
||||||
|
(({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }) 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 }
|
||||||
|
}
|
||||||
15
frontend/app/composables/useArticleActionsMenu.ts
Normal file
15
frontend/app/composables/useArticleActionsMenu.ts
Normal 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 }
|
||||||
|
}
|
||||||
118
frontend/app/composables/useArticleLibrary.ts
Normal file
118
frontend/app/composables/useArticleLibrary.ts
Normal 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 }
|
||||||
|
}
|
||||||
109
frontend/app/composables/useArticleTabs.ts
Normal file
109
frontend/app/composables/useArticleTabs.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
158
frontend/app/composables/useAuth.ts
Normal file
158
frontend/app/composables/useAuth.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/app/composables/useLanguageMenu.ts
Normal file
15
frontend/app/composables/useLanguageMenu.ts
Normal 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 }
|
||||||
|
}
|
||||||
20
frontend/app/composables/useProfileMenu.ts
Normal file
20
frontend/app/composables/useProfileMenu.ts
Normal 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 }
|
||||||
|
}
|
||||||
56
frontend/app/composables/useRecentlyViewed.ts
Normal file
56
frontend/app/composables/useRecentlyViewed.ts
Normal 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 }
|
||||||
|
}
|
||||||
20
frontend/app/composables/useSearch.ts
Normal file
20
frontend/app/composables/useSearch.ts
Normal 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 }
|
||||||
|
}
|
||||||
19
frontend/app/composables/useSettings.ts
Normal file
19
frontend/app/composables/useSettings.ts
Normal 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 }
|
||||||
|
}
|
||||||
27
frontend/app/composables/useSidebar.ts
Normal file
27
frontend/app/composables/useSidebar.ts
Normal 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 }
|
||||||
|
}
|
||||||
26
frontend/app/composables/useSignInPopup.ts
Normal file
26
frontend/app/composables/useSignInPopup.ts
Normal 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 }
|
||||||
|
}
|
||||||
36
frontend/app/middleware/open-article.ts
Normal file
36
frontend/app/middleware/open-article.ts
Normal 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))
|
||||||
|
})
|
||||||
272
frontend/app/pages/admin.vue
Normal file
272
frontend/app/pages/admin.vue
Normal 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>
|
||||||
291
frontend/app/pages/articles.vue
Normal file
291
frontend/app/pages/articles.vue
Normal 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>
|
||||||
50
frontend/app/pages/graph.vue
Normal file
50
frontend/app/pages/graph.vue
Normal 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>
|
||||||
174
frontend/app/pages/index.vue
Normal file
174
frontend/app/pages/index.vue
Normal 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>
|
||||||
54
frontend/app/pages/new.vue
Normal file
54
frontend/app/pages/new.vue
Normal 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>
|
||||||
58
frontend/app/pages/wiki/[lang]/[slug]/index.vue
Normal file
58
frontend/app/pages/wiki/[lang]/[slug]/index.vue
Normal 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>
|
||||||
38
frontend/app/pages/wiki/[slug]/edit.vue
Normal file
38
frontend/app/pages/wiki/[slug]/edit.vue
Normal 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>
|
||||||
55
frontend/app/pages/wiki/[slug]/index.vue
Normal file
55
frontend/app/pages/wiki/[slug]/index.vue
Normal 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>
|
||||||
15
frontend/app/plugins/auth.ts
Normal file
15
frontend/app/plugins/auth.ts
Normal 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)
|
||||||
|
})
|
||||||
42
frontend/app/utils/languages.ts
Normal file
42
frontend/app/utils/languages.ts
Normal 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
|
||||||
|
}
|
||||||
175
frontend/app/utils/markdown.ts
Normal file
175
frontend/app/utils/markdown.ts
Normal 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 }
|
||||||
|
}
|
||||||
57
frontend/app/utils/routes.ts
Normal file
57
frontend/app/utils/routes.ts
Normal 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'
|
||||||
|
}
|
||||||
8
frontend/i18n/i18n.config.ts
Normal file
8
frontend/i18n/i18n.config.ts
Normal 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',
|
||||||
|
}))
|
||||||
357
frontend/i18n/locales/ca.json
Normal file
357
frontend/i18n/locales/ca.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
357
frontend/i18n/locales/de.json
Normal file
357
frontend/i18n/locales/de.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
357
frontend/i18n/locales/en.json
Normal file
357
frontend/i18n/locales/en.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
357
frontend/i18n/locales/es.json
Normal file
357
frontend/i18n/locales/es.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
357
frontend/i18n/locales/fr.json
Normal file
357
frontend/i18n/locales/fr.json
Normal 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
67
frontend/nuxt.config.ts
Normal 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
13050
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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
Reference in New Issue
Block a user