From 50adab2eac3b38fe66b03c915857d8a8d1320034 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Mon, 7 Sep 2026 02:46:44 -0400 Subject: [PATCH] feat: view page version by ID --- CLAUDE.md | 54 +- backend/.oxlintrc.json | 8 + backend/api/pages.ts | 67 +++ backend/api/schemas/page.ts | 34 +- backend/index.ts | 4 +- backend/locales/en.json | 11 +- backend/models/pageHistory.ts | 140 +++-- frontend/.oxlintrc.json | 42 +- frontend/src/assets/icons.generated.js | 3 +- frontend/src/components/EditorMarkdown.vue | 35 -- frontend/src/components/PageHeader.vue | 26 +- .../src/components/PageHistoryOverlay.vue | 89 ++- frontend/src/components/PageVersionHeader.vue | 198 +++++++ frontend/src/css/_page-chrome.scss | 416 +++++++++++++ frontend/src/css/_page-contents.scss | 9 +- frontend/src/css/app.scss | 1 + frontend/src/helpers/pageVersions.js | 103 ++++ frontend/src/helpers/renderedContent.js | 40 ++ frontend/src/layouts/VersionLayout.vue | 96 +++ frontend/src/pages/AdminNavigation.vue | 11 + frontend/src/pages/AdminScheduler.vue | 9 +- frontend/src/pages/Index.vue | 337 +---------- frontend/src/pages/PageVersion.vue | 551 ++++++++++++++++++ frontend/src/renderers/markdown.js | 5 +- frontend/src/router/routes.js | 16 + 25 files changed, 1820 insertions(+), 485 deletions(-) create mode 100644 frontend/src/components/PageVersionHeader.vue create mode 100644 frontend/src/css/_page-chrome.scss create mode 100644 frontend/src/helpers/pageVersions.js create mode 100644 frontend/src/layouts/VersionLayout.vue create mode 100644 frontend/src/pages/PageVersion.vue diff --git a/CLAUDE.md b/CLAUDE.md index 4892eebea..40a384e2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,59 @@ elsewhere, where being wrong means shipping something visibly broken — a compo content classes is the case that has actually gone wrong. Also for a flow with real state to exercise (a login, an upload, a save), where a screenshot answers a question reading cannot. -See the `wikijs-isolated-test-instance` memory for how to boot one when it IS warranted. +### Booting a throwaway instance + +For the cases above, and never against a running dev instance: that database is somebody's own work, +and its admin account may well have 2FA on, which cannot be scripted. + +**A database of its own, not a schema of its own.** Copy `config.yml` to `config.test.yml` with +`port: 3010`, `db.db: wikitest` and `dataPath: ./data-test` — and leave `schema: wiki` alone. A second +*schema* in the same database fails on the first migration: `CREATE TYPE "treeType"` in +`db/migrations/20260809235619_init` is not schema-qualified, and neither is the column that references +it, so the type is created in one search path and looked for in another (`42704 typenameType`). There +is no `psql` in the dev container, so create the database with `pg` out of `backend/node_modules`, +connecting with the credentials already in `config.yml`. + +Then `CONFIG_FILE=config.test.yml node --no-experimental-webstorage backend` **from the repo root**. +`CONFIG_FILE` is resolved against `WIKI.ROOTPATH` (`core/config.ts`), so it is a path relative to the +root and not to `backend/`. It seeds itself and takes ~25s to reach listening. + +**Puppeteer is not installed in any workspace, and must not be added to one for a screenshot.** Install +`puppeteer-core` into a scratch directory instead and drive the browser already on the box: +`executablePath: '/usr/bin/chromium'`, `args: ['--no-sandbox']`. It pulls ~25 packages and downloads no +browser of its own. + +**Scripting the API rather than the browser**, which is the quicker way to get a page and a history in +place. Three things about it are not guessable: + +- **The site ID comes from `GET /_api/bootstrap`**, which is `publicAccess: true` and answers with the + site, the flags and the session — it is what the SPA itself calls on boot. Not from `GET /_api/sites`: + that needs `read:sites` or `access:admin`, so logged out it answers 401, and the site ID is what + logging in requires. +- **Login is `PUT /_api/sites/:siteId/auth/login`** (not POST) with `{strategyId, username, password}`. + The strategy is the built-in local one, whose ID is fixed as `systemIds.localAuthId` in `base.yml`. + A fresh instance answers `nextAction: changePassword` with a `continuationToken` for the seeded + `admin@example.com` / `12345678`; feed that to `PUT .../auth/changePassword`, which needs + **`strategyId` as well as** `continuationToken` and `newPassword`. The session cookie is good after + that. +- **The auth endpoints are rate limited, and successes are counted too.** `limitAuthAttempts` + (`helpers/rateLimit.ts`) guards login, 2FA, this password change, passkeys and page unlock with one + counter per client address — ten attempts per five minutes, then a fifteen minute ban. A re-runnable + script that tries the seeded password before the one it changed it to therefore burns a guaranteed + failure per run and eventually locks itself out. Try the changed password FIRST, and clear a ban with + `DELETE FROM wiki."rateLimits"` rather than waiting it out. + +**Two things block a fresh install's first screenshot.** The seeded admin is forced through a +change-password form on first login — fill both `input[autocomplete="new-password"]`, the current +password field being `v-if`'d away whenever a continuation token is in hand. And the site root raises +the **Welcome overlay** over the header while there is no home page, so navigate to any other path to +get at the real one. + +**Tearing down** is killing your own PID — a dev instance shows up as `node backend` too, so match on +start time or the `CONFIG_FILE` in `/proc//environ` rather than on the name — then +`DROP DATABASE wikitest` and deleting `config.test.yml` and `data-test/`. Neither is gitignored: +`.gitignore` names `/config.yml` and `/data` as exact paths, so a copy under any other name is +tracked and will turn up in the next commit. ## TypeScript (backend) diff --git a/backend/.oxlintrc.json b/backend/.oxlintrc.json index 1ca1e50ef..d8c2752cf 100644 --- a/backend/.oxlintrc.json +++ b/backend/.oxlintrc.json @@ -10,6 +10,14 @@ "categories": { "correctness": "error" }, + "rules": { + // Not in `correctness`, so it has to be named. Free here -- the backend reports none, `WIKI` being + // declared above and everything else imported -- and on for the same reason as the frontend's: + // a name used but never imported is a runtime error that no build step here would catch, there + // being no build step. `npm run typecheck` finds it too, but that is a separate command; this + // puts it in the lint everyone runs. + "no-undef": "error" + }, "ignorePatterns": [ "node_modules/**", "**/*.min.js", diff --git a/backend/api/pages.ts b/backend/api/pages.ts index a3cf2914b..9ee5e2a09 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -1263,6 +1263,73 @@ async function routes(app: FastifyInstance) { } ) + /** + * PAGE VERSION BY ID + */ + app.get<{ Params: { siteId: string; versionId: string } }>( + '/sites/:siteId/versions/:versionId', + { + // -> Checked per page below, for the same reason as the history routes above + schema: { + summary: 'Get a page version by its ID alone', + description: + 'The same version as the history route, addressed WITHOUT naming the page — what a `/_version/` link resolves. The page it came off is named in the reply, since that is what the reader is asking to be told.\n\nNeeds `read:history` and the ability to read that page, on the same terms as the history list. A version whose page has since been deleted answers 404: the permissions that would decide who may read it are page rules, and there is no longer a page to check them against.', + tags: ['Pages'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + versionId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId', 'versionId'] + }, + response: { + 200: { $ref: 'PageVersionById#' } + } + } + }, + async (req, reply) => { + const version = await WIKI.models.pageHistory.getVersionById( + req.params.siteId, + req.params.versionId + ) + if (!version) { + return reply.notFound('This version does not exist.') + } + /* + The page as it stands, which is what carries the access rules — a version has none of its own. + Note the rules are matched against the page's CURRENT path, not the path the version was + written at: a page that has moved is one page, and who may read its history is a question about + where it is now. + */ + const page = await loadReadablePage(req, req.params.siteId, version.pageId) + if (!page) { + return reply.notFound('This version does not exist.') + } + if (!mayOnPage(req, 'read:history', page)) { + return reply.forbidden("You are not allowed to read this page's history.") + } + if (page.isLocked) { + return reply.forbidden('This page is password protected.') + } + /* + The page's CURRENT path and locale, alongside the historical ones already on the version. + + Both are here so a reader looking at a snapshot can be sent to the page as it stands, and + `version.path` cannot do that job: it is where the page was WHEN the version was written, so + for a page that has since moved it points at nothing. Free to include — the page is already + loaded, one line above, to decide whether this reader may be here at all. + */ + return { ...version, pagePath: page.path, pageLocale: page.locale } + } + ) + /** * RESOLVE ALIAS */ diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 03faa5093..cef515176 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -430,7 +430,39 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'object', additionalProperties: true, description: - 'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor and content type.' + 'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor, content type and the contents list (`toc`).' + } + } + } + ] + }) + + /** + * PAGE VERSION BY ID - The same again, saying which page it came off + */ + app.addSchema({ + $id: 'PageVersionById', + type: 'object', + allOf: [ + { $ref: 'PageHistoryVersion#' }, + { + type: 'object', + properties: { + pageId: { + type: 'string', + format: 'uuid', + description: + 'The page this is a version of. Present because a version URL names only the version, so this is how the reader is told what they are looking at a snapshot OF.' + }, + pagePath: { + type: 'string', + description: + 'Where that page is NOW — which is what a link to the live page has to be built from. Not to be confused with `path`, which is where it was when this version was written.' + }, + pageLocale: { + type: 'string', + description: + "The locale that page is in now, needed to prefix the link on a site that brackets its URLs by locale. Its historical counterpart is not recorded on the version's own fields." } } } diff --git a/backend/index.ts b/backend/index.ts index 8b1c0ae3e..b7794f5e8 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -43,8 +43,8 @@ const nanoid = customAlphabet('1234567890abcdef', 10) * First path segments the SERVER itself answers — every prefix registered in `initHTTPServer`. * * Spelled out rather than tested with `isPageUrl`, because a leading underscore does not mean the - * server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit` - * and `/_error` too, and those have to reach the app shell like any page path. The distinction the + * server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit`, + * `/_version` and `/_error` too, and those have to reach the app shell like any page path. The distinction the * shell needs is "does something here serve this", which is this list, and it has to be kept in step * with the registrations below. * diff --git a/backend/locales/en.json b/backend/locales/en.json index 7edb54edb..8563f493e 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1707,7 +1707,7 @@ "common.header.viewSource": "View Source", "common.license.alr": "All Rights Reserved", "common.license.cc0": "Public Domain", - "common.license.ccby": " Creative Commons Attribution License", + "common.license.ccby": "Creative Commons Attribution License", "common.license.ccbync": "Creative Commons Attribution-NonCommercial License", "common.license.ccbyncnd": "Creative Commons Attribution-NonCommercial-NoDerivs License", "common.license.ccbyncsa": "Creative Commons Attribution-NonCommercial-ShareAlike License", @@ -2274,6 +2274,7 @@ "history.action.updated": "Updated", "history.branchFailed": "Failed to create a page from this version.", "history.branchOff": "Branch off from here", + "history.branchOffShort": "Branch off", "history.branchReason": "Branched off from the version of {date}", "history.branchSuccess": "New page created from this version.", "history.changedFields": "Changed: {fields}", @@ -2281,6 +2282,7 @@ "history.downloadFailed": "Failed to download this version.", "history.downloadVersion": "Download Version", "history.emptyPage": "Nothing", + "history.exportPdf": "Export to PDF", "history.inline": "Inline", "history.loadFailed": "Failed to load the page history.", "history.none": "No history has been recorded for this page yet.", @@ -2300,12 +2302,19 @@ "history.setAsSource": "Set as Differencing Source", "history.setAsTarget": "Set as Differencing Target", "history.sideBySide": "Side-by-Side", + "history.snapshotFrom": "Snapshot from", "history.sourceCopied": "Source copied to the clipboard.", "history.title": "Page History", "history.unknownAuthor": "Unknown", "history.versionActions": "Version Actions", "history.versionId": "Version ID {id}", + "history.versionLoadFailed": "Failed to load this version.", + "history.versionUnavailable": "This version is not available", + "history.versionUnavailableHint": "It may have been purged from the history, or you may not be allowed to see it.", + "history.viewLive": "View Live", "history.viewSource": "View Source", + "history.viewVersion": "View Version", + "history.viewingVersionId": "Viewing version ID", "iconPicker.allSets": "All sets", "iconPicker.icons": "Icons", "iconPicker.image": "Image", diff --git a/backend/models/pageHistory.ts b/backend/models/pageHistory.ts index a635a8e30..d7710bfb1 100644 --- a/backend/models/pageHistory.ts +++ b/backend/models/pageHistory.ts @@ -1,5 +1,6 @@ import { isEqual } from 'es-toolkit/predicate' import { and, desc, eq, lt, sql } from 'drizzle-orm' +import type { SQL } from 'drizzle-orm' import { pageHistory as pageHistoryTable, pages as pagesTable, @@ -41,10 +42,18 @@ export type PurgeTimeframe = keyof typeof purgeTimeframes * The page fields a version carries beyond the ones with columns of their own. * * Taken straight off the stored row, so a field added to a page is captured here without this list - * being touched. The exclusions are either derived from the content (`render`, `toc`, `searchContent`, + * being touched. The exclusions are either derived from the content (`render`, `searchContent`, * `ts`), fixed for the page's whole life (`id`, `siteId`, `creatorId`, `createdAt`), or bookkeeping * that says nothing about the version (`hash`, `updatedAt`, `authorId`, `ratingScore`, `ratingCount`, * `historyData`, `isSearchableComputed`). + * + * `toc` is derived as well and is kept regardless, because the version view draws a contents column + * beside the snapshot and there is nowhere else to get one: a version records the page's SOURCE, and + * the headings only exist in the render, which is not recorded. Deriving them on the way out would + * mean parsing the source a second time to answer what the save had already answered. + * + * It stays in `NOT_REPORTED_AS_CHANGED` below, which is a different question: a contents list moves + * whenever the source does, so naming it among a version's changed fields tells a reader nothing. */ const EXCLUDED_FROM_META = new Set([ 'id', @@ -55,7 +64,6 @@ const EXCLUDED_FROM_META = new Set([ 'authorId', 'hash', 'render', - 'toc', 'searchContent', 'ts', 'ratingScore', @@ -115,6 +123,58 @@ export type PageHistoryVersion = PageHistoryEntry & { meta: Record } +/** + * One version row, by whatever identifies it. + * + * Shared by the two ways in — page and version, or version alone — because they differ only in the + * `where`, and a second copy of this projection is a second place for the two to drift apart. + */ +async function selectVersionRow(where: SQL | undefined): Promise { + const rows = await WIKI.db + .select({ + id: pageHistoryTable.id, + pageId: pageHistoryTable.pageId, + action: pageHistoryTable.action, + changedFields: pageHistoryTable.changedFields, + reason: pageHistoryTable.reason, + versionDate: pageHistoryTable.versionDate, + path: pageHistoryTable.path, + title: pageHistoryTable.title, + content: pageHistoryTable.content, + meta: pageHistoryTable.meta, + authorId: usersTable.id, + authorName: usersTable.name, + authorEmail: usersTable.email + }) + .from(pageHistoryTable) + .leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId)) + .where(where) + .limit(1) + + return rows[0] ?? null +} + +/** That row as a version. `pageId` is deliberately not on it — only one caller wants it. */ +function toVersion(row: any): PageHistoryVersion { + return { + id: row.id, + action: row.action, + changedFields: row.changedFields ?? [], + reason: row.reason ?? '', + versionDate: row.versionDate, + path: row.path, + title: row.title, + content: row.content ?? '', + meta: (row.meta ?? {}) as Record, + author: { + // -> Null once the account is gone: the version outlives it, see the column's own note + id: row.authorId ?? null, + name: row.authorName ?? '', + email: row.authorEmail ?? '' + } + } +} + /** * Page history model * @@ -248,52 +308,40 @@ class PageHistory { pageId: string, versionId: string ): Promise { - const rows = await WIKI.db - .select({ - id: pageHistoryTable.id, - action: pageHistoryTable.action, - changedFields: pageHistoryTable.changedFields, - reason: pageHistoryTable.reason, - versionDate: pageHistoryTable.versionDate, - path: pageHistoryTable.path, - title: pageHistoryTable.title, - content: pageHistoryTable.content, - meta: pageHistoryTable.meta, - authorId: usersTable.id, - authorName: usersTable.name, - authorEmail: usersTable.email - }) - .from(pageHistoryTable) - .leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId)) - .where( - and( - eq(pageHistoryTable.siteId, siteId), - eq(pageHistoryTable.pageId, pageId), - eq(pageHistoryTable.id, versionId) - ) + const row = await selectVersionRow( + and( + eq(pageHistoryTable.siteId, siteId), + eq(pageHistoryTable.pageId, pageId), + eq(pageHistoryTable.id, versionId) ) - .limit(1) + ) + return row ? toVersion(row) : null + } - const row: any = rows[0] - if (!row) { - return null - } - return { - id: row.id, - action: row.action, - changedFields: row.changedFields ?? [], - reason: row.reason ?? '', - versionDate: row.versionDate, - path: row.path, - title: row.title, - content: row.content ?? '', - meta: (row.meta ?? {}) as Record, - author: { - id: row.authorId ?? null, - name: row.authorName ?? '', - email: row.authorEmail ?? '' - } - } + /** + * The same version addressed by its ID ALONE, with the page it belongs to named in the reply. + * + * What `/_version/` needs. A version URL is a link somebody was handed — out of the history + * timeline, a notification, a message — and the one thing such a link can reasonably carry is the + * version's own ID: the page it came off is exactly what the reader is asking to be told, and a URL + * that already had to name it would be a URL they could not have been given in the first place. + * + * `pageId` comes back because the caller has permissions to check and they are page rules — the + * version carries no access of its own, so it has to be turned back into a page first. Everything + * else is what {@link getVersion} returns. + * + * Scoped to the site regardless, so a version ID from one site cannot be read through another's URL. + * + * @returns The version and its page, or null when this site has no such version + */ + async getVersionById( + siteId: string, + versionId: string + ): Promise<(PageHistoryVersion & { pageId: string }) | null> { + const row = await selectVersionRow( + and(eq(pageHistoryTable.siteId, siteId), eq(pageHistoryTable.id, versionId)) + ) + return row ? { ...toVersion(row), pageId: row.pageId } : null } /** diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 2fffaa5cf..90ba81c7e 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -10,8 +10,38 @@ "chrome": "readonly", "API_CLIENT": "readonly", "EVENT_BUS": "readonly", - "Temporal": "readonly" + "Temporal": "readonly", + + // Vue's compiler macros. Not imports and not real globals -- ` diff --git a/frontend/src/css/_page-chrome.scss b/frontend/src/css/_page-chrome.scss new file mode 100644 index 000000000..3cdd78467 --- /dev/null +++ b/frontend/src/css/_page-chrome.scss @@ -0,0 +1,416 @@ +/* + The chrome a page view is drawn in: the breadcrumb bar, the title header, and the column the article + and its footer scroll inside. Plus the placeholder screen that stands in for the article when there + is nothing to draw there. + + Here rather than in `pages/Index.vue`, where all of it began, because there is now more than one view + wearing it -- `pages/PageVersion.vue` draws a historical snapshot in the same chrome. An SFC's + unscoped styles ship in that component's own lazily-imported chunk, so a second view reusing these + class names got the markup and none of the rules, which is the way this goes wrong invisibly: the + header renders, and it is a plain box with no gradient, no border and no height. + + The contents column is here too, panel behaviour and all: both views have one, and the version view's + is the same column drawing the same list from the same `toc`. What stayed behind in `Index.vue` is + only what a snapshot has no use for -- the toggle on the tags heading, tags being editable on a page + and not on a record of one. +*/ + +/* + The Sass variables, loaded explicitly. `vite.config.js` injects both of these into every SFC through + `additionalData`, but that reaches only the files Vite itself hands to Sass -- a partial pulled in by + `@use` from `app.scss` is resolved by Sass and arrives without it. Same note as `_page-contents.scss`, + which is here for the same reason. + + Both, not just the palette: the greys and the breakpoint come from `_palette.scss`, the `$dark-*` ramp + these gradients are built on from `_theme.scss`. +*/ +@use 'palette' as *; +@use 'theme' as *; + +/* + Where the contents column stops being able to afford 300px. A page view's own threshold, not one of the + app's -- `_palette.scss` is for the breakpoints the whole app shares, and this one is a function of a + page's two sidebars. Stated as a `max` value just under 1400px, the way the shared ones are. +*/ +$toc-narrow-max: 1399.98px; + +/* + ...and where it stops being a column at all and becomes a panel over the article. The same boundary as + the 750px `useMinWidth` in each view, which decides whether that view renders the opener, and as the + one each layout uses to stand scroll-to-top down from this corner. They all have to agree. +*/ +$toc-overlay-max: 749.98px; + +/* + The column in place of the article: the lock screen, the page that does not exist, and the + redirection on its way somewhere else. All three are the same shape -- a large faint icon, a + sentence, and the one button that does something about it -- and share the styling so they cannot + drift apart. `PageRedirect.vue` draws its own screens with these classes for that reason. +*/ +.page-placeholder { + display: flex; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + /* -> Off dead centre: the text reads better a little above the middle of the column */ + padding: 0 24px 10vh; + text-align: center; + + /* + Stated per theme, as everything else in this column is: the article's own colours come from + `_page-contents.scss`, so a plain block dropped in beside it inherits the document's black and + goes invisible on the dark surface. The icon below takes its colour from here as well. + */ + @at-root .body--light & { + color: $grey-9; + } + @at-root .body--dark & { + color: #fff; + } +} + +/* + Large and faint. It is the illustration on an otherwise empty column, not something to look at -- the + sentence under it is what the reader is here to read. +*/ +.page-placeholder-icon { + margin-bottom: 24px; + font-size: 96px; + opacity: 0.12; +} + +.page-breadcrumbs { + @at-root .body--light & { + background: linear-gradient(to bottom, $grey-1 0%, $grey-3 100%); + border-bottom: 1px solid $grey-4; + } + /* + The bar sets a background per theme, so it owes a foreground too: the LAST crumb -- the current + page -- deliberately inherits rather than taking `active-color`, and what it was inheriting in + dark mode was the document's black. + */ + @at-root .body--light & { + color: var(--color-black); + } + @at-root .body--dark & { + background: linear-gradient(to bottom, $dark-3 0%, $dark-4 100%); + border-bottom: 1px solid $dark-3; + color: var(--color-white); + } + + /* + A point off the trail on a phone, on the bar rather than on the crumbs: `WBreadcrumbs` sets no size + of its own and its icons are 125% of whatever it inherits, so one declaration here takes the text and + the icons down together and keeps the two in proportion. + + 13px is where it stops. The trail is how a reader gets back out, and it is already the smallest type + on the screen -- what is wanted is a bar that gives way to the page under it, not one nobody can read. + */ + @media (max-width: $breakpoint-xs-max) { + font-size: 0.8125rem; + } +} + +/* + The version view's bar, which says which snapshot is on screen rather than where a page sits. + + Indigo and not the page view's grey, because it is not the same statement: the trail on a page is + chrome a reader looks past, while this one is the only thing on screen saying that what is under it + is a RECORD of a page and not the page. A coloured band is what carries that at a glance, and indigo + is the colour the interface already gives history -- the Schedule tab's calendar, the version + timeline's own dots. + + Both themes take the same treatment rather than the light one being tinted and the dark one left + dark: the point is a band that stands out from the chrome above and below it, and in the dark theme + a grey bar between a dark header and a dark article is exactly what does not. + + Declared after the block above and overriding it at equal specificity, so source order is what + settles it -- one file, one place to look, and nothing depending on which chunk loaded last. + + The foreground is stated once for everything in the bar, which is why the markup carries no colour + of its own: the icon paints with `currentColor` and the date inherits. `` is what marks the + date out, as it did when the bar was grey. +*/ +.page-breadcrumbs--version { + @at-root .body--light & { + background: linear-gradient(to bottom, var(--color-indigo-6) 0%, var(--color-indigo-7) 100%); + border-bottom: 1px solid var(--color-indigo-9); + color: #fff; + } + /* -> A step down the same ramp, so the band reads as deliberate against a dark page rather than as + the light theme's bar left switched on */ + @at-root .body--dark & { + background: linear-gradient(to bottom, var(--color-indigo-7) 0%, var(--color-indigo-9) 100%); + border-bottom: 1px solid var(--color-indigo-9); + color: #fff; + } +} + +/* + The frame round the whole version view: 5px down both sides and along the bottom, with the bar above + closing the fourth. Together they box the screen in, which is the point -- the indigo says "what you + are reading is a RECORD of a page", and that is true of the whole view rather than of its first 30px. + + Its colour is the DARK end of that bar's gradient in each theme, which is what makes the top corners + corners: the bar finishes on this exact value, so where the two meet there is no seam and the eye + reads one shape bent round the content. Picking the light end instead would draw a line across the + join. + + No top width, since the bar is already there -- a border under it would be a second line saying the + same thing, and the bar has an underline of its own. + + On the page element, which is what makes all three sides land where they should: it is a flex item in + `WPageContainer` that also claims `h-full`, so it is exactly as tall as the content cell, and the + bottom edge sits at the bottom of the window rather than below the fold. The article scrolls inside + its own box further in (`.page-container-scrl`), so the frame holds still while the content moves + behind it. + + `box-sizing: border-box` is global (Tailwind's reset), so all 10px of the sides come out of the + content box and the view does not grow a scrollbar in either direction. That reset also declares + `border: 0 solid`, which is why a width and a colour are the whole of this -- and why the widths can + be stated once for both themes with only the colour switching. +*/ +.page-version { + border-style: solid; + border-width: 0 5px 5px; + + @at-root .body--light & { + border-color: var(--color-indigo-7); + } + @at-root .body--dark & { + border-color: var(--color-indigo-9); + } +} + +.page-header { + height: 95px; + + /* + Sized by its contents on a phone instead, which comes out around 70px: the 95px is pitched for a 64px + icon beside 34px display type, and holding it under the halved icon and title of the phone layout left + a band of empty gradient under the description. + + `auto` rather than a smaller fixed height, because a fixed one is what the desktop bar can only just + afford: a title long enough to wrap has nowhere to go in it. Here the bar grows by a line instead, and + a page with no description gets a bar shorter still. + */ + @media (max-width: $breakpoint-xs-max) { + height: auto; + } + + @at-root .body--light & { + background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%); + border-bottom: 1px solid $grey-4; + border-top: 1px solid #fff; + } + @at-root .body--dark & { + background: linear-gradient(to bottom, $dark-4 0%, $dark-3 100%); + // border-bottom: 1px solid $dark-5; + border-top: 1px solid $dark-6; + } + + .no-height .q-field__control { + height: auto; + } + + &-title { + /* + `text-h4` is a 34px display size written for a header the width of a desktop window: at 390px a + title of any length wrapped and pushed the description out of the bar. 24px is the step + `text-h5` takes, written as a value rather than as that class so the size sits beside the + breakpoint asking for it. + + Here rather than in a header component's scoped block, where it started: scoped rules carry a + data attribute and reach only that one component's markup, so the second header wearing these + classes rendered a 34px title on a phone. The header's own `@media` block keeps what is its own + business -- which of ITS actions survive the narrow layout. + */ + @media (max-width: $breakpoint-xs-max) { + font-size: 1.5rem; + line-height: 2rem; + } + + @at-root .body--light & { + color: $grey-9; + } + @at-root .body--dark & { + color: #fff; + } + } + &-subtitle { + @at-root .body--light & { + color: $grey-7; + } + @at-root .body--dark & { + color: rgba(255, 255, 255, 0.6); + } + } +} +/* + The article and the footer under it, stacked inside the one box that scrolls. + + `flex: 1 0 auto` on the article is what keeps the footer at the BOTTOM of a short page instead of + leaving it hanging under two lines of content: the article takes the leftover height, and past that + grows with its own content and pushes the footer out of view until the reader gets there. It must + not shrink either, or a long article would be squeezed to make room rather than scrolling. +*/ +.page-container-scrl { + display: flex; + flex-direction: column; +} +.page-container-body { + flex: 1 0 auto; + + /* + The other half of the padding change each view makes on a phone, where the article column pads by + 8px a side instead of 16. + + `--content-bleed` is how far the rule under an h1 reaches BACK through the padding of whatever holds + the content, so that it starts at the sidebar rather than at the text -- so it is a statement about + this surface's padding, and left at 1rem against 0.5rem of it the rule overhung the column by 8px. + `_page-contents.scss` declares the property expecting exactly this: a surface that pads differently + overrides the one property rather than the rule. + + On the `.page-contents` element rather than here, because that is where the default is declared and a + custom property set on the parent would simply be shadowed by it. The editor's preview pane carries + the class itself and still pads 1rem, so it keeps the default. + */ + @media (max-width: $breakpoint-xs-max) { + .page-contents { + --content-bleed: 0.5rem; + } + } +} + +/* + A hairline of the page's OWN background between the header and whatever the column starts with, in + each theme's colour -- so it is invisible against the article, which is that colour, and reads as one + pixel of daylight under anything that starts flush to the top of the column. A site banner does + exactly that, and against the header's bottom border it needs the gap. + + Both themes: with the dark one left out the banner butted straight into the header there and not in + the light theme, which is the sort of difference that reads as a bug in whichever one you see second. +*/ +.page-container { + @at-root .body--light & { + border-top: 1px solid #fff; + } + @at-root .body--dark & { + border-top: 1px solid $dark-6; + } +} + +.page-sidebar { + flex: 0 0 300px; + + /* + Narrower once the window is: 300px is pitched for a wide desktop, where it is a tenth of the width, and + by 1200px it is a quarter of what is left after the nav sidebar. 200px still holds a heading of a few + words per line -- the contents list wraps rather than truncating (see `PageToc`) -- and hands the + article the other 100px. + + 1400px is this view's own threshold rather than one of the app's `--breakpoint-*`: it is where THIS + column starts crowding the article, which depends on its own width and the nav's. + */ + @media (max-width: $toc-narrow-max) { + flex: 0 0 200px; + } + + /* + And below 750px it stops being a column at all: even at 200px it is a third of a 600px window, and an + article is what the reader came for. It becomes a panel the width of the wide column, parked off the + right edge and slid in when asked for -- the same shape as the nav drawer on a narrow screen, and for + the same reason, so the two behave alike from opposite sides. + + `position: fixed` is what takes it out of the row, so the article gets the whole width whether the + panel is open or not; the reader is never made to choose between the two, only to look at one at a + time. `transform` is what animates, being the one property that moves a box without laying anything + out again -- and the panel is out of flow, so there is nothing behind it to reflow anyway. + + Right regardless of `tocPosition`: the opener is in the bottom-RIGHT corner, and a panel arriving from + the far side of the screen from the button that summoned it reads as something else appearing. + */ + @media (max-width: $toc-overlay-max) { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 40; + /* -> The wide column's width, capped so it cannot take the whole of a small screen */ + width: 300px; + max-width: 85vw; + transform: translateX(100%); + transition: transform 0.2s var(--ease-standard); + box-shadow: -2px 0 12px rgb(0 0 0 / 0.3); + + &.is-open { + transform: none; + } + } + + @at-root .body--light & { + background-color: $grey-2; + } + @at-root .body--dark & { + background-color: $dark-5; + } + + // A light rule on the light sidebar, near-black on the dark one -- it reads as the bevel between + // two panels rather than as a drawn line. + // + // The original set a background-colour here as well as a border. It never showed: the element is + // 1px tall with `box-sizing: border-box`, so the content box is 0px and the opaque border covers + // it completely. Only the border colour is carried across. + .w-separator { + --w-hairline-color: #fff; + } + @at-root .body--dark & .w-separator { + --w-hairline-color: #070a0d; + } + + /* + The column is the height of the shell, so its own content scrolls when there is more of it than + there is room -- a long contents list, in practice. Nothing sticky is involved: the shell holds + still on its own, and the article beside this scrolls in its own box. + */ + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: rgb(102 102 102 / 0.5) transparent; +} + +/* + Behind the panel, and under it: the same tint and the same z-index as the nav drawer's scrim, one step + below the panel it dims. The opener is at z-30 as well and is not rendered while the panel is open, so + the two never overlap. +*/ +.page-sidebar-scrim { + position: fixed; + inset: 0; + z-index: 30; + background-color: rgb(0 0 0 / 0.4); +} + +.page-sidebar-scrim-enter-active, +.page-sidebar-scrim-leave-active, +.toc-open-btn-enter-active, +.toc-open-btn-leave-active { + transition: opacity 0.2s var(--ease-standard); +} +.page-sidebar-scrim-enter-from, +.page-sidebar-scrim-leave-to, +.toc-open-btn-enter-from, +.toc-open-btn-leave-to { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .page-sidebar, + .page-sidebar-scrim-enter-active, + .page-sidebar-scrim-leave-active, + .toc-open-btn-enter-active, + .toc-open-btn-leave-active { + transition-duration: 0.01ms; + } +} diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index 426f9844f..367f9bbb4 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -1404,14 +1404,13 @@ } /* - Diagram sources -- a mermaid, plantuml or kroki fence, or a base64 `diagram` block -- reach the - page as code, and are drawn later or not at all. A quiet dashed panel says "this is a diagram that - has not been drawn" rather than pretending to be a code sample. + Diagram sources -- a mermaid, plantuml or kroki fence -- reach the page as code, and are drawn + later or not at all. A quiet dashed panel says "this is a diagram that has not been drawn" rather + than pretending to be a code sample. */ pre.codeblock-kroki, pre.codeblock-mermaid, - pre.codeblock-plantuml, - pre.diagram { + pre.codeblock-plantuml { border-style: dashed; border-color: var(--content-rule-strong); background-color: var(--content-surface-alt); diff --git a/frontend/src/css/app.scss b/frontend/src/css/app.scss index b466d698b..f23cb7a4d 100644 --- a/frontend/src/css/app.scss +++ b/frontend/src/css/app.scss @@ -1,4 +1,5 @@ @use 'base'; @use 'animation'; +@use 'page-chrome'; @use 'page-contents'; diff --git a/frontend/src/helpers/pageVersions.js b/frontend/src/helpers/pageVersions.js new file mode 100644 index 000000000..bc60e60c9 --- /dev/null +++ b/frontend/src/helpers/pageVersions.js @@ -0,0 +1,103 @@ +import { fileSave } from 'browser-fs-access' + +import { MarkdownRenderer } from '@/renderers/markdown' + +/** + * What a recorded page version is, to the two screens that show one. + * + * The history overlay and the version view both have to answer the same questions about a version -- + * what format was it written in, what does its source save as, and what HTML does it become -- and + * they used to answer them separately. The format question alone is asked six times between them + * (colouring a diff, two renders, two downloads, a restore), so it lives here once. + * + * Nothing here touches a store or the i18n catalogue, which is what keeps it a helper: a caller + * supplies the renderer config it already holds, and reports failures in its own words. + */ + +/** What a version's source is saved as, by the format it was written in. */ +const FILE_TYPES = { + markdown: { ext: 'md', mime: 'text/markdown' }, + html: { ext: 'html', mime: 'text/html' } +} + +/** For a format nothing here knows: plain text saves and reads as itself. */ +const FALLBACK_TYPE = { ext: 'txt', mime: 'text/plain' } + +/** + * The format a version was written in -- which decides how it colours, how it renders and what it + * downloads as. + * + * Read off the VERSION and never off the page it belongs to: a page converted from markdown to HTML + * since is still a markdown version, and asking the page would render it as the wrong thing. + * + * `meta.contentType` is what `pageHistory.record` writes for every version, so the rest is belt and + * braces for a row that somehow lacks it. + * + * @param {object} version A version as the API returns one. + * @returns {string} `markdown`, `html`, or whatever the version claims. + */ +export function versionContentType(version) { + return version?.meta?.contentType || version?.meta?.editor || 'markdown' +} + +/** + * Save a version's source to a file the reader picks. + * + * Named for the page and the moment -- `notes-2026-09-07-03-30-38.md` -- because a folder of + * `page.md` files says nothing about which page or which version each one is. + * + * @param {object} version A version WITH its `content`; the history list alone does not carry it. + * @returns {Promise} True once written, false if the reader dismissed the picker. Anything + * that actually went wrong is thrown, for the caller to report in its own words. + */ +export async function saveVersionSource(version) { + const type = FILE_TYPES[versionContentType(version)] ?? FALLBACK_TYPE + const name = version.path?.split('/').at(-1) || 'page' + const stamp = (version.versionDate ?? '').slice(0, 19).replace(/[:T]/g, '-') + try { + /* + A bare MIME type, with no `;charset=` on it: the save picker uses this as an `accept` key and + rejects a type carrying parameters outright. Nothing is lost by dropping it -- a Blob built from + a JS string is UTF-8 already. + */ + await fileSave(new Blob([version.content ?? ''], { type: type.mime }), { + fileName: `${name}-${stamp}.${type.ext}`, + extensions: [`.${type.ext}`] + }) + return true + } catch (err) { + // -> Dismissing the file picker is not a failure, and nothing should be said about it + if (err.name === 'AbortError') { + return false + } + throw err + } +} + +/** + * A version's source as the HTML a page stores. + * + * Produced on this side for the same reason every save produces it here: the markdown pipeline is a + * frontend one, and the server would otherwise have to drive a headless browser. A version records + * the source it held and not the render, so there is nothing stored to use instead. + * + * The config is passed IN rather than read from `stores/editor` here, because no other helper in this + * directory reaches for a store and this is not the file to start in. The caller has it already, and + * has to make sure it is loaded (`editorStore.fetchConfigs()`) before asking. + * + * @param {object} version A version WITH its `content`. + * @param {object} options + * @param {object} options.markdownConfig `editorStore.editors.markdown` — per-site renderer settings + * (line breaks, typographer, …). + * @param {string} options.pagePath The page this HTML is FOR, which is what a relative image in it + * resolves against. Not always the version's own `path`: content being restored onto a page that + * has since moved belongs to where that page is now. + * @returns {string} The HTML, or the source unchanged for a format this does not render. + */ +export function renderVersionSource(version, { markdownConfig, pagePath }) { + const content = version?.content ?? '' + if (versionContentType(version) !== 'markdown') { + return content + } + return new MarkdownRenderer(markdownConfig ?? {}).render(content, { pagePath }) +} diff --git a/frontend/src/helpers/renderedContent.js b/frontend/src/helpers/renderedContent.js index c6b4688fa..e05441808 100644 --- a/frontend/src/helpers/renderedContent.js +++ b/frontend/src/helpers/renderedContent.js @@ -249,3 +249,43 @@ export function sameDocumentHash({ href, target, download, rel } = {}, current) return url.hash } + +/** + * What a click inside rendered content is asking for, if it is asking for anything. + * + * Every view that draws wiki content faces the same three-way decision -- an anchor on the page in + * front of the reader, a link into the wiki, or something to leave to the browser -- so the decision + * lives here and each view acts on the answer. `pages/Index.vue` reads a live page and + * `pages/PageVersion.vue` a snapshot of one; a link in either behaves the same way, and would drift + * if each worked it out for itself. + * + * The modifier tests are what keeps middle-click, ctrl-click and shift-click doing what they do + * everywhere else: opening a tab or a window is the browser's, not the router's. + * + * @param {MouseEvent} ev The click, as delegated from the element holding the content. + * @param {Location|{origin: string, pathname: string}} current Where the reader is now. + * @returns {{kind: 'hash', hash: string}|{kind: 'route', target: string}|null} What to do, or null to + * leave the click alone. + */ +export function resolveContentClick(ev, current) { + if ( + ev.defaultPrevented || + ev.button !== 0 || + ev.metaKey || + ev.ctrlKey || + ev.shiftKey || + ev.altKey + ) { + return null + } + const anchor = ev.target?.closest?.('a[href]') + if (!anchor) { + return null + } + const hash = sameDocumentHash(anchor, current) + if (hash) { + return { kind: 'hash', hash } + } + const target = routableHref(anchor, current) + return target ? { kind: 'route', target } : null +} diff --git a/frontend/src/layouts/VersionLayout.vue b/frontend/src/layouts/VersionLayout.vue new file mode 100644 index 000000000..afb81fd1a --- /dev/null +++ b/frontend/src/layouts/VersionLayout.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/pages/AdminNavigation.vue b/frontend/src/pages/AdminNavigation.vue index 94d8c92ca..03723276a 100644 --- a/frontend/src/pages/AdminNavigation.vue +++ b/frontend/src/pages/AdminNavigation.vue @@ -482,6 +482,17 @@ function copyFromLocale() { async function save() { this.$store.commit('loadingStart', 'admin-navigation-save') try { + /* + FIXME: This whole handler is dead. `APOLLO_CLIENT` is not defined anywhere -- the GraphQL client + went with the rest of Apollo -- so saving the navigation throws here, and the nine + `this.$store.commit(...)` calls around it throw too, this being ` diff --git a/frontend/src/pages/PageVersion.vue b/frontend/src/pages/PageVersion.vue new file mode 100644 index 000000000..a0a2e4c22 --- /dev/null +++ b/frontend/src/pages/PageVersion.vue @@ -0,0 +1,551 @@ + + + diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js index ae2a0e5f6..f3cc75093 100644 --- a/frontend/src/renderers/markdown.js +++ b/frontend/src/renderers/markdown.js @@ -311,13 +311,10 @@ function lineRows(lineCount, lineStart, highlights) { * @param {string} str The code, as the author wrote it. * @param {string} lang The first word of the info string. * @param {object} attributes The rest of it, parsed -- see `parseFenceAttributes`. Ignored by the - * diagram branches, which are a source for something else to draw and have + * diagram branch, which is a source for something else to draw and has * no gutter, no title bar and no lines to mark. */ function codeBlock(str, lang, attributes) { - if (lang === 'diagram') { - return `
${Buffer.from(str, 'base64').toString()}
` - } if (['kroki', 'mermaid', 'plantuml'].includes(lang)) { /* Left as source, deliberately: a diagram is drawn by the block whose body it is — diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index 154a1d53f..25a2b0028 100644 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -117,6 +117,22 @@ const routes = [ children: [{ path: '', component: () => import('../pages/Index.vue') }] }, // -------------------------------- + // PAGE VERSION + // -------------------------------- + /* + One recorded version of a page, read on its own. Addressed by the version alone -- a version URL is + a link somebody was handed, and the page it came off is exactly what the reader is asking to be + told, so a URL that already had to name it is one they could not have been given. + + Its own layout rather than `MainLayout`: a snapshot is reached from a link rather than browsed to, + so there is no navigation sidebar beside it. See `VersionLayout.vue`. + */ + { + path: '/_version/:versionId', + component: () => import('@/layouts/VersionLayout.vue'), + children: [{ path: '', component: () => import('@/pages/PageVersion.vue') }] + }, + // -------------------------------- // EDIT // -------------------------------- {