First commit
This commit is contained in:
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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
55
frontend/public/fonts/SIL Open Font License.txt
Normal file
55
frontend/public/fonts/SIL Open Font License.txt
Normal file
@@ -0,0 +1,55 @@
|
||||
Copyright (C) Authors of original metafont fonts:
|
||||
Donald Ervin Knuth (cm, concrete fonts)
|
||||
1995, 1996, 1997 J"org Knappen, 1990, 1992 Norbert Schwarz (ec fonts)
|
||||
1992-2006 A.Khodulev, O.Lapko, A.Berdnikov, V.Volovich (lh fonts)
|
||||
1997-2005 Claudio Beccari (cb greek fonts)
|
||||
2002 FUKUI Rei (tipa fonts)
|
||||
2003-2005 Han The Thanh (Vietnamese fonts)
|
||||
1996-2005 Walter Schmidt (cmbright fonts)
|
||||
|
||||
Copyright (C) 2003-2009, Andrey V. Panov (panov@canopus.iacp.dvo.ru),
|
||||
with Reserved Font Family Name "Computer Modern Unicode fonts".
|
||||
|
||||
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/public/fonts/cmunbi.ttf
Normal file
BIN
frontend/public/fonts/cmunbi.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/cmunbx.ttf
Normal file
BIN
frontend/public/fonts/cmunbx.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/cmunrm.ttf
Normal file
BIN
frontend/public/fonts/cmunrm.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/cmunti.ttf
Normal file
BIN
frontend/public/fonts/cmunti.ttf
Normal file
Binary file not shown.
2
frontend/public/robots.txt
Normal file
2
frontend/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
18
frontend/tsconfig.json
Normal file
18
frontend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
frontend/types/markdown-it-texmath.d.ts
vendored
Normal file
16
frontend/types/markdown-it-texmath.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
declare module 'markdown-it-texmath' {
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
|
||||
interface TexmathOptions {
|
||||
engine?: unknown
|
||||
delimiters?: 'dollars' | 'dollars-with- spaces' | 'brackets' | 'dollars-and-brackets'
|
||||
katexOptions?: Record<string, unknown>
|
||||
macros?: Record<string, string>
|
||||
outerSpace?: boolean
|
||||
strict?: boolean | 'warn' | 'ignore'
|
||||
errorColor?: string
|
||||
}
|
||||
|
||||
function texmath(md: MarkdownIt, options?: TexmathOptions): void
|
||||
export default texmath
|
||||
}
|
||||
Reference in New Issue
Block a user