From d3c89e8c6653d87995d1657c91c102863262eaa0 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 29 Sep 2026 03:01:00 +0000 Subject: [PATCH] Visitor contact messaging: in site communication system with email readiness (#3) - `{contact}` or `{contact: Label}` to add a button on article, additional access on top right corner letter emoji - Article button styling (with custom shape for Nitro theme) - Visitor contact panel: live thread over the activity socket, rich composer (formatting,/file uploads), PoW anti-spam gating, contact email reveal after proof - Admin inbox in the same overlay: thread list, replies, read marking, unread badge; background watch keeps badge and notifications live - Messaging site settings (button label, email subject, our email) with per-language overrides and auto-translation support - Browser notifications for new messages on both sides, with click-through to the new content - Close conversation: archives the thread for admins, starts visitors fresh Reviewed-on: https://git.zi.fi/LeoVasanko/pagerite/pulls/3 --- AGENTS.md | 21 +- docs/analytics.md | 50 ++- docs/backend.md | 4 +- docs/editing.md | 2 +- docs/frontend-runtime.md | 2 +- docs/localization.md | 7 + docs/messages.md | 264 ++++++++++++ frontend/src/AnalyticsView.vue | 194 +++++++-- frontend/src/BannerEditor.vue | 47 ++- frontend/src/Composer.vue | 261 ++++++++++++ frontend/src/ContactOverlay.vue | 161 ++++++++ frontend/src/ContactPanel.vue | 169 ++++++++ frontend/src/MessagesView.vue | 495 +++++++++++++++++++++++ frontend/src/PageEditor.vue | 43 +- frontend/src/SiteEditor.vue | 244 +++++++++++ frontend/src/StructureTree.vue | 3 +- frontend/src/TrailLink.vue | 10 + frontend/src/TransitionGraph.vue | 51 ++- frontend/src/analytics/format.js | 3 + frontend/src/analytics/time.js | 6 +- frontend/src/analytics/transitions.js | 183 +++++---- frontend/src/assets/pagerite.css | 92 +++++ frontend/src/contact-main.js | 409 +++++++++++++++++++ frontend/src/notify.js | 33 ++ frontend/src/pagerite.js | 264 ++++++++++-- frontend/src/reconnect.js | 35 ++ frontend/src/richtext.js | 119 ++++++ frontend/vite.config.js | 3 +- pagerite/analytics.py | 51 +++ pagerite/api.py | 114 +++++- pagerite/app.py | 8 +- pagerite/data.py | 65 +++ pagerite/feeds.py | 5 +- pagerite/files.py | 88 +++- pagerite/i18n.py | 30 ++ pagerite/messages.py | 557 ++++++++++++++++++++++++++ pagerite/pages.py | 55 ++- pagerite/themes/corporate/theme.css | 2 +- pagerite/themes/nitro/theme.css | 71 +++- pagerite/tracking.py | 98 ++++- pagerite/translate.py | 22 + pagerite/views.py | 106 ++++- pyproject.toml | 1 + 43 files changed, 4183 insertions(+), 265 deletions(-) create mode 100644 docs/messages.md create mode 100644 frontend/src/Composer.vue create mode 100644 frontend/src/ContactOverlay.vue create mode 100644 frontend/src/ContactPanel.vue create mode 100644 frontend/src/MessagesView.vue create mode 100644 frontend/src/contact-main.js create mode 100644 frontend/src/notify.js create mode 100644 frontend/src/richtext.js create mode 100644 pagerite/messages.py diff --git a/AGENTS.md b/AGENTS.md index cf26734..c992f4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,17 +10,18 @@ Please instead ask the user to see from dev tools what you need, e.g. to look up Pagerite is a CMS. See `docs` for the full design and implementation details. Key files for code changes: - `pagerite/` — Python backend package (hatchling build target). - - `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/files routers, then `frontend.route(app, "/")`, then the pages catch-all last). + - `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/messages/files routers, then `frontend.route(app, "/")`, then the pages catch-all last). - `state.py` — shared core, no routes: env-derived site constants, `data`/`kanta`, `analytics_store`, the fastapi-vue `frontend`, the render cache (`_html_response`, `_invalidate_pages`), the translator `dispatcher`, slug helpers, `@kanta.bootstrap` hooks. - - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`), file routes (`/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, favicon settings). + - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings). - `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`. - - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). - - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. - - `feeds.py` — machine-readable exports: `/llms.txt`, `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); all published articles, full content, linked from every page `` and the sitemap, recorded in analytics. + - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames and holds the per-connection easypow challenge (a validated proof grants the per-session message/upload quota held in messages.py, reveals the site contact email, and records the visit's contact trail marker), the analytics socket the admin inbox. + - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols (PoW challenges live in `tracking.activity_ws` as a per-connection local; the granted quota here as a per-session RAM budget shared with the upload route; browser solver served at `/_contact/easypow.js`), the admin unread count for `/_api/settings`, and the public `/_contact/files` upload route (PoW quota + pending-upload quota, unreferenced files deleted when the session's last socket closes). + - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via ``, and llms.txt links point at the `.md` URLs. + - `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `` and the sitemap, recorded in analytics. - `data.py` — msgspec Structs for the kanta database. - `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md). - - `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh). - - `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route. + - `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh), plus the site-string translations (`site_string`/`set_string_translation`) for the contact label/subject (docs/messages.md). + - `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route. Pending title/nav work includes the site contact strings, so the contact chrome localizes automatically. - `segments.py` — the translation round trip: fragments split into pure-prose wire segments (via markdown.make_md's verbatim parser; link- and formatting-carrying blocks stay whole, link/formatted texts inline, Markdown stripped) and translations spliced back by source offset, link/formatting markdown re-inserted at weight-mapped positions (docs/localization.md). - `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field. - `markdown.py` — markdown-it-py renderer. @@ -31,10 +32,12 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `main.js` — Vue editor app entry. - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). - `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown). + - `contact-main.js` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked, and at page load when a message session is live: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected while open — a background `watchAdmin` connection keeps the badge and notifications live at all times). A visitor's open conversation is flagged in localStorage and resumed on page load; closing it archives the thread server-side and rotates the token. Both sides notify new messages via the browser Notification API (`notify.js`), permission asked after the first message of a fresh thread. + - `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly. - `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state. - `pagerite.js` — public page entry. - `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`). - - `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling. + - `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling. Also the auth-aware close handling for the admin sockets (`authReconnect`): probe `/_api/settings` on close, run the paskia login flow on 401/403 and reconnect at once, pace only genuine network/server failures. - `assets/` — base CSS, Pygments styles, fonts. - `scripts/devserver.py` — dev server with auto reload (the user mostly uses this; avoid running the server yourself, ask the user to test). - `scripts/translator.py` — Seed-X translator service client for the `/_translate/{key}` socket (reference client, runs in its own uv env via PEP 723); stays connected full time, unloads the model after 60 s idle and reloads on the next job. @@ -45,7 +48,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed). ## Toolchain -- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). +- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `easypow`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). - Key libraries: - **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML). - To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header diff --git a/docs/analytics.md b/docs/analytics.md index 3e4284c..43a4b23 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -119,7 +119,11 @@ transition applies the new `` only after the ping goes out) and follows the session, so the activity of a visit stays tied together, and while the user is active the accumulated reading time is flushed every few seconds: the times are incremental, so a disconnection simply leaves the -last reported time in place (no close beacon). After 5 minutes without +last reported time in place (no close beacon). A sleeping computer accrues +no reading time: the inactivity-cutoff timer cannot fire while asleep (and +`performance.now()` keeps running through sleep on some platforms), so a +gap longer than the timeout closes the span at its intended cutoff, and a +late-firing cutoff timer clamps its accumulation the same way. After 5 minutes without any activity the client closes the socket itself — a sleeping browser tab would lose it anyway — and the next activity reconnects; reconnects are attempted only on user activity, with an exponential backoff between @@ -195,7 +199,11 @@ for misses. re-visited in a different language becomes a distinct trail step instead of merging into the existing item. The entry page's referer and `utm_*` tags come from the GET that loaded it (within 10 s before the - first message). + first message). A server-originated contact marker (the visitor opened + the contact panel and solved its proof of work, docs/messages.md — + recorded by the `/_ws` handler on the validated solution, never by the + client) flags the trail step it happened on, the first one per visit, + rendered with a ✉️ marker in the viewer. - **Crawler hits**: a document GET no activity message matched within `_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch documents never register as visits. JS-running crawlers (Googlebot, @@ -341,8 +349,8 @@ the edit pens) links to `/_a`, the analytics page. It is a normal site page: the standard banner, navigation and footer stay in place, and the analytics content is rendered inside `#main`. The page itself is public, but the data stream comes from `WebSocket /_api/ws/analytics`, which remains admin-gated -like the rest of the management API; visitors without access see the viewer -with a "could not be loaded" message. +like the rest of the management API; without a session the viewer starts the +paskia login flow instead of loading data. Because it is a real page, fetch-navigation handles it like any other internal link: clicking the 📊 pen (or any link to `/_a`) fetches the server-rendered @@ -355,6 +363,24 @@ spans less than 24 hours, otherwise `week`. `AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open` page-chrome hiding and `#/analytics/` hash routing have been removed. +The viewer owns its connection lifecycle (the probe/login mechanics are the +shared `authReconnect` in `reconnect.js`, also used by the editor sockets). When the socket drops it probes +`/_api/settings` (plain `fetchJson`, never pops a dialog) to tell an expired +session from a dead network: a 401/403 runs the paskia login flow instead of +retrying (the WS handshake cannot open the login dialog) and the stream +reconnects the moment the login completes; an aborted login (declined, or a +proxy offering no paskia flow) leaves for the front page with a history +replace, so Back does not return to `/_a` just to pop the login again. +Anything else retries with the shared paced backoff (`reconnect.js`). +paskia's `auth-success`/`auth-logout` window messages are followed too: a +login anywhere in the page (the banner's 🔑 button included) reconnects the +stream at once, never waiting out a backoff timer, and a logout leaves for +the front page. Waking the page (`visibilitychange`, `online`, a bfcache +restore) recycles the socket immediately — one that survived sleep still +looks open but is dead — so the fresh snapshot lands right away, and the +stats window slides on a minute clock so an open page never leaves +"last 24 hours" frozen at mount. + Charts are SVG curves (Catmull-Rom over an edge-aware Gaussian — a change-point detector splits the series at traffic-level shifts, then each segment is smoothed independently with a fixed sigma chosen so N events in @@ -416,8 +442,15 @@ by a top lane labeled 🏠︎ beside the home pill (50% thicker than the branch lanes, its label font and guide offset scaled along), each item's subtree fanning out below it in menu order along a large-radius circular arc that leaves heading -straight down and gradually bends right, index pages without views omitted -and their children promoted in their place. The submenu structure is drawn +straight down and gradually bends right. Every page of the tree is shown +whether or not it had views in the window — only index pages without +content of their own (category labels and empty pages — the ones the +navigation itself bypasses in favor of the first child) are omitted, +their children +promoted in their place, the first visible child taking the slot. Paths +the traffic names but the tree no longer has (deleted pages) are +inserted as ghosts where they would have been, missing ancestors +materialized as category lanes. The submenu structure is drawn as wide branch lanes: one per path prefix with at least two visible nodes, running behind the branch's node pills as circle arcs concentric with the fan (parent levels one radius step outward, so all lanes of a @@ -436,7 +469,10 @@ flow along each edge, persisting across data reloads (emitters are keyed per edge direction and beads tracked by progress, so an unrelated count change never reshuffles them), emitted at a rate linearly proportional to the directional count with no in-flight limit, opposing directions -offset onto parallel lanes. External sources and exits whose connectors are +offset onto parallel lanes; each bead renders as an elongated pulse +(gradient-faded tail) along its direction of travel, colored like its +connector — accent for internal edges, plain text color for external +ones. External sources and exits whose connectors are all culled by the width threshold are dropped from their rows themselves (the site's own page nodes always stay, connected or not). External sources show as a node row above the map: each visit is attributed to `utm_campaign`, then `utm_source`, then the diff --git a/docs/backend.md b/docs/backend.md index 4804fdf..488374c 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -10,8 +10,8 @@ Thin FastAPI assembly: lifespan (open the kanta database, load the file store, t - `files.py` — the `FileStore` and image derivative helpers, and the file routes: `/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, the favicon settings endpoints. - `api.py` — the editor REST API and WebSockets: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, and the translator channel `/_translate/{clientkey}`. - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetching, debounced broadcasts, the `/_ws` activity socket, the admin stream `/_api/ws/analytics`, and the `/_a` viewer page. -- `feeds.py` — machine-readable site exports: `/llms.txt` (Markdown site map for LLM agents), `/feed.json` (JSON Feed 1.1) and `/feed.xml` (RSS 2.0 + atom:link), all carrying every published article with full content (relative URLs absolutized), linked from every page's `` and from the sitemap, and recorded in analytics like page GETs. Bodies are RAM-cached (keyed by base URL, cleared by `_invalidate_pages` like the page render cache) and served with a content-hash ETag (304 revalidation). -- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all. +- `feeds.py` — machine-readable site exports: `/llms.txt` (Markdown site map for LLM agents, linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1) and `/feed.xml` (RSS 2.0 + atom:link), the feeds carrying every published article with full content (relative URLs absolutized), linked from every page's `` and from the sitemap, and recorded in analytics like page GETs. Bodies are RAM-cached (keyed by base URL, cleared by `_invalidate_pages` like the page render cache) and served with a content-hash ETag (304 revalidation). +- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all. Every article also has a Markdown counterpart: a `.md` suffix on the page URL (or an Accept header explicitly naming `text/markdown` on the plain URL — the HTML response carries `vary: accept`) serves the page's source Markdown (per-language hybrid assembly via `?lang=`/Accept-Language, page title injected as `# title` when the content has no h1, same ETag/Last-Modified scheme as the HTML). Pages advertise it with ``. Route ordering is load-bearing and lives in `app.py`: the api/tracking/files routers are included BEFORE `frontend.route(app, "/")` is called — fastapi-vue inserts its file routes at the position where `route()` was called (during `load()` in the lifespan), so anything registered earlier wins. The content catch-all `/{path:path}` is included AFTER `frontend.route()` so that built frontend assets still take priority over content slugs. The `Frontend` is constructed with `spa=False` explicitly: it only serves the built files without a catch-all. diff --git a/docs/editing.md b/docs/editing.md index bc9a2f3..e8361ad 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -18,7 +18,7 @@ In-place page re-rendering shared by the banner/site/structure tabs lives in `sw The page and structure tabs share one language selector: `LangSelect.vue` (small flag + dropdown) v-modeled on the shell-wide selection in `editorLang.js` (`''` = primary). While the panel is open that selection overrides the page's normal language preferences: EditorShell calls `swapdoc.setLangOverride`, which pins every `loadPlain` fetch (`?lang=`, the primary by its own code) and pagerite.js's own fetches/prefetches (`pagerite:session-lang`), until the panel closes and the override clears. -All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it). +All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. The admin sockets (`/_api/...`) also close through `authReconnect`: an expired session fails the WS handshake like a dead network but can never open the login dialog from a socket, so the close handler probes `/_api/settings` first — a 401/403 runs the paskia login flow and reconnects the moment it completes (a declined login holds at a "session expired" note; a login anywhere in the page reconnects the panel, via paskia's `auth-success` window message), and only genuine network/server failures take the paced backoff. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it). ## Saving behavior diff --git a/docs/frontend-runtime.md b/docs/frontend-runtime.md index 5c86b46..d0f56aa 100644 --- a/docs/frontend-runtime.md +++ b/docs/frontend-runtime.md @@ -14,7 +14,7 @@ It first probes `GET /auth/api/settings` to detect whether Paskia SSO is availab The editor/analytics components (everything except pagerite.js and main.js) make their `/_api` calls through paskia-js's `apiFetch`/`apiJson` instead of plain `fetch`: when a call gets a 401/403 carrying an `auth.iframe` hint (expired session), the login dialog opens in place and the request retries transparently after authentication. pagerite.js uses `apiJson` only for the task-checkbox toggle (ticking a box is an explicit edit attempt, so a login dialog is welcome there; any failure — including a cancelled login — reverts the checkbox) and `fetchJson` for its auth probes (plain fetch with JSON handling and errors on non-OK, never a dialog); page-cache and navigation fetches stay on plain `fetch`, so anonymous visitors never get a login popup uninvited. -Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as ` diff --git a/frontend/src/ContactOverlay.vue b/frontend/src/ContactOverlay.vue new file mode 100644 index 0000000..223c77f --- /dev/null +++ b/frontend/src/ContactOverlay.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue new file mode 100644 index 0000000..76380e0 --- /dev/null +++ b/frontend/src/ContactPanel.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue new file mode 100644 index 0000000..7ea90fa --- /dev/null +++ b/frontend/src/MessagesView.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 1a78ec6..83746f6 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -38,7 +38,7 @@ import { flagFor, langName, langSort } from './langs' import { editorLang, pagePrimary } from './editorLang' import LangSelect from './LangSelect.vue' import ConnNote from './ConnNote.vue' -import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' +import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { dropPageCache, loadPlain } from './swapdoc' const props = defineProps({ @@ -75,13 +75,14 @@ let sessionDoc = null // input until the page's document has arrived: typing before the accept // would be clobbered by it. While merely DISconnected the editor stays // editable — text stashes and pending saves flush on reconnect. -const conn = ref('connecting') // connecting | open | waiting +const conn = ref('connecting') // connecting | open | waiting | login const retryIn = ref(0) const docReady = ref(false) const editable = new Compartment() const connNote = computed(() => conn.value === 'connecting' ? 'connecting to the server…' : conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…` + : conn.value === 'login' ? 'session expired — log in to continue editing…' : docReady.value ? '' : 'loading the page…', ) @@ -96,6 +97,9 @@ let connectWatchdog = null // retries trip the browser's WebSocket throttling (sockets stuck "pending" // for minutes), which is what kept hollowing out the editor. const reconnects = reconnectPolicy() +// Bumped by every connect()/unmount: the auth probe in onclose is async, so +// a stale tail checks it did not get superseded before acting. +let connectEpoch = 0 const dirty = ref(false) // unsaved text exists (drives the 💾 button) let syncingScroll = false @@ -990,6 +994,7 @@ function consumePendingLine() { } function connect() { + connectEpoch++ clearTimeout(reconnectTimer) conn.value = 'connecting' if (ws) { @@ -1021,19 +1026,40 @@ function connect() { openPath(normPath(props.pagePath)) } } - ws.onclose = (ev) => { + ws.onclose = async (ev) => { // 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or // the connecting watchdog fired); worth seeing since a dead socket // before the first doc bricks the editor until a retry lands one. - // The wait is the policy's: doubling backoff with jitter (./reconnect). console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '') + const gen = connectEpoch const wait = reconnects.closed() - retryIn.value = Math.max(1, Math.round(wait / 1000)) - conn.value = 'waiting' - reconnectTimer = setTimeout(connect, wait) + // An expired session fails the handshake like a dead network, but blind + // retries loop forever — the WS API cannot open the login dialog. Probe + // /_api (./reconnect's authReconnect): a lost session runs the paskia + // login flow and reconnects the moment it completes; a declined login + // holds at the 'login' note (a later login anywhere in the page, or a + // save attempt, reconnects); only transient failures take the backoff. + const outcome = await authReconnect(() => { conn.value = 'login' }) + if (gen !== connectEpoch) return + if (outcome === 'auth') { + connect() + } else if (outcome === 'abort') { + conn.value = 'login' + } else { + retryIn.value = Math.max(1, Math.round(wait / 1000)) + conn.value = 'waiting' + reconnectTimer = setTimeout(connect, wait) + } } } +// A login completed anywhere in the page (the banner's 🔑 button, another +// panel's flow) reconnects a waiting editor at once instead of waiting out +// the backoff. paskia's auth iframe announces it as a window message. +function onAuthMessage(ev) { + if (ev.data?.type === 'auth-success' && conn.value !== 'open') connect() +} + onMounted(() => { // The first connection takes a staggered slot (see ./reconnect): page // load opens several sockets at once, and the burst trips throttling. @@ -1090,9 +1116,11 @@ onMounted(() => { addEventListener('pagerite:editor-shown', onEditorShown) // A section pen clicked while the page editor is already open. addEventListener('pagerite:edit-section', consumePendingLine) + addEventListener('message', onAuthMessage) }) onUnmounted(() => { + connectEpoch++ // invalidate a pending onclose auth probe clearTimeout(reconnectTimer) clearTimeout(connectWatchdog) if (ws) { @@ -1106,6 +1134,7 @@ onUnmounted(() => { removeEventListener('keydown', onKeydown) removeEventListener('pagerite:editor-shown', onEditorShown) removeEventListener('pagerite:edit-section', consumePendingLine) + removeEventListener('message', onAuthMessage) }) diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 4d871e8..221aa60 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -12,6 +12,9 @@ import { html } from '@codemirror/lang-html' import { cmHighlight, cmTheme } from './cmtheme' import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { apiFetch, apiJson } from 'paskia' +import { editorLang } from './editorLang' +import LangSelect from './LangSelect.vue' +import { flagFor, langName, langSort } from './langs' const props = defineProps({ pagePath: { type: String, default: '' }, @@ -57,6 +60,103 @@ async function errorDetail(res) { // empty brand removes the header link and the title suffix entirely. const brand = ref('') const theme = ref('') + +// --- Messaging (contact) --------------------------------------------------- +// Contact messaging settings: a two-row grid (labels above, the flag +// selector and the boxes below). The label (the {contact:} buttons' +// label and the visitor dialog's title) doubles as the on/off switch: +// empty = disabled. The label and email subject are per-language +// strings — the LangSelect (the article editor's flag dropdown, on the +// shared shell-wide editorLang) switches the two fields between the +// original and a translation override (stored in the chunk-keyed trans +// store; auto translators fill the same store, so this is the +// manual-override UI). The email and mail server are global: the +// email's reveal after the PoW solve is server-side, and the mail +// server (a mailbridge URL, docs/messages.md; empty = no email) is +// reserved for email notifications. +const contactEmail = ref('') +const contactLabel = ref('') +const contactSubject = ref('') +const contactMailserver = ref('') +const contactLabelOrig = ref('') +const contactSubjectOrig = ref('') +// Password managers offer credential autofill on these boxes no matter +// the autocomplete setting; readonly until the first interaction keeps +// them off (the vendors' ignore attributes on the inputs back it up). +const pmGuard = ref(true) +const pmUnlock = () => { pmGuard.value = false } +const lang = editorLang // '' = the original strings (the primary language) +const primaryLang = ref('en') +const translateLangs = ref([]) +const translating = computed(() => lang.value && lang.value !== primaryLang.value) + +// The flag selector's options, built like the article editor's: the +// primary language first (tag ''), then the configured targets in the +// geographic grouping. +const contactLangOptions = computed(() => { + const others = langSort(translateLangs.value.filter((l) => l && l !== primaryLang.value)) + return [primaryLang.value, ...others].map((code) => ({ + tag: code === primaryLang.value ? '' : code, + code, + name: langName(code), + flag: flagFor(code), + primary: code === primaryLang.value, + })) +}) + +async function onContactLangChange() { + if (!translating.value) { + contactLabel.value = contactLabelOrig.value + contactSubject.value = contactSubjectOrig.value + return + } + try { + const s = await apiJson(`/_api/settings?lang=${lang.value}`) + contactLabel.value = s.contact_label_tr || '' + contactSubject.value = s.contact_subject_tr || '' + } catch { /* keep empty */ } +} +watch(lang, onContactLangChange) + +function onContactInput() { + // Capture the language being edited: a debounced save must not land in + // whatever language happens to be selected when the timer fires. + const tag = lang.value + if (!tag || tag === primaryLang.value) { + contactLabelOrig.value = contactLabel.value + contactSubjectOrig.value = contactSubject.value + } + debounce('contact', () => saveContact(tag, contactLabel.value, contactSubject.value)) +} + +async function saveContact(tag = lang.value, label = '', subject = '') { + if (tag && tag !== primaryLang.value) { + // Translation override: only the label/subject apply (empty = drop). + const res = await apiFetch(`/_api/settings?lang=${tag}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + brand: brand.value, + theme: theme.value, + custom_css: customCss.value, + contact_label: label, + contact_subject: subject, + }), + }) + saveError.value = res.ok ? '' : '⚠️ changes could not be saved' + if (res.ok) rerender() + return + } + if (await saveSettings()) rerender() +} + +// The rendered {contact:} buttons are server HTML: after a save the page +// re-renders (like a theme change), updating buttons and dialog strings +// without a reload. +async function rerender() { + dropPageCache() + loadPlain(path.value) +} // Theme options come from the backend (theme folders on disk, see GET // /_api/settings), so added themes need no frontend changes. const themeOptions = ref([{ value: '', label: '☀️ none' }]) @@ -88,6 +188,16 @@ async function loadSettings() { ] transition.value = s.transition || 'cube' transitionOptions.value = s.transitions || [] + contactEmail.value = s.contact_email || '' + contactMailserver.value = s.contact_mailserver || '' + contactLabelOrig.value = s.contact_label || '' + contactSubjectOrig.value = s.contact_subject || '' + if (!translating.value) { + contactLabel.value = contactLabelOrig.value + contactSubject.value = contactSubjectOrig.value + } + primaryLang.value = s.primary_lang || 'en' + translateLangs.value = s.translate_langs || [] fontOptions.value = [ ...BASE_FONT_OPTIONS, ...(s.fonts || []).map((f) => ({ @@ -255,6 +365,12 @@ async function saveSettings(opts = {}) { custom_css: customCss.value, brand_html: brandHtml.value, transition: transition.value, + contact_email: contactEmail.value, + contact_mailserver: contactMailserver.value, + // The originals: while a translation override is being edited the + // input refs hold the override, not the original. + contact_label: contactLabelOrig.value, + contact_subject: contactSubjectOrig.value, ...opts, }), }) @@ -264,6 +380,7 @@ async function saveSettings(opts = {}) { } else { saveError.value = '⚠️ changes could not be saved' } + return res.ok } async function onThemeChange() { @@ -528,6 +645,9 @@ onMounted(async () => { parent: brandEl.value, }) await loadSettings() + // The shared editorLang may already point at a translation (picked in + // another tab): load its label/subject overrides. + await onContactLangChange() parseFonts(customCss.value) setCssDocument(customCss.value) applyCustomCss(customCss.value) @@ -655,6 +775,88 @@ onUnmounted(() => { +
+
+
+ + +
+ + + + +
+
+
brand code (replaces the brand link) @@ -785,6 +987,48 @@ onUnmounted(() => { font-size: 1rem; } +/* The messaging block: a two-row grid — labels on the first row, the + article editor's flag selector and the boxes on the second, so the + flag centers with the boxes and every label sits directly above its + own field (contact button label = the enable switch, email subject, + our email; the hidden mail-server field joins the email group once the + backend lands). The flag column sizes + to the flag; when only one language is configured the selector renders + nothing and the column collapses. + The boxes are readonly until first interaction (+ vendor ignore + attributes): password managers offer credential autofill on them no + matter the autocomplete setting. */ +.msg-grid { + display: grid; + /* The hidden mail-server field (template comment) re-adds a column. */ + grid-template-columns: auto repeat(3, minmax(0, 1fr)); + align-items: center; + gap: 0.15rem 0.6rem; +} + +/* Field wrappers vanish into the grid: labels land on row 1 (the + flag's empty spacer keeps the fields in the 1fr columns whether or + not the flag renders), the flag and boxes on row 2. */ +.msg-grid .field, +.msg-grid .msg-flag { + display: contents; +} + +.msg-grid .field-label { + grid-row: 1; + min-width: 0; +} + +.msg-grid .lang-select, +.msg-grid .text-input { + grid-row: 2; +} + +.msg-grid .text-input { + flex: none; + font-size: 0.85rem; +} + .text-input { flex: 1; min-width: 4rem; diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue index 4d47e82..882f51f 100644 --- a/frontend/src/StructureTree.vue +++ b/frontend/src/StructureTree.vue @@ -1,7 +1,8 @@ @@ -39,6 +42,7 @@ const title = computed(() => { {{ formatCount(count) }}× {{ step.slug }} + ✉️ @@ -51,6 +55,12 @@ const title = computed(() => { vertical-align: -0.1em; } +/* The contact-panel marker (PoW solved on this step). */ +.contact-mark { + margin-left: 0.25em; + font-size: 0.85em; +} + /* Same flag chip as the visitor cells (VisitorCell.vue). */ .flag { display: inline-flex; diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index fea6913..f13c4af 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -13,6 +13,7 @@ import { TNODE_W, TNODE_H, BEAD_R, + BEAD_LEN, buildTransitionGraph, } from './analytics/transitions.js' @@ -121,9 +122,16 @@ const syncBeads = (flows) => { } const tick = (t) => { - const dt = lastTick ? (t - lastTick) / 1000 : 0 + // Long gaps (sleep, hidden tab) must not spawn a bead per missed interval + // just to drop them all in the same tick: clamp the step (every in-flight + // bead simply completes) and fast-forward each emitter's schedule to its + // next future slot, keeping the phase. + const dt = lastTick ? Math.min((t - lastTick) / 1000, TRAVERSAL_S) : 0 lastTick = t for (const e of emitters.values()) { + if (e.next < t - e.interval) { + e.next = t + (((e.next - t) % e.interval) + e.interval) % e.interval + } while (e.next <= t) { live.push({ e, p: 0 }) e.next += e.interval @@ -138,7 +146,17 @@ const tick = (t) => { continue } const f = b.e.flow - out.push({ x: f.x1 + (f.x2 - f.x1) * b.p, y: f.y1 + (f.y2 - f.y1) * b.p }) + // The pulse is elongated along the direction of travel (constant per + // flow) and colored like its connector (external edges use --text). + if (f.deg === undefined) { + f.deg = (Math.atan2(f.y2 - f.y1, f.x2 - f.x1) * 180) / Math.PI + } + out.push({ + x: f.x1 + (f.x2 - f.x1) * b.p, + y: f.y1 + (f.y2 - f.y1) * b.p, + deg: f.deg, + ext: f.external, + }) } beads.value = out rafId = requestAnimationFrame(tick) @@ -177,6 +195,21 @@ const countLabel = (n) =>
+ + + + + + + + + + + + + ), and treating those as blocks +// would split the paragraph and drop the formatting. +const BLOCK_TAGS = new Set([ + 'DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE', + 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'TABLE', +]) + +// One message's Markdown from the composer root: inline runs become +// paragraphs, block elements become their own blocks, blank lines between. +export function serializeRich(root) { + const blocks = [] + let run = [] + const flush = () => { + const text = run.join('').trim() + run = [] + if (text) blocks.push(text) + } + for (const node of root.childNodes) { + if (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName)) { + flush() + block(node, blocks) + } else { + run.push(inline(node)) + } + } + flush() + return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() +} + +function block(node, out) { + switch (node.tagName) { + case 'UL': + out.push([...node.children].map((li) => `- ${inlineChildren(li).trim()}`).join('\n')) + break + case 'OL': + out.push([...node.children].map((li, i) => `${i + 1}. ${inlineChildren(li).trim()}`).join('\n')) + break + case 'BLOCKQUOTE': + out.push(inlineChildren(node).split('\n').map((l) => `> ${l}`).join('\n')) + break + case 'PRE': + out.push('```\n' + node.textContent + '\n```') + break + case 'HR': + break + default: { + // DIV, P, headings: a paragraph of inline content. + const text = inlineChildren(node).trim() + if (text) out.push(text) + } + } +} + +function inlineChildren(node) { + return [...node.childNodes].map(inline).join('') +} + +function inline(node) { + if (node.nodeType === Node.TEXT_NODE) return escapeMd(node.textContent) + if (node.nodeType !== Node.ELEMENT_NODE) return '' + switch (node.tagName) { + case 'B': + case 'STRONG': + return `**${inlineChildren(node)}**` + case 'I': + case 'EM': + return `*${inlineChildren(node)}*` + case 'CODE': + return '`' + node.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`' + case 'BR': + return '\n' + case 'A': { + const text = inlineChildren(node) + const href = node.getAttribute('href') || '' + return /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text + } + case 'IMG': + // Uploaded image (data-path set by the composer on insert). + return node.dataset.path ? `![${escapeMd(node.alt || 'image')}](${node.dataset.path})` : '' + case 'SPAN': + // Attachment chip (data-path + data-name); other spans unwrap. + if (node.dataset.path && node.dataset.name) { + return `[📎 ${escapeMd(node.dataset.name)}](${node.dataset.path})` + } + return wrapStyle(node, inlineChildren(node)) + default: + return wrapStyle(node, inlineChildren(node)) + } +} + +// Some browsers style formatting instead of tagging it (Firefox's +// execCommand produces ): keep span-level +// bold/italic alive into the Markdown. +function wrapStyle(node, text) { + if (!text.trim()) return text + const s = node.style + if (s) { + if (/bold|bolder|[6-9]00/.test(s.fontWeight)) text = `**${text}**` + if (s.fontStyle === 'italic' || s.fontStyle === 'oblique') text = `*${text}*` + } + return text +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2ecf691..32deb64 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,7 +16,7 @@ const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$' // https://vite.dev/config/ export default defineConfig({ plugins: [ - fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate"] }), + fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate", "/_contact"] }), vue(), vueDevTools(), ], @@ -51,6 +51,7 @@ export default defineConfig({ pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)), analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)), langselect: fileURLToPath(new URL('./src/langselect-main.js', import.meta.url)), + contact: fileURLToPath(new URL('./src/contact-main.js', import.meta.url)), // Only the base CSS is built; theme/banner-design stylesheets live // in pagerite/themes/{name}/ and are served by the backend as-is. pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)), diff --git a/pagerite/analytics.py b/pagerite/analytics.py index 45ad4f5..f841aed 100644 --- a/pagerite/analytics.py +++ b/pagerite/analytics.py @@ -148,6 +148,10 @@ class Msg(msgspec.Struct, omit_defaults=True): #: Rendered language reported by the client for the page the activity #: happened on. lang: str = "" + #: Server-originated contact marker (docs/messages.md): the visitor + #: opened the contact panel and solved its proof of work on ``fr``. + #: Never sent by clients — recorded by the /_ws handler. + contact: bool = False class Client(msgspec.Struct, omit_defaults=True): @@ -217,6 +221,9 @@ class TrailItem(msgspec.Struct, omit_defaults=True): #: page falling back to its GET's rendered language; "" when unknown #: (old clients or data from before language recording). lang: str = "" + #: The visitor opened the contact panel and solved its proof of work + #: on this page (docs/messages.md) — the trail's ✉️ marker. + contact: bool = False class Visit(msgspec.Struct, omit_defaults=True): @@ -711,6 +718,34 @@ class Store: self._save() return client_hash if new else None + def record_contact( + self, fr: str, ip: str, ua: str, accept_language: str = "" + ) -> bytes | None: + """Append one server-originated contact marker to the raw log: the + visitor opened the contact panel on ``fr`` and solved its proof of + work (docs/messages.md). Recorded by the /_ws handler on a + validated solution — an unsolved panel open leaves no trace. + Display lands the marker on the visit's trail step for ``fr``. + + Returns the client hash when the client record was just created (so + the caller can schedule async enrichment), else None. + """ + client_lang, country = _parse_accept_language(accept_language) + client_hash = _client_hash(ip, ua, client_lang) + new = client_hash not in self.data.clients + if new: + self._ensure_client(ip, ua, client_lang, country=country) + self.data.msgs.append( + Msg( + t=datetime.now(UTC), + client=client_hash, + fr=(_internal_path(fr) or "") if fr else "", + contact=True, + ) + ) + self._save() + return client_hash if new else None + def favicon_origins_needed(self) -> list[str]: """External https origins seen in the raw events whose favicon needs fetching. @@ -775,6 +810,9 @@ class Store: rendered language), and a page re-visited in a different rendered language becomes a distinct trail step. The entry referer and UTM tags come from the GET that loaded the entry page. + Server-originated contact markers (the contact panel's proof of + work solved, docs/messages.md) flag the trail step they happened + on — the first one per visit. Hidden (admin) clients are excluded from every list and aggregate. ``multilingual`` and ``primary_lang`` are site context (translation @@ -871,6 +909,19 @@ class Store: visit: Visit | None = None last_t: datetime | None = None for m in msgs: + if m.contact: + # Contact PoW proven (docs/messages.md): mark the trail + # step it happened on — the first one per visit only. + if visit is not None and not any( + item.contact for item in visit.trail.values() + ): + want = m.fr or visit.entry + for item in visit.trail.values(): + if item.to == want: + item.contact = True + break + last_t = m.t + continue if m.to: if visit is None or ( last_t is not None and m.t - last_t > _SESSION_GAP diff --git a/pagerite/api.py b/pagerite/api.py index be66e66..bddef77 100644 --- a/pagerite/api.py +++ b/pagerite/api.py @@ -23,8 +23,8 @@ from fastapi import ( from html5tagger import E from pydantic import BaseModel -from pagerite import i18n, views -from pagerite.chunks import store_chunks +from pagerite import i18n, messages, views +from pagerite.chunks import chunk_key, store_chunks from pagerite.data import ( Node, append_order, @@ -65,6 +65,10 @@ async def list_pages(lang: str | None = None) -> list[dict]: Nested by slug; each node carries its full path, menu order, flags and language settings (``language`` is the node's own primary-language setting, "" = inherit; ``primary`` is the resolved effective one). + ``has_content`` tells whether a landing page exists at all (even an + empty one — the structure editor offers deleting it); ``empty`` is the + navigation's notion: no content of its own (None or empty markdown), + so nav links bypass the node to its first child page. With a ``?lang=`` translation, titles come out in that language where a translation exists (``translated`` flags it — true trivially for rows whose primary language IS the selected one; other rows fall back to @@ -88,6 +92,10 @@ async def list_pages(lang: str | None = None) -> list[dict]: "order": node.order, "published": node.published, "has_content": node.chunks is not None, + # No content of its own (None or empty markdown): the + # navigation bypasses the node, linking its first child + # page instead (views nav_html/first_leaf). + "empty": not node.chunks, "language": node.language, "primary": primary, "children": dump(node.children, path, primary), @@ -268,12 +276,18 @@ async def update_structure(op: StructureOp, request: Request) -> None: @router.get("/_api/settings") -async def get_settings() -> dict: +async def get_settings(lang: str | None = None) -> dict: """Site-wide settings (brand, theme, custom CSS and favicon URL), plus the themes, banner designs and user fonts available on disk for the selectors, the translator service keys and the wanted translation - languages (for the /_translate socket).""" - return { + languages (for the /_translate socket), and the contact-messaging + configuration. + + With a ``?lang=`` translation language the contact label/subject also + come out as ``*_tr`` (their per-language override, "" = none — the + site settings' messaging section edits those, falling back to the + original it edits without the query).""" + out = { "brand": data.brand, "brand_html": data.brand_html, "theme": data.theme, @@ -285,11 +299,27 @@ async def get_settings() -> dict: "transition": data.transition, "transitions": views._transition_names(), "translate_keys": data.translate_keys, + # Unanswered contact threads: the corner ✉️ icon's badge for + # admins (this probe runs on every page load; no polling). + "contact_unread": messages.unread_count(), + # Messaging is enabled by configuring a label (empty = off). + "contact_label": data.contact_label, + "contact_email": data.contact_email, + "contact_subject": data.contact_subject, + "contact_mailserver": data.contact_mailserver, # The site default primary language: the front page's resolved # setting (every page may override it, inherited down the tree). "primary_lang": i18n.primary_lang(data.menu, ""), "translate_langs": sorted(data.translate_langs), } + tag = i18n.base_tag(lang or "") + if tag and tag != out["primary_lang"]: + for field in ("contact_label", "contact_subject"): + original = getattr(data, field) + out[f"{field}_tr"] = ( + data.trans.get(chunk_key(original), {}).get(tag, "") if original else "" + ) + return out class SettingsIn(BaseModel): @@ -302,17 +332,54 @@ class SettingsIn(BaseModel): transition: str = "cube" translate_langs: list[str] | None = None # None keeps the current set translate_keys: dict[str, str] | None = None # None keeps the current keys + contact_label: str | None = None + contact_email: str | None = None + contact_subject: str | None = None + contact_mailserver: str | None = None @router.put("/_api/settings", status_code=204) -async def put_settings(settings: SettingsIn, request: Request) -> None: - """Update site-wide settings; invalidates cached pages and ETags.""" +async def put_settings( + settings: SettingsIn, request: Request, lang: str | None = None +) -> None: + """Update site-wide settings; invalidates cached pages and ETags. + + With a ``?lang=`` translation language only the contact label/subject + apply — as per-language overrides in the chunk-keyed trans store, the + same storage machine translations use (i18n.set_string_translation); + empty or the original's text drops the override.""" + tag = i18n.base_tag(lang or "") + if tag and tag != i18n.primary_lang(data.menu, ""): + with kanta.transaction( + f"settings:{tag}", user=request.headers.get("remote-user") + ): + changed = False + for field in ("contact_label", "contact_subject"): + value = getattr(settings, field) + if value is None: + continue + original = getattr(data, field) + if original: + changed |= i18n.set_string_translation( + data, tag, original, value.strip() + ) + if changed: + _invalidate_pages() + return with kanta.transaction("settings", user=request.headers.get("remote-user")): data.brand = settings.brand data.brand_html = settings.brand_html data.theme = settings.theme data.custom_css = settings.custom_css data.transition = settings.transition + if settings.contact_label is not None: + data.contact_label = settings.contact_label.strip() + if settings.contact_email is not None: + data.contact_email = settings.contact_email.strip() + if settings.contact_subject is not None: + data.contact_subject = settings.contact_subject.strip() + if settings.contact_mailserver is not None: + data.contact_mailserver = settings.contact_mailserver.strip() if settings.translate_langs is not None: # Any language may be a target — including the site default # (an article in another language can be translated INTO it); @@ -536,11 +603,26 @@ async def editor_ws(ws: WebSocket) -> None: markdown = msg.get("markdown", "") chain = resolve(data.menu, path) node = chain[-1] if chain else None - # Expand {cards} like page_content does, so the preview - # shows real cards, not the literal tag. No translation - # context: the preview has no lang of its own, so cards - # render in their originals. has_cards_tag = views._CARDS_TAG_RE.search(markdown) is not None + # {contact} buttons preview like the served page: inert, + # and nothing at all when messaging is disabled (no + # configured label). {cards} expands like page_content + # does, so the preview shows real cards, not the literal + # tag. No translation context: the preview has no lang of + # its own, so cards render in their originals. + directives: dict = { + "contact": ( + (lambda args, _env: views._contact_tag(args, _env, data)) + if data.contact_label.strip() + else (lambda _args, _env: "") + ) + } + if node is not None and has_cards_tag: + directives["cards"] = ( + lambda args, _env, node=node, path=path: views._cards_tag( + data.menu, data, node, path, args + ) + ) rendered = render( markdown, path, @@ -557,15 +639,7 @@ async def editor_ws(ws: WebSocket) -> None: if node else None ), - directives=( - { - "cards": lambda args, _env, node=node, path=path: ( - views._cards_tag(data.menu, data, node, path, args) - ) - } - if node is not None and has_cards_tag - else None - ), + directives=directives, ) html = rendered.html if node is not None and not has_cards_tag: diff --git a/pagerite/app.py b/pagerite/app.py index 5858268..8a2cf29 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -11,6 +11,9 @@ The routes live in specialized modules, included below as APIRouters: (``/_api/*``, ``/_translate/{clientkey}``). - ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``, the ``/_a`` viewer page). +- ``pagerite.messages`` — visitor contact threads (docs/messages.md): the + ``/_contact/files`` upload route; the messaging protocol itself rides + the tracking sockets. - ``pagerite.feeds`` — machine-readable site exports: ``/llms.txt``, ``/feed.json`` (JSON Feed) and ``/feed.xml`` (RSS). - ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``, @@ -41,7 +44,7 @@ from fastapi.responses import Response from fastapi_vue import Frontend, env from starlette.types import ASGIApp, Receive, Scope, Send -from pagerite import api, feeds, files, pages, tracking +from pagerite import api, feeds, files, messages, pages, tracking from pagerite.files import file_store from pagerite.state import analytics_store, config, kanta @@ -119,9 +122,10 @@ async def _headers(request: Request, call_next) -> Response: # Our own routes first: the editor API and translator socket, the analytics -# machinery, and the file store/user assets. +# machinery, the contact-message upload route, and the file store/user assets. app.include_router(api.router) app.include_router(tracking.router) +app.include_router(messages.router) app.include_router(files.router) # Vue build asset routes are inserted at this position during load(): the diff --git a/pagerite/data.py b/pagerite/data.py index cd6c017..c2ac2e1 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -50,6 +50,50 @@ class LangEdits(msgspec.Struct, omit_defaults=True): adds: dict[str, str] = {} +class ContactMessage(msgspec.Struct, omit_defaults=True): + """One message in a visitor contact thread (docs/messages.md). + + ``md`` is the Markdown-subset source serialized by the composer; + ``html`` its rendering by the strict parser in pagerite.messages + (html=False — no user markup ever reaches a page, only markdown-it + output from the constrained grammar). Both are stored: the source for + future edits/moderation, the rendering so panels inject it verbatim. + """ + + t: datetime + #: Markdown-subset source. + md: str = "" + #: Strict server-side rendering of ``md``. + html: str = "" + #: Author: "" = the visitor, otherwise the admin's SSO remote-user. + admin: str = "" + #: Page path the message was sent from ("" for admin replies). + page: str = "" + + +class ContactThread(msgspec.Struct, omit_defaults=True): + """One visitor's message thread, keyed in Data.contact by token hash. + + Messages are keyed (never a list) by a sortable timestamp-random id so + a save's database diff touches only the appended message. + """ + + created: datetime + messages: dict[str, ContactMessage] = {} + #: When an admin last looked at the thread; visitor messages newer + #: than this are unread in the admin inbox. + admin_read: datetime | None = None + #: The visitor's self-entered identity (name required, email optional + #: and unvalidated), captured with their first message and fixed + #: afterwards ("" until then). + name: str = "" + email: str = "" + #: The visitor closed the conversation (their next message starts a + #: fresh thread under a new token); archived in the admin inbox, + #: excluded from the unread count. + closed: bool = False + + class Node(msgspec.Struct, omit_defaults=True): """One item of the site hierarchy. @@ -167,6 +211,27 @@ class Data(msgspec.Struct): #: path -> lang -> LangEdits (paths without leading slash). Replaces #: the old "patches" key (ignored on decode, discarding that data). overrides: dict[str, dict[str, LangEdits]] = {} + #: Visitor contact threads (docs/messages.md), keyed by the blake3 + #: hash of the visitor's session token (never the token itself). + contact: dict[bytes, ContactThread] = {} + #: The {contact:} button label and visitor dialog title — and the + #: feature's on/off switch: EMPTY disables messaging (no buttons, no + #: visitor corner icon, sends rejected; admins keep the inbox). A + #: per-button {contact: Label} arg overrides it. Translated per + #: language in Data.trans, keyed by chunk hash like titles + #: (i18n.site_string). + contact_label: str = "" + #: Contact email shown as a mailto link in the visitor dialog (with + #: the subject prefilled), revealed over the socket only after the + #: proof of work is solved. Empty = no link. + contact_email: str = "" + #: Subject for the mailto link; empty = the (localized) label. + #: Translated like contact_label. + contact_subject: str = "" + #: Mail server for email notifications (docs/messages.md): a mailbridge + #: URL — a bare host[:port] or smtp://user:pass@host:port for plain + #: SMTP, or a backend URL like sendgrid:API-KEY. Empty = no email. + contact_mailserver: str = "" def node_markdown(data: Data, node: Node) -> str | None: diff --git a/pagerite/feeds.py b/pagerite/feeds.py index 24a5692..7032457 100644 --- a/pagerite/feeds.py +++ b/pagerite/feeds.py @@ -2,7 +2,8 @@ - ``/llms.txt`` (llmstxt.org convention): a Markdown map of the site for LLM agents — the brand as title, then every published article as a link - with a short excerpt. + to its ``.md`` Markdown counterpart (pages.show_page) with a short + excerpt. - ``/feed.json``: JSON Feed 1.1 of all published articles, full content. - ``/feed.xml``: the same as RSS 2.0 (with an atom:link self reference) for older feed readers. @@ -78,7 +79,7 @@ def _iso(node: Node) -> str: def _render_llms(base: str) -> str: lines = [f"# {data.brand}", "", "## Pages", ""] for path, node in _articles(): - url = f"{base}/{path}" if path else base + url = f"{base}/{path}.md" if path else f"{base}/.md" excerpt = _description(_body_html(node, base), 120) suffix = f": {excerpt}" if excerpt else "" lines.append(f"- [{node.title or path}]({url}){suffix}") diff --git a/pagerite/files.py b/pagerite/files.py index 17ec5f6..54f274b 100644 --- a/pagerite/files.py +++ b/pagerite/files.py @@ -8,7 +8,11 @@ images and SVGs are recompressed into AVIF/WebP/JPEG derivatives ``.orig`` (never served). Routes: upload/delete under ``/_api/files``, the favicon settings endpoints, the /favicon.ico redirect to the configured icon, the ``/_f/`` server with -Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``). +Accept-negotiated formats (``/_f/{name}/{filename}`` serves ``{name}.file`` +attachment blobs), and the user assets (``/_themes/``, ``/_fonts/``). +All ``/_f/`` responses are forced downloads (``content-disposition: +attachment`` — ignored by /