From 6edd03aaceae20224ac6286d391584d5d64522db Mon Sep 17 00:00:00 2001 From: Aran Roig Date: Wed, 30 Sep 2026 01:49:35 +0200 Subject: [PATCH] First commit --- .gitignore | 3 + AGENTS.md | 152 + backend/.env.example | 4 + backend/.gitignore | 2 + backend/package-lock.json | 1279 ++ backend/package.json | 21 + backend/src/config/auth.js | 15 + backend/src/config/db.js | 11 + backend/src/index.js | 72 + backend/src/middleware/auth.js | 60 + backend/src/models/Article.js | 52 + backend/src/models/Item.js | 21 + backend/src/models/User.js | 124 + backend/src/routes/admin.js | 79 + backend/src/routes/articles.js | 361 + backend/src/routes/auth.js | 189 + backend/src/routes/items.js | 62 + backend/src/seed.js | 410 + dev.sh | 158 + frontend/.gitignore | 24 + frontend/README.md | 67 + frontend/app/app.vue | 34 + frontend/app/assets/css/_admin.scss | 78 + frontend/app/assets/css/_article.scss | 225 + frontend/app/assets/css/_articles.scss | 67 + frontend/app/assets/css/_auth.scss | 119 + frontend/app/assets/css/_base.scss | 65 + frontend/app/assets/css/_book.scss | 300 + frontend/app/assets/css/_buttons.scss | 62 + frontend/app/assets/css/_cards.scss | 80 + frontend/app/assets/css/_editor.scss | 295 + frontend/app/assets/css/_fonts.scss | 19 + frontend/app/assets/css/_graph.scss | 68 + frontend/app/assets/css/_home.scss | 152 + frontend/app/assets/css/_langs.scss | 193 + frontend/app/assets/css/_layout.scss | 283 + frontend/app/assets/css/_mixins.scss | 211 + frontend/app/assets/css/_profile.scss | 91 + frontend/app/assets/css/_prose.scss | 167 + frontend/app/assets/css/_search.scss | 182 + frontend/app/assets/css/_settings.scss | 73 + frontend/app/assets/css/_states.scss | 46 + frontend/app/assets/css/_tokens.scss | 110 + frontend/app/assets/css/_transitions.scss | 10 + frontend/app/assets/css/main.scss | 28 + .../app/components/AccountActionsMenu.vue | 123 + frontend/app/components/AppHeader.vue | 193 + .../app/components/ArticleActionsMenu.vue | 127 + frontend/app/components/ArticleBookStack.vue | 257 + frontend/app/components/ArticleCard.vue | 77 + frontend/app/components/ArticleForm.vue | 786 + .../app/components/ArticleLanguageMenu.vue | 143 + frontend/app/components/ArticlePane.vue | 146 + frontend/app/components/AuthGate.vue | 44 + frontend/app/components/AuthModal.vue | 194 + frontend/app/components/ConfirmModal.vue | 62 + frontend/app/components/EditorCheatsheet.vue | 145 + frontend/app/components/GraphView.vue | 574 + frontend/app/components/KatexSpan.vue | 14 + frontend/app/components/MarkdownView.vue | 37 + frontend/app/components/ProfileModal.vue | 358 + frontend/app/components/RecentlyViewed.vue | 26 + frontend/app/components/SearchModal.vue | 284 + frontend/app/components/SettingsModal.vue | 229 + frontend/app/components/TocNav.vue | 73 + .../app/composables/useAccountActionsMenu.ts | 15 + frontend/app/composables/useAppLocale.ts | 60 + .../app/composables/useArticleActionsMenu.ts | 15 + frontend/app/composables/useArticleLibrary.ts | 118 + frontend/app/composables/useArticleTabs.ts | 109 + frontend/app/composables/useAuth.ts | 158 + frontend/app/composables/useLanguageMenu.ts | 15 + frontend/app/composables/useProfileMenu.ts | 20 + frontend/app/composables/useRecentlyViewed.ts | 56 + frontend/app/composables/useSearch.ts | 20 + frontend/app/composables/useSettings.ts | 19 + frontend/app/composables/useSidebar.ts | 27 + frontend/app/composables/useSignInPopup.ts | 26 + frontend/app/middleware/open-article.ts | 36 + frontend/app/pages/admin.vue | 272 + frontend/app/pages/articles.vue | 291 + frontend/app/pages/graph.vue | 50 + frontend/app/pages/index.vue | 174 + frontend/app/pages/new.vue | 54 + .../app/pages/wiki/[lang]/[slug]/index.vue | 58 + frontend/app/pages/wiki/[slug]/edit.vue | 38 + frontend/app/pages/wiki/[slug]/index.vue | 55 + frontend/app/plugins/auth.ts | 15 + frontend/app/utils/languages.ts | 42 + frontend/app/utils/markdown.ts | 175 + frontend/app/utils/routes.ts | 57 + frontend/i18n/i18n.config.ts | 8 + frontend/i18n/locales/ca.json | 357 + frontend/i18n/locales/de.json | 357 + frontend/i18n/locales/en.json | 357 + frontend/i18n/locales/es.json | 357 + frontend/i18n/locales/fr.json | 357 + frontend/nuxt.config.ts | 67 + frontend/package-lock.json | 13050 ++++++++++++++++ frontend/package.json | 28 + frontend/public/favicon.ico | Bin 0 -> 4286 bytes .../public/fonts/SIL Open Font License.txt | 55 + frontend/public/fonts/cmunbi.ttf | Bin 0 -> 285096 bytes frontend/public/fonts/cmunbx.ttf | Bin 0 -> 221904 bytes frontend/public/fonts/cmunrm.ttf | Bin 0 -> 235656 bytes frontend/public/fonts/cmunti.ttf | Bin 0 -> 289276 bytes frontend/public/robots.txt | 2 + frontend/tsconfig.json | 18 + frontend/types/markdown-it-texmath.d.ts | 16 + 109 files changed, 27020 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/config/auth.js create mode 100644 backend/src/config/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/models/Article.js create mode 100644 backend/src/models/Item.js create mode 100644 backend/src/models/User.js create mode 100644 backend/src/routes/admin.js create mode 100644 backend/src/routes/articles.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/items.js create mode 100644 backend/src/seed.js create mode 100755 dev.sh create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/app/app.vue create mode 100644 frontend/app/assets/css/_admin.scss create mode 100644 frontend/app/assets/css/_article.scss create mode 100644 frontend/app/assets/css/_articles.scss create mode 100644 frontend/app/assets/css/_auth.scss create mode 100644 frontend/app/assets/css/_base.scss create mode 100644 frontend/app/assets/css/_book.scss create mode 100644 frontend/app/assets/css/_buttons.scss create mode 100644 frontend/app/assets/css/_cards.scss create mode 100644 frontend/app/assets/css/_editor.scss create mode 100644 frontend/app/assets/css/_fonts.scss create mode 100644 frontend/app/assets/css/_graph.scss create mode 100644 frontend/app/assets/css/_home.scss create mode 100644 frontend/app/assets/css/_langs.scss create mode 100644 frontend/app/assets/css/_layout.scss create mode 100644 frontend/app/assets/css/_mixins.scss create mode 100644 frontend/app/assets/css/_profile.scss create mode 100644 frontend/app/assets/css/_prose.scss create mode 100644 frontend/app/assets/css/_search.scss create mode 100644 frontend/app/assets/css/_settings.scss create mode 100644 frontend/app/assets/css/_states.scss create mode 100644 frontend/app/assets/css/_tokens.scss create mode 100644 frontend/app/assets/css/_transitions.scss create mode 100644 frontend/app/assets/css/main.scss create mode 100644 frontend/app/components/AccountActionsMenu.vue create mode 100644 frontend/app/components/AppHeader.vue create mode 100644 frontend/app/components/ArticleActionsMenu.vue create mode 100644 frontend/app/components/ArticleBookStack.vue create mode 100644 frontend/app/components/ArticleCard.vue create mode 100644 frontend/app/components/ArticleForm.vue create mode 100644 frontend/app/components/ArticleLanguageMenu.vue create mode 100644 frontend/app/components/ArticlePane.vue create mode 100644 frontend/app/components/AuthGate.vue create mode 100644 frontend/app/components/AuthModal.vue create mode 100644 frontend/app/components/ConfirmModal.vue create mode 100644 frontend/app/components/EditorCheatsheet.vue create mode 100644 frontend/app/components/GraphView.vue create mode 100644 frontend/app/components/KatexSpan.vue create mode 100644 frontend/app/components/MarkdownView.vue create mode 100644 frontend/app/components/ProfileModal.vue create mode 100644 frontend/app/components/RecentlyViewed.vue create mode 100644 frontend/app/components/SearchModal.vue create mode 100644 frontend/app/components/SettingsModal.vue create mode 100644 frontend/app/components/TocNav.vue create mode 100644 frontend/app/composables/useAccountActionsMenu.ts create mode 100644 frontend/app/composables/useAppLocale.ts create mode 100644 frontend/app/composables/useArticleActionsMenu.ts create mode 100644 frontend/app/composables/useArticleLibrary.ts create mode 100644 frontend/app/composables/useArticleTabs.ts create mode 100644 frontend/app/composables/useAuth.ts create mode 100644 frontend/app/composables/useLanguageMenu.ts create mode 100644 frontend/app/composables/useProfileMenu.ts create mode 100644 frontend/app/composables/useRecentlyViewed.ts create mode 100644 frontend/app/composables/useSearch.ts create mode 100644 frontend/app/composables/useSettings.ts create mode 100644 frontend/app/composables/useSidebar.ts create mode 100644 frontend/app/composables/useSignInPopup.ts create mode 100644 frontend/app/middleware/open-article.ts create mode 100644 frontend/app/pages/admin.vue create mode 100644 frontend/app/pages/articles.vue create mode 100644 frontend/app/pages/graph.vue create mode 100644 frontend/app/pages/index.vue create mode 100644 frontend/app/pages/new.vue create mode 100644 frontend/app/pages/wiki/[lang]/[slug]/index.vue create mode 100644 frontend/app/pages/wiki/[slug]/edit.vue create mode 100644 frontend/app/pages/wiki/[slug]/index.vue create mode 100644 frontend/app/plugins/auth.ts create mode 100644 frontend/app/utils/languages.ts create mode 100644 frontend/app/utils/markdown.ts create mode 100644 frontend/app/utils/routes.ts create mode 100644 frontend/i18n/i18n.config.ts create mode 100644 frontend/i18n/locales/ca.json create mode 100644 frontend/i18n/locales/de.json create mode 100644 frontend/i18n/locales/en.json create mode 100644 frontend/i18n/locales/es.json create mode 100644 frontend/i18n/locales/fr.json create mode 100644 frontend/nuxt.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/fonts/SIL Open Font License.txt create mode 100644 frontend/public/fonts/cmunbi.ttf create mode 100644 frontend/public/fonts/cmunbx.ttf create mode 100644 frontend/public/fonts/cmunrm.ttf create mode 100644 frontend/public/fonts/cmunti.ttf create mode 100644 frontend/public/robots.txt create mode 100644 frontend/tsconfig.json create mode 100644 frontend/types/markdown-it-texmath.d.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f637951 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# dev.sh runtime artifacts +.logs/ +.pids/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4152809 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ +# Mathew — a wiki for mathematics + +A collaborative wiki of math concepts. Articles are written in Markdown with LaTeX +(KaTeX) and stored in MongoDB behind an Express API; a Nuxt 4 app renders and edits them. + +``` +mathew/ +├── dev.sh # starts/stops both dev servers together +├── backend/ Express 5 + Mongoose API (port 5000) +└── frontend/ Nuxt 4 / Vue 3 wiki UI (port 3000) +``` + +## Running the project + +Requires MongoDB on `localhost:27017` (database `mathew`). + +```bash +./dev.sh # both dev servers in foreground (Ctrl+C stops both) +./dev.sh start|stop|restart|status +./dev.sh logs [all|backend|frontend] +``` + +Or per-service: `npm run dev` inside `backend/` or `frontend/` +(backend uses `node --watch`; backend has no build step). + +Seed sample articles: `npm run seed` in `backend/`. + +Environment: `backend/.env` (`PORT=5000`, `MONGO_URI=mongodb://127.0.0.1:27017/mathew`, +`JWT_SECRET` — signs session tokens, `AUTH_TOKEN_TTL` — how long one lasts, default `7d`; +see `.env.example`). Without `JWT_SECRET` the API warns and uses an insecure dev key. +Frontend API URL comes from `NUXT_PUBLIC_API_BASE` +(default `http://localhost:5000`, read via `runtimeConfig.public.apiBase`). + +There is **no test suite, linter, or typecheck script** configured — don't invent commands. + +## Backend (`backend/`) + +Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` import specifiers). + +- `src/index.js` — app setup: CORS, JSON body, `/health`, routers, 404 handler, central + error handler (Mongoose `ValidationError`/`CastError` → 400), graceful shutdown. After + the connection it calls `User.ensureAnAdmin()` so somebody can always open the dashboard. +- `src/config/db.js` — `connectDB` / `disconnectDB`. +- `src/models/` — Mongoose schemas with `timestamps: true`. `Article` (slug, title, + summary, content, tags + text index), `Item` (simple demo resource), + `User` (username + `passwordHash`, `select: false`; `setPassword`/`checkPassword` + via bcrypt, `toPublic()` for responses, and `credentialProblem()` as the single + place account rules are checked). The profile picture lives on `User.avatar` as a + small data URL — no file storage. `User.role` is `'admin'` or `'member'` + (`ROLES`); `User.hasNone()` and `User.ensureAnAdmin()` cover who gets the + former — see the roles gotcha below. +- `src/routes/` — one Express `Router` per resource, mounted in `index.js`. +- `src/routes/auth.js` — `POST /api/auth/register` (creates the account and returns + its session; the wiki's **first** account is created as an admin), + `POST /api/auth/login`, `GET /api/auth/me` (who a token belongs to). + Login answers the same way to a wrong name and a wrong password. Account updates, + all behind `requireAuth`: `PUT /api/auth/me` (rename), `PUT /api/auth/password` + (needs the current one), `PUT /api/auth/avatar` (data URL, `""` clears). +- `src/routes/admin.js` — mounted at `/api/admin`, everything behind + `requireAuth, requireAdmin`: `GET /api/admin/users` (every account, newest + first, with `createdAt`), `PUT /api/admin/users/:id/role` (`role` is `'admin'` + or `'member'`; an admin's own role is the one they cannot change), and + `DELETE /api/admin/users/:id` (closes an account for good — never their own, + by the same rule; the articles it wrote stay, since articles name no author). +- `src/middleware/auth.js` — `signToken(user)`, `requireAuth`, which reads + `Authorization: Bearer ` and sets `req.user = { id, username }`; anything + else is answered with 401 `{ message }`. `requireAdmin` runs after it and reads + the role off the **account** (403 `{ message }` for a member), so removing the + role works the next time the dashboard is asked for. +- `src/seed.js` — sample articles. + +Conventions to follow: + +- Every handler is `async` with `try { ... } catch (err) { next(err); }`; the central + error handler in `index.js` formats responses (`{ message }` everywhere). +- Articles are addressed by **slug**, not by Mongo id (`/api/articles/slug/:slug`). + `slugify()` / `uniqueSlug()` (in `routes/articles.js`) generate uniquified slugs + (`-2`, `-3`, ... suffixes); POST requires `title`, PUT re-slugs when the title changes. +- The list endpoint (`GET /api/articles`) selects `-content` (no body) and supports + `?q=` (regex search on title/summary/tags) and `?tag=`. +- Reads are public; the writes (`POST /api/articles`, `PUT`/`DELETE` by slug) run + through `requireAuth`. + +## Frontend (`frontend/`) + +Nuxt 4 (srcDir `app/`, TypeScript, Vue 3 ` + + diff --git a/frontend/app/assets/css/_admin.scss b/frontend/app/assets/css/_admin.scss new file mode 100644 index 0000000..0a4dc8c --- /dev/null +++ b/frontend/app/assets/css/_admin.scss @@ -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; } diff --git a/frontend/app/assets/css/_article.scss b/frontend/app/assets/css/_article.scss new file mode 100644 index 0000000..b80b76d --- /dev/null +++ b/frontend/app/assets/css/_article.scss @@ -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; } diff --git a/frontend/app/assets/css/_articles.scss b/frontend/app/assets/css/_articles.scss new file mode 100644 index 0000000..deaef92 --- /dev/null +++ b/frontend/app/assets/css/_articles.scss @@ -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; +} diff --git a/frontend/app/assets/css/_auth.scss b/frontend/app/assets/css/_auth.scss new file mode 100644 index 0000000..226d613 --- /dev/null +++ b/frontend/app/assets/css/_auth.scss @@ -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); } diff --git a/frontend/app/assets/css/_base.scss b/frontend/app/assets/css/_base.scss new file mode 100644 index 0000000..5b433cb --- /dev/null +++ b/frontend/app/assets/css/_base.scss @@ -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; + } +} diff --git a/frontend/app/assets/css/_book.scss b/frontend/app/assets/css/_book.scss new file mode 100644 index 0000000..b9e6c62 --- /dev/null +++ b/frontend/app/assets/css/_book.scss @@ -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/ +// 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; } +} diff --git a/frontend/app/assets/css/_buttons.scss b/frontend/app/assets/css/_buttons.scss new file mode 100644 index 0000000..8d0d66f --- /dev/null +++ b/frontend/app/assets/css/_buttons.scss @@ -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); } +} diff --git a/frontend/app/assets/css/_cards.scss b/frontend/app/assets/css/_cards.scss new file mode 100644 index 0000000..a986815 --- /dev/null +++ b/frontend/app/assets/css/_cards.scss @@ -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); } diff --git a/frontend/app/assets/css/_editor.scss b/frontend/app/assets/css/_editor.scss new file mode 100644 index 0000000..fb03f4a --- /dev/null +++ b/frontend/app/assets/css/_editor.scss @@ -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; } diff --git a/frontend/app/assets/css/_fonts.scss b/frontend/app/assets/css/_fonts.scss new file mode 100644 index 0000000..0eee18e --- /dev/null +++ b/frontend/app/assets/css/_fonts.scss @@ -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; + } +} diff --git a/frontend/app/assets/css/_graph.scss b/frontend/app/assets/css/_graph.scss new file mode 100644 index 0000000..ca2ffc5 --- /dev/null +++ b/frontend/app/assets/css/_graph.scss @@ -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; } +} diff --git a/frontend/app/assets/css/_home.scss b/frontend/app/assets/css/_home.scss new file mode 100644 index 0000000..9f998db --- /dev/null +++ b/frontend/app/assets/css/_home.scss @@ -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; } + } +} diff --git a/frontend/app/assets/css/_langs.scss b/frontend/app/assets/css/_langs.scss new file mode 100644 index 0000000..57237ff --- /dev/null +++ b/frontend/app/assets/css/_langs.scss @@ -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); } +} diff --git a/frontend/app/assets/css/_layout.scss b/frontend/app/assets/css/_layout.scss new file mode 100644 index 0000000..ebf310d --- /dev/null +++ b/frontend/app/assets/css/_layout.scss @@ -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; } diff --git a/frontend/app/assets/css/_mixins.scss b/frontend/app/assets/css/_mixins.scss new file mode 100644 index 0000000..3d28cf4 --- /dev/null +++ b/frontend/app/assets/css/_mixins.scss @@ -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); + } +} diff --git a/frontend/app/assets/css/_profile.scss b/frontend/app/assets/css/_profile.scss new file mode 100644 index 0000000..38fc009 --- /dev/null +++ b/frontend/app/assets/css/_profile.scss @@ -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 +); diff --git a/frontend/app/assets/css/_prose.scss b/frontend/app/assets/css/_prose.scss new file mode 100644 index 0000000..0e67dbe --- /dev/null +++ b/frontend/app/assets/css/_prose.scss @@ -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; } diff --git a/frontend/app/assets/css/_search.scss b/frontend/app/assets/css/_search.scss new file mode 100644 index 0000000..9ad3b3d --- /dev/null +++ b/frontend/app/assets/css/_search.scss @@ -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 +); diff --git a/frontend/app/assets/css/_settings.scss b/frontend/app/assets/css/_settings.scss new file mode 100644 index 0000000..abb1004 --- /dev/null +++ b/frontend/app/assets/css/_settings.scss @@ -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 +); diff --git a/frontend/app/assets/css/_states.scss b/frontend/app/assets/css/_states.scss new file mode 100644 index 0000000..6046d66 --- /dev/null +++ b/frontend/app/assets/css/_states.scss @@ -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%; } +} diff --git a/frontend/app/assets/css/_tokens.scss b/frontend/app/assets/css/_tokens.scss new file mode 100644 index 0000000..c7df3f3 --- /dev/null +++ b/frontend/app/assets/css/_tokens.scss @@ -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 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; } +} diff --git a/frontend/app/assets/css/_transitions.scss b/frontend/app/assets/css/_transitions.scss new file mode 100644 index 0000000..b485576 --- /dev/null +++ b/frontend/app/assets/css/_transitions.scss @@ -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; } diff --git a/frontend/app/assets/css/main.scss b/frontend/app/assets/css/main.scss new file mode 100644 index 0000000..a1fa353 --- /dev/null +++ b/frontend/app/assets/css/main.scss @@ -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 diff --git a/frontend/app/components/AccountActionsMenu.vue b/frontend/app/components/AccountActionsMenu.vue new file mode 100644 index 0000000..ea00ca9 --- /dev/null +++ b/frontend/app/components/AccountActionsMenu.vue @@ -0,0 +1,123 @@ + + + diff --git a/frontend/app/components/AppHeader.vue b/frontend/app/components/AppHeader.vue new file mode 100644 index 0000000..41a4edb --- /dev/null +++ b/frontend/app/components/AppHeader.vue @@ -0,0 +1,193 @@ + + + diff --git a/frontend/app/components/ArticleActionsMenu.vue b/frontend/app/components/ArticleActionsMenu.vue new file mode 100644 index 0000000..e624ff2 --- /dev/null +++ b/frontend/app/components/ArticleActionsMenu.vue @@ -0,0 +1,127 @@ + + + diff --git a/frontend/app/components/ArticleBookStack.vue b/frontend/app/components/ArticleBookStack.vue new file mode 100644 index 0000000..a8dc046 --- /dev/null +++ b/frontend/app/components/ArticleBookStack.vue @@ -0,0 +1,257 @@ + + + diff --git a/frontend/app/components/ArticleCard.vue b/frontend/app/components/ArticleCard.vue new file mode 100644 index 0000000..57a72c7 --- /dev/null +++ b/frontend/app/components/ArticleCard.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/app/components/ArticleForm.vue b/frontend/app/components/ArticleForm.vue new file mode 100644 index 0000000..7c148fb --- /dev/null +++ b/frontend/app/components/ArticleForm.vue @@ -0,0 +1,786 @@ + + +