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
This commit was merged in pull request #3.
This commit is contained in:
1 parent
0ff5c6b526
commit
d3c89e8c66
43 files changed
+4183
-265
No files matched your search
@@ -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 is a CMS. See `docs` for the full design and implementation details. Key files for code changes:
|
||||||
|
|
||||||
- `pagerite/` — Python backend package (hatchling build target).
|
- `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.
|
- `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}`.
|
- `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).
|
- `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.
|
||||||
- `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all.
|
- `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).
|
||||||
- `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 `<head>` and the sitemap, recorded in analytics.
|
- `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 `<link rel="alternate" type="text/markdown">`, 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 `<head>` and the sitemap, recorded in analytics.
|
||||||
- `data.py` — msgspec Structs for the kanta database.
|
- `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).
|
- `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).
|
- `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.
|
- `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).
|
- `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.
|
- `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.
|
- `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.
|
- `main.js` — Vue editor app entry.
|
||||||
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
|
- `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).
|
- `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.
|
- `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state.
|
||||||
- `pagerite.js` — public page entry.
|
- `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`).
|
- `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.
|
- `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/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.
|
- `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
|
## 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:
|
- Key libraries:
|
||||||
- **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML).
|
- **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
|
- To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header
|
||||||
|
|||||||
+43
-7
@@ -119,7 +119,11 @@ transition applies the new `<html lang>` only after the ping goes out) and
|
|||||||
follows the session, so the activity of a visit stays tied together, 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
|
while the user is active the accumulated reading time is flushed every few
|
||||||
seconds: the times are incremental, so a disconnection simply leaves the
|
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
|
any activity the client closes the socket itself — a sleeping browser tab
|
||||||
would lose it anyway — and the next activity reconnects; reconnects are
|
would lose it anyway — and the next activity reconnects; reconnects are
|
||||||
attempted only on user activity, with an exponential backoff between
|
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
|
re-visited in a different language becomes a distinct trail step instead
|
||||||
of merging into the existing item. The entry page's referer and
|
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
|
`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 hits**: a document GET no activity message matched within
|
||||||
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
||||||
documents never register as visits. JS-running crawlers (Googlebot,
|
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
|
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
|
content is rendered inside `#main`. The page itself is public, but the data
|
||||||
stream comes from `WebSocket /_api/ws/analytics`, which remains admin-gated
|
stream comes from `WebSocket /_api/ws/analytics`, which remains admin-gated
|
||||||
like the rest of the management API; visitors without access see the viewer
|
like the rest of the management API; without a session the viewer starts the
|
||||||
with a "could not be loaded" message.
|
paskia login flow instead of loading data.
|
||||||
|
|
||||||
Because it is a real page, fetch-navigation handles it like any other internal
|
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
|
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`
|
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
|
||||||
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
|
page-chrome hiding and `#/analytics/<range>` 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
|
Charts are SVG curves (Catmull-Rom over an edge-aware Gaussian — a
|
||||||
change-point detector splits the series at traffic-level shifts, then each
|
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
|
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
|
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
|
subtree fanning out below it in menu order along a large-radius circular
|
||||||
arc that leaves heading
|
arc that leaves heading
|
||||||
straight down and gradually bends right, index pages without views omitted
|
straight down and gradually bends right. Every page of the tree is shown
|
||||||
and their children promoted in their place. The submenu structure is drawn
|
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
|
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
|
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
|
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
|
per edge direction and beads tracked by progress, so an unrelated count
|
||||||
change never reshuffles them), emitted at a rate linearly proportional
|
change never reshuffles them), emitted at a rate linearly proportional
|
||||||
to the directional count with no in-flight limit, opposing directions
|
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
|
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
|
(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
|
map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
|
||||||
|
|||||||
+2
-2
@@ -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.
|
- `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}`.
|
- `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.
|
- `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 `<head>` 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).
|
- `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 `<head>` 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.
|
- `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 `<link rel="alternate" type="text/markdown">`.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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
|
## Saving behavior
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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, `<link>` 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 `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
|
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src`/`pagerite:contact-src` meta tags, `<link>` 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 `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened; the langselect and contact bundles load on demand the same way, on multi-language pages and `{contact:}` trigger clicks. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
|
||||||
|
|
||||||
## `assets/`
|
## `assets/`
|
||||||
|
|
||||||
|
|||||||
@@ -503,6 +503,13 @@ offerable to clients of another approach.
|
|||||||
placeholders lost is skipped individually and likewise stays pending
|
placeholders lost is skipped individually and likewise stays pending
|
||||||
for a scoped title job.
|
for a scoped title job.
|
||||||
|
|
||||||
|
Site-level settings strings (the contact-messaging label/subject,
|
||||||
|
docs/messages.md) localize through the same machinery: they are emitted
|
||||||
|
as title-kind fragments by `pending_items` and ride nav jobs as extra
|
||||||
|
top-level list items; `i18n.site_string` looks them up at render time,
|
||||||
|
and manual overrides are edited in the site editor's messaging block
|
||||||
|
(`i18n.set_string_translation`).
|
||||||
|
|
||||||
`scripts/llm_translator.py` is the reference markdown+article+nav client
|
`scripts/llm_translator.py` is the reference markdown+article+nav client
|
||||||
(instruct LLMs via an OpenAI Chat Completions endpoint or ollama's native
|
(instruct LLMs via an OpenAI Chat Completions endpoint or ollama's native
|
||||||
API); `scripts/translator.py` (Seed-X) is untouched and announces
|
API); `scripts/translator.py` (Seed-X) is untouched and announces
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Contact messages
|
||||||
|
|
||||||
|
Visitor→admin messaging built into the site: a `{contact: Label}` directive
|
||||||
|
in article Markdown renders a button that opens the messaging overlay, and
|
||||||
|
the corner ✉️ icon (top right, for everyone) opens it too — the visitor's
|
||||||
|
own thread for visitors, the admin inbox for editors. Messages are stored
|
||||||
|
in the kanta database. Visitors are anonymous — a localStorage session
|
||||||
|
token identifies their thread — and everything realtime rides the two
|
||||||
|
existing WebSockets; there is no REST polling anywhere.
|
||||||
|
|
||||||
|
- `pagerite/messages.py` — the feature's core: the strict message renderer,
|
||||||
|
thread store ops, wire payloads, both socket protocols, the admin unread
|
||||||
|
count and the `/_contact/files` upload route.
|
||||||
|
- `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`)
|
||||||
|
and the site settings (`contact_label` — which doubles as the on/off
|
||||||
|
switch — `contact_email`, `contact_subject`, `contact_mailserver`).
|
||||||
|
- `pagerite/tracking.py` — the sockets the protocol rides (see below).
|
||||||
|
- `pagerite/api.py` — `/_api/settings` carries `contact_unread` (the admin
|
||||||
|
icon badge seed — the probe runs on every page load, no polling) and the
|
||||||
|
contact configuration (with `?lang=` overrides for the label/subject).
|
||||||
|
- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`), the
|
||||||
|
localized contact strings (`_contact_config`) and the on-demand bundle's
|
||||||
|
asset config.
|
||||||
|
- `frontend/src/contact-main.js` + `ContactOverlay.vue` — the overlay entry
|
||||||
|
and shell (paskia's shared blur backdrop, centered dialog, full-screen on
|
||||||
|
mobile), imported on demand by `pagerite.js` on icon/trigger clicks.
|
||||||
|
- `frontend/src/ContactPanel.vue` — the visitor thread content.
|
||||||
|
- `frontend/src/MessagesView.vue` — the admin inbox content (thread list,
|
||||||
|
replies, read marking); connects its admin-gated socket only while open.
|
||||||
|
- `frontend/src/Composer.vue` + `richtext.js` — the shared composer and its
|
||||||
|
DOM→Markdown serializer.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
A lone `{contact: Label}` line in article Markdown (the block-directive
|
||||||
|
machinery of `markdown.py`) renders
|
||||||
|
`<button class="contact-trigger">Label</button>`. An empty label falls
|
||||||
|
back to the configured site label (`Data.contact_label`, localized —
|
||||||
|
below; "Contact us" is the last-resort fallback when neither is set).
|
||||||
|
`pagerite.js`
|
||||||
|
click-delegates these buttons and lazy-loads
|
||||||
|
the contact bundle; the editor preview renders the same button inert, and
|
||||||
|
description/card mining resolves the tag to nothing. A per-button label
|
||||||
|
lives in the chunk, so translated pages localize it like any other text.
|
||||||
|
Feeds render the tag as its literal source (no directive context). When
|
||||||
|
messaging is disabled (no site label configured) the tag renders nothing
|
||||||
|
at all, in the editor preview just as on the served page.
|
||||||
|
|
||||||
|
## Site settings and localization
|
||||||
|
|
||||||
|
The site editor's messaging row holds the feature's configuration
|
||||||
|
(`/_api/settings`): `contact_label` — the {contact:} buttons' label and
|
||||||
|
the visitor dialog's title, doubling as the on/off switch (empty = no
|
||||||
|
trigger buttons, no visitor corner icon, sends rejected; admins keep the
|
||||||
|
inbox for existing threads) — `contact_email` (a mailto link in the
|
||||||
|
visitor dialog: the recipient is addressed `Brand <email>`, the link
|
||||||
|
text the bare address, the subject prefilled) and `contact_subject`
|
||||||
|
(defaults to the label). `contact_mailserver` is a mailbridge URL
|
||||||
|
(`smtp://user:pass@host:port`, a bare `host[:port]`, or a backend URL
|
||||||
|
like `sendgrid:API-KEY`) reserved for email notifications — the actual
|
||||||
|
sending is not implemented yet. Saves re-render the page in place, so
|
||||||
|
button and dialog strings update without a reload.
|
||||||
|
|
||||||
|
Both sides also get browser notifications (the Notification API,
|
||||||
|
`frontend/src/notify.js`): permission is asked at one entry point only —
|
||||||
|
right after the first message of a fresh thread (a denied permission is
|
||||||
|
permanent in the browser and never re-asked; a dismissed prompt comes up
|
||||||
|
again on the next fresh thread). With permission granted the visitor is
|
||||||
|
notified of new admin replies while not viewing the thread and the admin
|
||||||
|
of new visitor messages; clicking a notification focuses the tab and
|
||||||
|
opens the thread/inbox at what was new.
|
||||||
|
|
||||||
|
The two strings localize like titles: translations live in the
|
||||||
|
chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at
|
||||||
|
render time) and the translator service picks them up automatically
|
||||||
|
(`pending_items` emits them as title-kind fragments; nav-mode jobs carry
|
||||||
|
them as extra top-level list items). Manual overrides are edited in the
|
||||||
|
same messaging row: the flag selector (the article editor's LangSelect)
|
||||||
|
switches the label/subject fields to a translation, saved via
|
||||||
|
`PUT /_api/settings?lang=` into the
|
||||||
|
same store (`i18n.set_string_translation`; empty or the original's text
|
||||||
|
drops the override). The localized strings reach the client through the
|
||||||
|
page's asset config (`pagerite:contact-label` / `-subject`, absent
|
||||||
|
entirely when disabled — pagerite.js reads that as "visitors get no
|
||||||
|
icon"); `contact-main.js` re-reads them on every open and on page swaps,
|
||||||
|
so a language switch or a settings save is reflected immediately. The
|
||||||
|
email address itself is never in the config: it is revealed over the
|
||||||
|
messaging socket only after the proof of work is solved (and only when
|
||||||
|
configured).
|
||||||
|
|
||||||
|
## Visitor side
|
||||||
|
|
||||||
|
The token is `crypto.randomUUID()` in localStorage
|
||||||
|
(`pagerite-contact-token`), generated on first use; the server stores only
|
||||||
|
its blake3 hash as the thread key. Pages are shared/cached renders, so the
|
||||||
|
thread UI is entirely client-side. A per-browser "seen" timestamp
|
||||||
|
(`pagerite-contact-seen`) and the newest admin reply's timestamp
|
||||||
|
(`pagerite-contact-lastadmin`) drive the unread marker (a periodic
|
||||||
|
scale-and-shake nudge animation, `has-unread` in pagerite.css) on trigger
|
||||||
|
buttons and the corner ✉️ icon when an admin reply arrives while the panel
|
||||||
|
is closed — including on a fresh page load before any socket activity
|
||||||
|
(pagerite.js reads the pair directly; the bundle keeps it live once
|
||||||
|
loaded). A live conversation is also flagged
|
||||||
|
(`pagerite-contact-session`): while set, page loads register the session
|
||||||
|
right away (no panel click needed), so a granted notification permission
|
||||||
|
reports new replies immediately.
|
||||||
|
|
||||||
|
**Closing a conversation**: the panel's ⟲ button sends `{token, close}` —
|
||||||
|
the server marks the thread `closed` (archived in the admin inbox,
|
||||||
|
excluded from the unread count, replies rejected, the token's pushes
|
||||||
|
stopped) — then wipes the local session keys (token, seen, lastadmin,
|
||||||
|
session flag) and rotates the token, so the next message starts an
|
||||||
|
all-new thread.
|
||||||
|
|
||||||
|
The panel rides the public `/_ws` activity socket via a bridge in
|
||||||
|
`pagerite.js` (`register`/`send`, sharing its queue and reconnect pacing —
|
||||||
|
no extra connection). Frames **to** the server:
|
||||||
|
|
||||||
|
- `{token}` — register/resync; the current thread state is pushed back.
|
||||||
|
`pagerite.js` re-sends this on every socket (re)connect, which is the
|
||||||
|
resync mechanism (the socket idle-closes after 5 min and reconnects on
|
||||||
|
activity).
|
||||||
|
- `{token, pow}` — a solved proof-of-work, submitted as soon as the
|
||||||
|
background solve finishes (below); grants the message/upload quota,
|
||||||
|
reveals the site's contact email and marks the visit's analytics trail.
|
||||||
|
- `{token, msg, fr, name?, email?}` — append a message; `msg` is the
|
||||||
|
Markdown-subset source, `fr` the page path it was sent from (stored
|
||||||
|
for context). Spends one quota; with none left it is answered with a
|
||||||
|
fresh `required` challenge instead, which the client solves, re-proves
|
||||||
|
and retries the message once. The **first** message
|
||||||
|
of a thread must carry `name` (and optionally `email`, unvalidated)
|
||||||
|
introducing the visitor; the identity is stored on the thread and fixed
|
||||||
|
from then on.
|
||||||
|
- `{token, close}` — end the conversation (archived for admins; above).
|
||||||
|
|
||||||
|
Frames **from** the server: `{type: "thread", id, created, unread,
|
||||||
|
closed, name, email, messages: [{id, t, admin, html, page}]}` — a full
|
||||||
|
thread snapshot
|
||||||
|
(replace/upsert by `id`); `{type: "pow", challenge, required}` — a fresh
|
||||||
|
single-use PoW challenge (`required` means the last message was rejected
|
||||||
|
for an empty quota and should be re-sent once re-proven); `{type: "pow",
|
||||||
|
quota}` — a submitted proof was accepted, this many messages/uploads it
|
||||||
|
grants; `{type: "contact-email", email}` — the site's contact email,
|
||||||
|
revealed only after a solve; or `{type: "contact-error", detail}`.
|
||||||
|
|
||||||
|
### Proof of work (anti-spam)
|
||||||
|
|
||||||
|
Sends and uploads draw from a quota granted by easypow (PBKDF2
|
||||||
|
challenges, ~5 s solving cost, 10 actions per solve). The server hands a
|
||||||
|
fresh single-use challenge on registration/resync and whenever the quota
|
||||||
|
runs out; the panel solves it in the background with the bundled solver
|
||||||
|
module (served from the installed package at `/_contact/easypow.js`, so
|
||||||
|
client and validator versions always match) and submits the solution as
|
||||||
|
soon as it is ready — typically long before the user finishes writing,
|
||||||
|
so sends just work. Solutions are single-use and challenges are a
|
||||||
|
per-connection local in the `/_ws` handler; the granted quota lives in
|
||||||
|
RAM keyed by the token hash (single worker), shared with the HTTP upload
|
||||||
|
route and kept across reconnects. A validated proof also reveals the
|
||||||
|
site's contact email (the address never appears in pages — scrapers
|
||||||
|
cannot lift it without solving) and records the visit's contact trail
|
||||||
|
marker in analytics (a panel opened without a solve leaves no trace).
|
||||||
|
|
||||||
|
## Admin side
|
||||||
|
|
||||||
|
Editors open the inbox from the same ✉️ corner icon (the trigger buttons
|
||||||
|
always open the visitor side, so both can be tested side by side): the
|
||||||
|
same overlay, wider to fit the thread list, served by the existing
|
||||||
|
`/_api/ws/analytics` socket (admin-gated like all of `/_api`) which
|
||||||
|
`MessagesView` connects while the overlay is open — and a background
|
||||||
|
watch in `contact-main.js` (`watchAdmin`) connects at every page load
|
||||||
|
while messaging is enabled, keeping the icon badge live and notifying
|
||||||
|
new visitor messages without waiting for the inbox (MessagesView owns
|
||||||
|
notifications while open). Analytics payloads
|
||||||
|
keep their type-less snapshot shape; messaging frames always carry a
|
||||||
|
`type` field, and that is how each client picks out its own frames (the
|
||||||
|
analytics viewer ignores them). On connect the server sends
|
||||||
|
`{type: "threads", threads: [...]}` (100 most recently active, archived
|
||||||
|
last); new
|
||||||
|
messages and replies push single-thread updates. Admin→server frames:
|
||||||
|
`{type: "reply", id, text}` and `{type: "read", id}`.
|
||||||
|
Replies store the SSO `remote-user` name for audit but are labeled with
|
||||||
|
the currently configured **site name** in the wire payload (substituted at
|
||||||
|
read time) and mark the thread read; `unread` on a thread means its last
|
||||||
|
message is the visitor's and newer than the admin's last read. The icon's
|
||||||
|
unread marker (any unanswered thread, archived ones excluded) is seeded by the `/_api/settings`
|
||||||
|
probe's `contact_unread` on every page load and kept live by the watch
|
||||||
|
and the inbox.
|
||||||
|
|
||||||
|
## Message format and the XSS model
|
||||||
|
|
||||||
|
Bodies are a **Markdown subset** — emphasis, links, lists, code, plus
|
||||||
|
`/_f/` image and file references — serialized client-side from a
|
||||||
|
constrained contenteditable composer (plain-text paste only; formatting
|
||||||
|
via toolbar; uploads insert image/attachment-chip nodes).
|
||||||
|
|
||||||
|
**No user HTML is ever rendered or sanitized.** Sanitizing arbitrary HTML
|
||||||
|
is unwinnable, so the design never attempts it: the composer serializes to
|
||||||
|
*text*, and the server re-renders that text with a strict `MarkdownIt`
|
||||||
|
instance (`messages.py`): `html=False` (the page renderer's trusted-author
|
||||||
|
raw passthrough does not apply here), no plugins. Every byte of the stored
|
||||||
|
`html` is markdown-it output from the constrained grammar. markdown-it
|
||||||
|
also validates link protocols (`javascript:` & co. never become `href`s);
|
||||||
|
the link rule adds `rel="nofollow ugc noopener"` + `target="_blank"` (and
|
||||||
|
`download` + a chip class for `/_f/` attachment links); images are
|
||||||
|
restricted to `/_f/` store names, anything else collapses to its alt text.
|
||||||
|
Both panels inject this server-rendered HTML verbatim (`v-html`) — the one
|
||||||
|
injection point, fed only by the strict renderer.
|
||||||
|
|
||||||
|
## Uploads
|
||||||
|
|
||||||
|
`PUT /_contact/files/{name}` (public, like the messaging socket) stores
|
||||||
|
panel uploads in the content-addressed file store. Images (raster and
|
||||||
|
SVG) go through the usual AVIF/WebP/JPEG derivative pipeline and inline
|
||||||
|
in the message — SVGs included: the extension-less link negotiates
|
||||||
|
raster derivatives, and even an as-is-served `.svg` is script-inert
|
||||||
|
inside an `<img>`. Everything else (videos, archives, html, exe…) is
|
||||||
|
stored as an inert `{hash}.file` blob — the original extension is
|
||||||
|
dropped on disk, so the store can never be double-click-executed — and
|
||||||
|
linked as `/_f/{hash}/{original-name}`; the `.file` suffix exists only
|
||||||
|
on the filesystem and is rejected in URLs. Serving is strictly
|
||||||
|
separated: `/_f/{name}` serves media only (hash with or without
|
||||||
|
extension), `.orig.` originals stay blocked, and the filename-carrying
|
||||||
|
route serves only `{name}.file` blobs (never media, never by hash).
|
||||||
|
Every `/_f/` response carries `content-disposition: attachment` (no
|
||||||
|
filename needed — the URL's last segment names the download) and
|
||||||
|
`x-content-type-options: nosniff`, so a store file can never be
|
||||||
|
rendered or sniffed into something executable in the site's origin;
|
||||||
|
subresource fetches (`<img>`, `<video>`, favicons) ignore the
|
||||||
|
disposition, so inline media is unaffected. The chip anchors
|
||||||
|
additionally carry a `download` attribute. Uploads spend the same PoW
|
||||||
|
quota as messages (a solved challenge covers both; the gates are a
|
||||||
|
session-shaped `x-contact-token` header, a 20 MB size cap, and the
|
||||||
|
quota itself) — an upload answered 429 "pow" makes the client re-prove
|
||||||
|
and retry once. Every upload also stays *pending* per session (10
|
||||||
|
un-sent files cap, answered 429 — not 403, which could trip auth
|
||||||
|
flows): a sent message strikes the files it references off the pending
|
||||||
|
set, and when the session's last socket closes the remaining pending
|
||||||
|
files are deleted from the store. Admin uploads from the same composer
|
||||||
|
go to the admin-gated `/_api/files` instead. Files left behind by a
|
||||||
|
server restart (the pending set is RAM-only) still linger — no GC.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
`Data.contact: dict[bytes, ContactThread]` in the kanta database, keyed by
|
||||||
|
the 12-byte blake3 hash of the session token (never the token itself). A
|
||||||
|
thread is `created`, `admin_read`, the visitor's self-entered
|
||||||
|
`name`/`email` (name required, email optional; captured with the first
|
||||||
|
message, fixed afterwards) and
|
||||||
|
`messages: dict[str, ContactMessage]` keyed by a sortable timestamp-random
|
||||||
|
id (dict-keyed, so a save's database diff touches only the appended
|
||||||
|
message). Each message stores `t`, the Markdown source `md`, its strict
|
||||||
|
rendering `html`, `admin` ("" = visitor, else the SSO name — the wire
|
||||||
|
payload shows the current site name instead) and `page`.
|
||||||
|
Appends are single transactions (`contact` / `contact:reply` /
|
||||||
|
`contact:read`).
|
||||||
|
|
||||||
|
## Limits and deliberate gaps (MVP)
|
||||||
|
|
||||||
|
- Message source ≤ 8000 chars; uploads ≤ 20 MB and ≤ 10 pending per
|
||||||
|
session; payloads cap at 100 threads × last 200 messages.
|
||||||
|
- No thread moderation (delete/block), no orphan-upload GC for files
|
||||||
|
orphaned by a server restart, and the panels' own UI strings (composer
|
||||||
|
hints, "you", inbox labels) are English-only — the *configurable* site
|
||||||
|
strings (label, subject) do localize (above).
|
||||||
+161
-33
@@ -2,10 +2,12 @@
|
|||||||
// Analytics viewer rendered as a normal page inside #main. Receives live
|
// Analytics viewer rendered as a normal page inside #main. Receives live
|
||||||
// analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and
|
// analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and
|
||||||
// renders totals, smoothed visit/views curves, a transition map, and recent
|
// renders totals, smoothed visit/views curves, a transition map, and recent
|
||||||
// visit/crawler tables. Read-only.
|
// visit/crawler tables. The same socket also carries the contact-messaging
|
||||||
|
// protocol (typed frames) for the admin inbox overlay (MessagesView.vue,
|
||||||
|
// docs/messages.md), so typed frames are ignored here.
|
||||||
// See docs/analytics.md for the data format.
|
// See docs/analytics.md for the data format.
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { apiJson } from 'paskia'
|
import { fetchJson } from 'paskia'
|
||||||
import {
|
import {
|
||||||
RANGES,
|
RANGES,
|
||||||
rangeWindow,
|
rangeWindow,
|
||||||
@@ -29,7 +31,7 @@ import VisitorCell from './VisitorCell.vue'
|
|||||||
import TransitionGraph from './TransitionGraph.vue'
|
import TransitionGraph from './TransitionGraph.vue'
|
||||||
import VisitorCharts from './VisitorCharts.vue'
|
import VisitorCharts from './VisitorCharts.vue'
|
||||||
import { VIEW_W } from './analytics/chart.js'
|
import { VIEW_W } from './analytics/chart.js'
|
||||||
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||||
import ConnNote from './ConnNote.vue'
|
import ConnNote from './ConnNote.vue'
|
||||||
|
|
||||||
// Same centering margin as the charts, so the totals row's left edge
|
// Same centering margin as the charts, so the totals row's left edge
|
||||||
@@ -43,18 +45,23 @@ const pageTree = ref(null)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
let ws = null
|
let ws = null
|
||||||
let reconnectTimeout = null
|
let reconnectTimer = null
|
||||||
let connectWatchdog = null
|
let connectWatchdog = null
|
||||||
const reconnects = reconnectPolicy()
|
const reconnects = reconnectPolicy()
|
||||||
let timeInterval = null
|
let timeInterval = null
|
||||||
|
let mounted = false
|
||||||
|
let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail
|
||||||
|
let hiddenAt = 0
|
||||||
|
|
||||||
// The panel is live data over its socket: while it is connecting or waiting
|
// The panel is live data over its socket: while it is connecting, waiting to
|
||||||
// to reconnect, say so (ConnNote) instead of showing a silent stale view.
|
// reconnect or logged out, say so (ConnNote / the login line) instead of
|
||||||
const conn = ref('connecting') // connecting | open | waiting
|
// showing a silent stale view.
|
||||||
|
const conn = ref('connecting') // connecting | open | waiting | login
|
||||||
const retryIn = ref(0)
|
const retryIn = ref(0)
|
||||||
const connNote = computed(() =>
|
const connNote = computed(() =>
|
||||||
conn.value === 'connecting' ? 'connecting to the server…'
|
conn.value === 'connecting' ? 'connecting to the server…'
|
||||||
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
||||||
|
: conn.value === 'login' ? 'login required — authenticate to continue…'
|
||||||
: '',
|
: '',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,8 +72,8 @@ const hashRange = location.hash.slice(1)
|
|||||||
const range = ref(RANGES[hashRange] ? hashRange : 'week')
|
const range = ref(RANGES[hashRange] ? hashRange : 'week')
|
||||||
let rangePinned = Boolean(RANGES[hashRange])
|
let rangePinned = Boolean(RANGES[hashRange])
|
||||||
|
|
||||||
function connectAnalytics() {
|
function connect() {
|
||||||
if (ws) return
|
if (ws || !mounted) return
|
||||||
conn.value = 'connecting'
|
conn.value = 'connecting'
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
|
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
|
||||||
@@ -79,7 +86,12 @@ function connectAnalytics() {
|
|||||||
}
|
}
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
data.value = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
|
// Contact-messaging frames (docs/messages.md) carry a type field and
|
||||||
|
// are for the admin inbox overlay; the type-less snapshot is
|
||||||
|
// analytics data.
|
||||||
|
if (msg?.type) return
|
||||||
|
data.value = msg
|
||||||
if (!rangePinned) {
|
if (!rangePinned) {
|
||||||
rangePinned = true
|
rangePinned = true
|
||||||
const starts = (data.value?.visits || [])
|
const starts = (data.value?.visits || [])
|
||||||
@@ -93,37 +105,147 @@ function connectAnalytics() {
|
|||||||
error.value = 'analytics data could not be loaded'
|
error.value = 'analytics data could not be loaded'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ws.onerror = () => {
|
// No error banner from onerror: it is always followed by onclose, which
|
||||||
error.value = 'analytics data could not be loaded'
|
// probes the cause — a lost login gets the login flow, not an error loop.
|
||||||
}
|
ws.onclose = onSocketClose
|
||||||
ws.onclose = () => {
|
}
|
||||||
ws = null
|
|
||||||
// The policy paces the retry: doubling backoff with jitter, reset only
|
// The socket dropped. Find out why before retrying (./reconnect's
|
||||||
// by a healthy connection — a fixed rapid loop trips the browser's
|
// authReconnect): an expired session fails the handshake exactly like a dead
|
||||||
// WebSocket throttling (all sockets then sit "pending" for minutes).
|
// network, but retrying the socket loops forever — the WS API cannot open
|
||||||
const wait = reconnects.closed()
|
// the login dialog. A lost session runs the paskia login flow and reconnects
|
||||||
retryIn.value = Math.max(1, Math.round(wait / 1000))
|
// the moment it completes; an aborted login (declined, or a proxy offering
|
||||||
conn.value = 'waiting'
|
// no paskia flow) leaves for the front page — the viewer is useless without
|
||||||
reconnectTimeout = setTimeout(connectAnalytics, wait)
|
// a session; anything else gets the paced backoff.
|
||||||
|
async function onSocketClose() {
|
||||||
|
ws = null
|
||||||
|
if (!mounted) return
|
||||||
|
const gen = epoch
|
||||||
|
const wait = reconnects.closed()
|
||||||
|
const outcome = await authReconnect(() => { conn.value = 'login' })
|
||||||
|
if (!mounted || gen !== epoch) return
|
||||||
|
if (outcome === 'auth') {
|
||||||
|
loadPageTree() // the mount-time fetch may have run anonymous
|
||||||
|
connectNow()
|
||||||
|
} else if (outcome === 'abort') {
|
||||||
|
goHome()
|
||||||
|
} else {
|
||||||
|
scheduleRetry(wait)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function scheduleRetry(wait) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
retryIn.value = Math.max(1, Math.round(wait / 1000))
|
||||||
|
conn.value = 'waiting'
|
||||||
|
reconnectTimer = setTimeout(connect, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave for the front page, replacing the history entry: Back must not
|
||||||
|
// return to /_a just to pop the login flow again.
|
||||||
|
function goHome() {
|
||||||
|
location.replace('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect (or recycle) right away, skipping any pending backoff: used after
|
||||||
|
// login and when the page wakes — a socket that survived sleep still looks
|
||||||
|
// OPEN but is dead (the server gave up on it while we were asleep), and only
|
||||||
|
// a fresh connect pushes the full snapshot that catches the view up.
|
||||||
|
function connectNow() {
|
||||||
|
epoch++
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waking up (sleep, or a long while hidden): recycle the connection rather
|
||||||
|
// than trust a zombie socket or wait out a stale backoff. A short tab switch
|
||||||
|
// with a live socket is left alone.
|
||||||
|
const WAKE_RECYCLE_MS = 30_000
|
||||||
|
|
||||||
|
function onVisibility() {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
hiddenAt = Date.now()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now.value = Date.now()
|
||||||
|
if (!mounted || conn.value === 'login') return
|
||||||
|
const longAway = hiddenAt && Date.now() - hiddenAt > WAKE_RECYCLE_MS
|
||||||
|
hiddenAt = 0
|
||||||
|
if (conn.value !== 'open' || longAway) connectNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOnline() {
|
||||||
|
if (mounted && conn.value !== 'open' && conn.value !== 'login') connectNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageShow(ev) {
|
||||||
|
// A bfcache restore comes back with dead sockets.
|
||||||
|
if (ev.persisted && mounted) connectNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// paskia's auth iframe announces its result as a window message: a login
|
||||||
|
// completed anywhere in the page (the banner's 🔑 button or our own flow —
|
||||||
|
// 'login' means it is pending and will connect itself on resolve) reconnects
|
||||||
|
// at once instead of waiting out the backoff timer; a logout leaves for the
|
||||||
|
// front page — the stream is useless without a session.
|
||||||
|
function onAuthMessage(ev) {
|
||||||
|
if (!mounted) return
|
||||||
|
const type = ev.data?.type
|
||||||
|
if (type === 'auth-success') {
|
||||||
|
if (conn.value === 'open' || conn.value === 'login') return
|
||||||
|
loadPageTree()
|
||||||
|
connectNow()
|
||||||
|
} else if (type === 'auth-logout') {
|
||||||
|
epoch++
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
goHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The site tree for the transition map (all pages in menu order). Not fatal:
|
||||||
|
// without it the map just narrows to pages seen in transitions. Plain
|
||||||
|
// fetchJson: the login flow is the socket's business, this fetch never pops
|
||||||
|
// a dialog.
|
||||||
|
async function loadPageTree() {
|
||||||
|
try {
|
||||||
|
pageTree.value = await fetchJson('/_api/pages')
|
||||||
|
} catch { /* map just narrows to pages seen in transitions */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mounted = true
|
||||||
// The first connection takes a staggered slot (see ./reconnect).
|
// The first connection takes a staggered slot (see ./reconnect).
|
||||||
reconnectTimeout = setTimeout(connectAnalytics, socketSlot())
|
reconnectTimer = setTimeout(connect, socketSlot())
|
||||||
now.value = Date.now()
|
now.value = Date.now()
|
||||||
timeInterval = setInterval(() => { now.value = Date.now() }, 1000)
|
timeInterval = setInterval(() => { now.value = Date.now() }, 1000)
|
||||||
// The site tree for the transition map (all pages in menu order). Not
|
addEventListener('visibilitychange', onVisibility)
|
||||||
// fatal: without it the map just narrows to pages seen in transitions.
|
addEventListener('online', onOnline)
|
||||||
try {
|
addEventListener('pageshow', onPageShow)
|
||||||
pageTree.value = await apiJson('/_api/pages')
|
addEventListener('message', onAuthMessage)
|
||||||
} catch { /* map just narrows to pages seen in transitions */ }
|
loadPageTree()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
mounted = false
|
||||||
if (connectWatchdog) clearTimeout(connectWatchdog)
|
clearTimeout(reconnectTimer)
|
||||||
if (timeInterval) clearInterval(timeInterval)
|
clearTimeout(connectWatchdog)
|
||||||
|
clearInterval(timeInterval)
|
||||||
|
removeEventListener('visibilitychange', onVisibility)
|
||||||
|
removeEventListener('online', onOnline)
|
||||||
|
removeEventListener('pageshow', onPageShow)
|
||||||
|
removeEventListener('message', onAuthMessage)
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.onclose = null
|
ws.onclose = null
|
||||||
ws.close()
|
ws.close()
|
||||||
@@ -131,7 +253,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const window = computed(() => rangeWindow(range.value))
|
// Minute-granular clock for the stats window: the range filters slide with
|
||||||
|
// time (a page left open must not leave "last 24 hours" frozen at mount)
|
||||||
|
// without re-filtering every second.
|
||||||
|
const windowMinute = computed(() => Math.floor(now.value / 60_000) * 60_000)
|
||||||
|
const window = computed(() => rangeWindow(range.value, windowMinute.value))
|
||||||
|
|
||||||
// All non-chart stats follow the selected range; the charts keep their own
|
// All non-chart stats follow the selected range; the charts keep their own
|
||||||
// range-specific x windows (week aligned to Monday, overlaid with the
|
// range-specific x windows (week aligned to Monday, overlaid with the
|
||||||
@@ -190,6 +316,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
<a href="/" class="close" title="home">✕</a>
|
<a href="/" class="close" title="home">✕</a>
|
||||||
</header>
|
</header>
|
||||||
<ConnNote :text="connNote" />
|
<ConnNote :text="connNote" />
|
||||||
|
<div>
|
||||||
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
||||||
<p v-else-if="!data" class="loading">loading…</p>
|
<p v-else-if="!data" class="loading">loading…</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -328,6 +455,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { indentWithTab } from '@codemirror/commands'
|
|||||||
import { html } from '@codemirror/lang-html'
|
import { html } from '@codemirror/lang-html'
|
||||||
import { cmHighlight, cmTheme } from './cmtheme'
|
import { cmHighlight, cmTheme } from './cmtheme'
|
||||||
import ConnNote from './ConnNote.vue'
|
import ConnNote from './ConnNote.vue'
|
||||||
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||||
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
|
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
|
||||||
import { apiFetch, apiJson } from 'paskia'
|
import { apiFetch, apiJson } from 'paskia'
|
||||||
|
|
||||||
@@ -34,17 +34,21 @@ let reconnectTimer = null
|
|||||||
let connectWatchdog = null
|
let connectWatchdog = null
|
||||||
// Reconnection pacing lives in ./reconnect (shared with the other sockets).
|
// Reconnection pacing lives in ./reconnect (shared with the other sockets).
|
||||||
const reconnects = reconnectPolicy()
|
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
|
||||||
let everConnected = false
|
let everConnected = false
|
||||||
// Connection state drives the note at the top (ConnNote), and locks input
|
// Connection state drives the note at the top (ConnNote), and locks input
|
||||||
// until the banner's document has arrived (typing before it would be
|
// until the banner's document has arrived (typing before it would be
|
||||||
// clobbered by the doc accept).
|
// clobbered by the doc accept).
|
||||||
const conn = ref('connecting') // connecting | open | waiting
|
const conn = ref('connecting') // connecting | open | waiting | login
|
||||||
const retryIn = ref(0)
|
const retryIn = ref(0)
|
||||||
const docReady = ref(false)
|
const docReady = ref(false)
|
||||||
const editable = new Compartment()
|
const editable = new Compartment()
|
||||||
const connNote = computed(() =>
|
const connNote = computed(() =>
|
||||||
conn.value === 'connecting' ? 'connecting to the server…'
|
conn.value === 'connecting' ? 'connecting to the server…'
|
||||||
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
||||||
|
: conn.value === 'login' ? 'session expired — log in to continue editing…'
|
||||||
: docReady.value ? '' : 'loading the banner…',
|
: docReady.value ? '' : 'loading the banner…',
|
||||||
)
|
)
|
||||||
let view = null // CodeMirror for the banner HTML
|
let view = null // CodeMirror for the banner HTML
|
||||||
@@ -393,6 +397,7 @@ function onKeydown(ev) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
|
connectEpoch++
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
conn.value = 'connecting'
|
conn.value = 'connecting'
|
||||||
if (ws) {
|
if (ws) {
|
||||||
@@ -416,17 +421,38 @@ function connect() {
|
|||||||
}
|
}
|
||||||
everConnected = true
|
everConnected = true
|
||||||
}
|
}
|
||||||
ws.onclose = () => {
|
ws.onclose = async () => {
|
||||||
// The wait is the policy's: doubling backoff with jitter (./reconnect),
|
// An expired session fails the handshake like a dead network, but blind
|
||||||
// reset only by a healthy connection — rapid retries trip the browser's
|
// retries loop forever — the WS API cannot open the login dialog. Probe
|
||||||
// WebSocket throttling (sockets stuck "pending" for minutes).
|
// /_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 paced
|
||||||
|
// backoff (./reconnect), reset only by a healthy connection — rapid
|
||||||
|
// retries trip the browser's WebSocket throttling.
|
||||||
|
const gen = connectEpoch
|
||||||
const wait = reconnects.closed()
|
const wait = reconnects.closed()
|
||||||
retryIn.value = Math.max(1, Math.round(wait / 1000))
|
const outcome = await authReconnect(() => { conn.value = 'login' })
|
||||||
conn.value = 'waiting'
|
if (gen !== connectEpoch) return
|
||||||
reconnectTimer = setTimeout(connect, wait)
|
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(async () => {
|
onMounted(async () => {
|
||||||
// The first connection takes a staggered slot (see ./reconnect).
|
// The first connection takes a staggered slot (see ./reconnect).
|
||||||
reconnectTimer = setTimeout(connect, socketSlot())
|
reconnectTimer = setTimeout(connect, socketSlot())
|
||||||
@@ -455,10 +481,12 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
addEventListener('keydown', onKeydown)
|
addEventListener('keydown', onKeydown)
|
||||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||||
|
addEventListener('message', onAuthMessage)
|
||||||
await loadSettings()
|
await loadSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
connectEpoch++ // invalidate a pending onclose auth probe
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
clearTimeout(connectWatchdog)
|
clearTimeout(connectWatchdog)
|
||||||
for (const t of Object.values(timers)) clearTimeout(t)
|
for (const t of Object.values(timers)) clearTimeout(t)
|
||||||
@@ -469,6 +497,7 @@ onUnmounted(() => {
|
|||||||
view?.destroy()
|
view?.destroy()
|
||||||
removeEventListener('keydown', onKeydown)
|
removeEventListener('keydown', onKeydown)
|
||||||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||||||
|
removeEventListener('message', onAuthMessage)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script setup>
|
||||||
|
// The message composer shared by the visitor contact panel and the admin
|
||||||
|
// inbox: a constrained contenteditable (plain-text paste, toolbar-driven
|
||||||
|
// formatting, upload chips) serialized to the Markdown subset on send
|
||||||
|
// (./richtext.js). The server re-renders that with its strict parser —
|
||||||
|
// what shows in threads is never this DOM.
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { apiFetch } from 'paskia'
|
||||||
|
import { serializeRich } from './richtext.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
placeholder: { type: String, default: 'Write a message…' },
|
||||||
|
// Visitor session token for the public upload endpoint; empty = admin,
|
||||||
|
// whose uploads go to the /_api file store instead.
|
||||||
|
token: { type: String, default: '' },
|
||||||
|
autofocus: { type: Boolean, default: false },
|
||||||
|
// When true, send (button and Ctrl+Enter) is inert and the draft is
|
||||||
|
// kept — e.g. the contact panel's first-message name/email fields are
|
||||||
|
// still empty.
|
||||||
|
sendDisabled: { type: Boolean, default: false },
|
||||||
|
// Visitor side: an upload hit the end of the PoW quota (429 "pow") —
|
||||||
|
// re-prove and resolve, then the upload retries once.
|
||||||
|
reauthorize: { type: Function, default: null },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['send'])
|
||||||
|
|
||||||
|
const box = ref(null)
|
||||||
|
const imageInput = ref(null)
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const uploading = ref(0)
|
||||||
|
const error = ref('')
|
||||||
|
let errorTimer = null
|
||||||
|
|
||||||
|
function showError(text) {
|
||||||
|
error.value = text
|
||||||
|
clearTimeout(errorTimer)
|
||||||
|
errorTimer = setTimeout(() => { error.value = '' }, 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.autofocus) box.value?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
function cmd(name, value = null) {
|
||||||
|
box.value?.focus()
|
||||||
|
document.execCommand(name, false, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function link() {
|
||||||
|
const url = window.prompt('Link URL', 'https://')
|
||||||
|
if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const escHtml = (s) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
|
||||||
|
// Paste is plain text only — the grammar (bold, links, chips…) comes from
|
||||||
|
// the toolbar and uploads, never from foreign HTML.
|
||||||
|
function onPaste(ev) {
|
||||||
|
ev.preventDefault()
|
||||||
|
if (ev.clipboardData?.files?.length) {
|
||||||
|
uploadFiles(ev.clipboardData.files)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = ev.clipboardData?.getData('text/plain') || ''
|
||||||
|
if (!text) return
|
||||||
|
const html = text
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((p) => `<div>${p.split(/\r\n|\r|\n/).map(escHtml).join('<br>')}</div>`)
|
||||||
|
.join('')
|
||||||
|
document.execCommand('insertHTML', false, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(ev) {
|
||||||
|
if (ev.dataTransfer?.files?.length) {
|
||||||
|
ev.preventDefault()
|
||||||
|
uploadFiles(ev.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
|
showError(`"${file.name}" is over 20 MB`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uploading.value++
|
||||||
|
try {
|
||||||
|
const path = `/_${props.token ? 'contact' : 'api'}/files/${encodeURIComponent(file.name)}`
|
||||||
|
const init = { method: 'PUT', body: file }
|
||||||
|
if (props.token) init.headers = { 'x-contact-token': props.token }
|
||||||
|
const put = () => (props.token ? fetch(path, init) : apiFetch(path, init))
|
||||||
|
let r = await put()
|
||||||
|
if (!r.ok && r.status === 429 && props.reauthorize) {
|
||||||
|
// The PoW quota (shared with messages) ran out: re-prove, retry once.
|
||||||
|
const detail = await r.json().then((d) => d?.detail, () => null)
|
||||||
|
if (detail === 'pow' && (await props.reauthorize())) r = await put()
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
const detail = await r.json().then((d) => d?.detail, () => null)
|
||||||
|
throw new Error(detail || `status ${r.status}`)
|
||||||
|
}
|
||||||
|
insertUpload(await r.json())
|
||||||
|
} catch (e) {
|
||||||
|
console.error('contact upload failed:', e)
|
||||||
|
showError(`could not upload "${file.name}": ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
uploading.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An uploaded file becomes an inline image or an attachment chip; both
|
||||||
|
// serialize back to /_f/ Markdown references on send.
|
||||||
|
function insertUpload({ path, image, name }) {
|
||||||
|
const html = image
|
||||||
|
? `<img class="contact-img" src="${escHtml(path)}" data-path="${escHtml(path)}" alt="${escHtml(name)}">`
|
||||||
|
: `<span class="contact-file" contenteditable="false" data-path="${escHtml(path)}" data-name="${escHtml(name)}">📎 ${escHtml(name)}</span> `
|
||||||
|
box.value?.focus()
|
||||||
|
document.execCommand('insertHTML', false, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const md = serializeRich(box.value)
|
||||||
|
if (!md || uploading.value || props.sendDisabled) return
|
||||||
|
emit('send', md)
|
||||||
|
box.value.innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(ev) {
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') send()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus: () => box.value?.focus() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="composer">
|
||||||
|
<div
|
||||||
|
ref="box"
|
||||||
|
class="composer-box"
|
||||||
|
contenteditable="true"
|
||||||
|
:data-placeholder="placeholder"
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
@paste="onPaste"
|
||||||
|
@drop="onDrop"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
></div>
|
||||||
|
<div class="composer-bar">
|
||||||
|
<button type="button" title="bold" @mousedown.prevent @click="cmd('bold')"><b>B</b></button>
|
||||||
|
<button type="button" title="italic" @mousedown.prevent @click="cmd('italic')"><i>I</i></button>
|
||||||
|
<button type="button" title="link" @mousedown.prevent @click="link">🔗</button>
|
||||||
|
<button type="button" title="list" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button>
|
||||||
|
<button type="button" title="image" @mousedown.prevent @click="imageInput.click()">🖼</button>
|
||||||
|
<button type="button" title="attachment" @mousedown.prevent @click="fileInput.click()">📎</button>
|
||||||
|
<span v-if="uploading" class="uploading">uploading…</span>
|
||||||
|
<button type="button" class="send" title="send (Ctrl+Enter)" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="composer-error">{{ error }}</p>
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.composer {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg, Canvas);
|
||||||
|
}
|
||||||
|
.composer:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-box {
|
||||||
|
min-height: 3.2rem;
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
outline: none;
|
||||||
|
font: inherit;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.composer-box:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.composer-box :deep(img.contact-img) {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 10rem;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
.composer-box :deep(.contact-file) {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1em;
|
||||||
|
background: color-mix(var(--muted) 10%, transparent);
|
||||||
|
font-size: 0.9em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.composer-bar button {
|
||||||
|
min-width: 1.7rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: none;
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.composer-bar button:hover {
|
||||||
|
background: color-mix(var(--muted) 15%, transparent);
|
||||||
|
}
|
||||||
|
.composer-bar button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.composer-bar .uploading {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.composer-bar .send {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.2rem 0.7rem 0.35rem;
|
||||||
|
color: #b3261e;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup>
|
||||||
|
// The messaging overlay shell (docs/messages.md): one centered dialog over
|
||||||
|
// paskia's shared blur backdrop, on any page. The mode decides the content:
|
||||||
|
// 'visitor' shows the visitor's own thread (ContactPanel — what the
|
||||||
|
// {contact:} trigger buttons open, for admins too), 'admin' the contact
|
||||||
|
// inbox (MessagesView — what the corner ✉️ icon opens for admins).
|
||||||
|
// contact-main holds the backdrop while state.open and owns the store.
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import ContactPanel from './ContactPanel.vue'
|
||||||
|
import MessagesView from './MessagesView.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
state: { type: Object, required: true },
|
||||||
|
// Visitor-side PoW quota re-grant for the composer's upload retries.
|
||||||
|
reauthorize: { type: Function, default: null },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['send', 'close', 'close-thread'])
|
||||||
|
|
||||||
|
const admin = computed(() => props.state.mode === 'admin')
|
||||||
|
|
||||||
|
// The visitor dialog's title is the localized site contact label (the
|
||||||
|
// {contact:} buttons' default label). The mailto href carries the
|
||||||
|
// server-encoded "Brand <email>" recipient and the localized subject;
|
||||||
|
// the link text is the bare address.
|
||||||
|
const mailto = computed(
|
||||||
|
() => `mailto:${props.state.mailtoTo}?subject=${encodeURIComponent(props.state.subject)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Escape closes, like the paskia dialogs whose backdrop we share.
|
||||||
|
function onKey(ev) {
|
||||||
|
if (ev.key === 'Escape' && props.state.open) emit('close')
|
||||||
|
}
|
||||||
|
onMounted(() => addEventListener('keydown', onKey))
|
||||||
|
onUnmounted(() => removeEventListener('keydown', onKey))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="state.open" class="contact-overlay" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="contact-panel"
|
||||||
|
:class="{ admin }"
|
||||||
|
role="dialog"
|
||||||
|
:aria-label="admin ? 'Messages' : state.label"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>{{ admin ? 'Messages' : state.label }}</h2>
|
||||||
|
<a v-if="!admin && state.email" class="mailto" :href="mailto">{{ state.email }}</a>
|
||||||
|
<button
|
||||||
|
v-if="!admin && state.thread?.messages?.length"
|
||||||
|
type="button"
|
||||||
|
class="close-thread"
|
||||||
|
title="close this conversation — your next message starts a fresh thread"
|
||||||
|
@click="emit('close-thread')"
|
||||||
|
>⟲</button>
|
||||||
|
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
|
||||||
|
</header>
|
||||||
|
<ContactPanel
|
||||||
|
v-if="!admin"
|
||||||
|
:state="state"
|
||||||
|
:token="state.token"
|
||||||
|
:reauthorize="reauthorize"
|
||||||
|
@send="(p) => emit('send', p)"
|
||||||
|
/>
|
||||||
|
<MessagesView v-else :state="state" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contact-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above paskia's shared blur backdrop (1099, held by contact-main while
|
||||||
|
open), below the authentication iframe (9999). Transparent: the blur
|
||||||
|
is the backdrop's job, this layer only centers the panel and catches
|
||||||
|
outside clicks (the backdrop itself is pointer-events: none). */
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Same dimensions as paskia's own dialog. */
|
||||||
|
width: min(36rem, 100%);
|
||||||
|
height: min(42rem, 92vh);
|
||||||
|
padding: 0.8rem;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--bg, Canvas);
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0.5rem 2rem rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The admin inbox adds the thread-list sidebar: a wider dialog. */
|
||||||
|
.contact-panel.admin {
|
||||||
|
width: min(54rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-panel header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.contact-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.contact-panel .mailto {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.contact-panel .mailto:hover {
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.contact-panel .close {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.contact-panel .close,
|
||||||
|
.contact-panel .close-thread {
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.contact-panel .close:hover,
|
||||||
|
.contact-panel .close-thread:hover {
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: full screen (the admin layout's own sidebar stacking kicks in
|
||||||
|
at the same width). */
|
||||||
|
@media (max-width: 36rem) {
|
||||||
|
.contact-panel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.contact-panel.admin {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup>
|
||||||
|
// The visitor's contact thread: messages pushed live over the activity
|
||||||
|
// socket (via the bridge pagerite.js hands to contact-main), with the
|
||||||
|
// composer shared with the admin inbox. state is contact-main's reactive
|
||||||
|
// store ({open, mode, thread, unread, ...}). The overlay shell (backdrop,
|
||||||
|
// dialog frame, close) is ContactOverlay.vue.
|
||||||
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { formatWhen } from './analytics/format.js'
|
||||||
|
import Composer from './Composer.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
state: { type: Object, required: true },
|
||||||
|
token: { type: String, required: true },
|
||||||
|
// Re-grant the PoW quota (an upload hit its end): resolves once the
|
||||||
|
// fresh proof is accepted, so the upload can retry.
|
||||||
|
reauthorize: { type: Function, default: null },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['send'])
|
||||||
|
|
||||||
|
const scroll = ref(null)
|
||||||
|
|
||||||
|
async function scrollDown() {
|
||||||
|
await nextTick()
|
||||||
|
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to the newest message: on mount (the overlay is v-if'd, so every
|
||||||
|
// open is a fresh mount) and whenever the message count changes — sent or
|
||||||
|
// received, both arrive as server-pushed thread updates.
|
||||||
|
watch(() => props.state.thread?.messages?.length, scrollDown)
|
||||||
|
onMounted(scrollDown)
|
||||||
|
|
||||||
|
// Relative timestamps like in analytics.
|
||||||
|
const fmtTime = (t) => formatWhen(t)
|
||||||
|
|
||||||
|
const NAME_KEY = 'pagerite-contact-name'
|
||||||
|
const EMAIL_KEY = 'pagerite-contact-email'
|
||||||
|
|
||||||
|
// Name (and optionally email) are asked with the first message; from
|
||||||
|
// then on the thread's stored identity labels the visitor's messages.
|
||||||
|
const intro = reactive({
|
||||||
|
name: localStorage.getItem(NAME_KEY) || '',
|
||||||
|
email: localStorage.getItem(EMAIL_KEY) || '',
|
||||||
|
})
|
||||||
|
const showIntro = computed(() => !props.state.thread?.name)
|
||||||
|
// Sending stays disabled until the name field is filled.
|
||||||
|
const introInvalid = computed(() => showIntro.value && !intro.name.trim())
|
||||||
|
|
||||||
|
function onSend(md) {
|
||||||
|
if (showIntro.value) {
|
||||||
|
const name = intro.name.trim()
|
||||||
|
const email = intro.email.trim()
|
||||||
|
localStorage.setItem(NAME_KEY, name)
|
||||||
|
localStorage.setItem(EMAIL_KEY, email)
|
||||||
|
emit('send', { md, name, email })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('send', { md })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="scroll" class="contact-messages">
|
||||||
|
<p v-if="!state.thread || !state.thread.messages.length" class="contact-empty">
|
||||||
|
Send us a message — the reply appears here.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="m in state.thread?.messages || []"
|
||||||
|
:key="m.id"
|
||||||
|
class="contact-msg"
|
||||||
|
:class="{ mine: !m.admin }"
|
||||||
|
>
|
||||||
|
<!-- m.html is the server's strict-markdown rendering of the
|
||||||
|
message source (pagerite/messages.py, html=False): markdown-it
|
||||||
|
output only, never user markup. -->
|
||||||
|
<header>
|
||||||
|
<span class="who">{{ m.admin || state.thread?.name || 'you' }}</span>
|
||||||
|
<time>{{ fmtTime(m.t) }}</time>
|
||||||
|
</header>
|
||||||
|
<div class="contact-msg-body" v-html="m.html"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showIntro" class="contact-intro">
|
||||||
|
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100">
|
||||||
|
<input v-model="intro.email" type="email" placeholder="Your email (optional)" maxlength="200">
|
||||||
|
</div>
|
||||||
|
<Composer
|
||||||
|
:token="token"
|
||||||
|
:send-disabled="introInvalid"
|
||||||
|
:reauthorize="reauthorize"
|
||||||
|
autofocus
|
||||||
|
@send="onSend"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contact-messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
.contact-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-msg {
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
}
|
||||||
|
.contact-msg + .contact-msg {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.contact-msg-body {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.contact-msg-body :deep(p) {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
.contact-msg-body :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
.contact-msg-body :deep(a.attachment) {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1em;
|
||||||
|
background: var(--bg, Canvas);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.contact-msg header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.contact-msg.mine header {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.contact-msg header .who {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-intro {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.contact-intro input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 8rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: var(--bg, Canvas);
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.contact-intro input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
<script setup>
|
||||||
|
// The admin contact inbox, mounted inside the messaging overlay
|
||||||
|
// (ContactOverlay.vue) when an admin opens the corner ✉️ icon. Owns its
|
||||||
|
// connection to the admin-gated /_api/ws/analytics socket (the same
|
||||||
|
// endpoint the analytics viewer uses) for exactly as long as the overlay
|
||||||
|
// is open: frames carrying a `type` field are messaging payloads
|
||||||
|
// (threads/thread/contact-error), the type-less analytics snapshot is
|
||||||
|
// ignored. Replies and read-markings go back down the same socket
|
||||||
|
// (docs/messages.md). Message HTML is the server's strict-markdown
|
||||||
|
// rendering — injected verbatim, never user markup (pagerite/messages.py).
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { formatWhen } from './analytics/format.js'
|
||||||
|
import Composer from './Composer.vue'
|
||||||
|
import ConnNote from './ConnNote.vue'
|
||||||
|
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||||
|
import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
|
||||||
|
|
||||||
|
// The shared store (contact-main): adminUnread mirrors the inbox's unread
|
||||||
|
// count onto the corner icon's badge.
|
||||||
|
const props = defineProps({
|
||||||
|
state: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const threads = ref([])
|
||||||
|
const selected = ref('')
|
||||||
|
const scroll = ref(null)
|
||||||
|
|
||||||
|
let ws = null
|
||||||
|
let reconnectTimer = null
|
||||||
|
let connectWatchdog = null
|
||||||
|
const reconnects = reconnectPolicy()
|
||||||
|
let mounted = false
|
||||||
|
let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail
|
||||||
|
let hiddenAt = 0
|
||||||
|
// 'threads' full-state pushes are the baseline, history never notifies.
|
||||||
|
const seenLastMsg = new Map()
|
||||||
|
|
||||||
|
// The inbox is live data over its socket: while it is connecting, waiting
|
||||||
|
// to reconnect or logged out, say so (ConnNote) instead of showing a
|
||||||
|
// silent stale view.
|
||||||
|
const conn = ref('connecting') // connecting | open | waiting | login
|
||||||
|
const retryIn = ref(0)
|
||||||
|
const connNote = computed(() =>
|
||||||
|
conn.value === 'connecting' ? 'connecting to the server…'
|
||||||
|
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
||||||
|
: conn.value === 'login' ? 'login required — authenticate to continue…'
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread && !t.closed ? 1 : 0), 0))
|
||||||
|
watch(unreadTotal, (n) => { props.state.adminUnread = n }, { immediate: true })
|
||||||
|
|
||||||
|
function lastT(t) {
|
||||||
|
return t.messages.length ? t.messages[t.messages.length - 1].t : t.created
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertThread(thread) {
|
||||||
|
const rest = threads.value.filter((t) => t.id !== thread.id)
|
||||||
|
// Most recently active first, visitor-closed (archived) threads last.
|
||||||
|
threads.value = [thread, ...rest].sort(
|
||||||
|
(a, b) => (a.closed - b.closed) || lastT(b).localeCompare(lastT(a))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyThread(t) {
|
||||||
|
const last = t.messages[t.messages.length - 1]
|
||||||
|
const prev = seenLastMsg.get(t.id)
|
||||||
|
seenLastMsg.set(t.id, last?.id || '')
|
||||||
|
// prev undefined = a brand-new thread, which should notify. Not when the
|
||||||
|
// thread is already on screen.
|
||||||
|
if (!last || last.admin || prev === last.id || t.id === selected.value) return
|
||||||
|
notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html), () => {
|
||||||
|
selected.value = t.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsSend(obj) {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (ws || !mounted) return
|
||||||
|
conn.value = 'connecting'
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
|
||||||
|
clearTimeout(connectWatchdog)
|
||||||
|
connectWatchdog = watchConnecting(ws, 'messages')
|
||||||
|
ws.onopen = () => {
|
||||||
|
conn.value = 'open'
|
||||||
|
reconnects.opened()
|
||||||
|
}
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
let msg = null
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(event.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The type-less snapshot is analytics data, not for this view.
|
||||||
|
if (msg?.type === 'threads') {
|
||||||
|
threads.value = msg.threads
|
||||||
|
for (const t of msg.threads) seenLastMsg.set(t.id, lastMsg(t)?.id || '')
|
||||||
|
} else if (msg?.type === 'thread') {
|
||||||
|
notifyThread(msg)
|
||||||
|
upsertThread(msg)
|
||||||
|
} else if (msg?.type === 'contact-error') {
|
||||||
|
console.warn('contact:', msg.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No error banner from onerror: it is always followed by onclose, which
|
||||||
|
// probes the cause — a lost login gets the login flow, not an error loop.
|
||||||
|
ws.onclose = onSocketClose
|
||||||
|
}
|
||||||
|
|
||||||
|
// The socket dropped. Find out why before retrying (./reconnect's
|
||||||
|
// authReconnect): an expired session fails the handshake exactly like a
|
||||||
|
// dead network, but retrying the socket loops forever — the WS API cannot
|
||||||
|
// open the login dialog. A lost session runs the paskia login flow and
|
||||||
|
// reconnects the moment it completes; an aborted login (declined, or a
|
||||||
|
// proxy offering no paskia flow) closes the inbox; anything else gets the
|
||||||
|
// paced backoff.
|
||||||
|
async function onSocketClose() {
|
||||||
|
ws = null
|
||||||
|
if (!mounted) return
|
||||||
|
const gen = epoch
|
||||||
|
const wait = reconnects.closed()
|
||||||
|
const outcome = await authReconnect(() => { conn.value = 'login' })
|
||||||
|
if (!mounted || gen !== epoch) return
|
||||||
|
if (outcome === 'auth') {
|
||||||
|
connectNow()
|
||||||
|
} else if (outcome === 'abort') {
|
||||||
|
props.state.open = false
|
||||||
|
} else {
|
||||||
|
scheduleRetry(wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry(wait) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
retryIn.value = Math.max(1, Math.round(wait / 1000))
|
||||||
|
conn.value = 'waiting'
|
||||||
|
reconnectTimer = setTimeout(connect, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect (or recycle) right away, skipping any pending backoff: used after
|
||||||
|
// login and when the page wakes — a socket that survived sleep still looks
|
||||||
|
// OPEN but is dead (the server gave up on it while we were asleep), and
|
||||||
|
// only a fresh connect pushes the full inbox that catches the view up.
|
||||||
|
function connectNow() {
|
||||||
|
epoch++
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waking up (sleep, or a long while hidden): recycle the connection rather
|
||||||
|
// than trust a zombie socket or wait out a stale backoff. A short tab
|
||||||
|
// switch with a live socket is left alone.
|
||||||
|
const WAKE_RECYCLE_MS = 30_000
|
||||||
|
|
||||||
|
function onVisibility() {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
hiddenAt = Date.now()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!mounted || conn.value === 'login') return
|
||||||
|
const longAway = hiddenAt && Date.now() - hiddenAt > WAKE_RECYCLE_MS
|
||||||
|
hiddenAt = 0
|
||||||
|
if (conn.value !== 'open' || longAway) connectNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOnline() {
|
||||||
|
if (mounted && conn.value !== 'open' && conn.value !== 'login') connectNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageShow(ev) {
|
||||||
|
// A bfcache restore comes back with dead sockets.
|
||||||
|
if (ev.persisted && mounted) connectNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// paskia's auth iframe announces its result as a window message: a login
|
||||||
|
// completed anywhere in the page reconnects at once instead of waiting out
|
||||||
|
// the backoff timer; a logout closes the inbox — it is useless without a
|
||||||
|
// session.
|
||||||
|
function onAuthMessage(ev) {
|
||||||
|
if (!mounted) return
|
||||||
|
const type = ev.data?.type
|
||||||
|
if (type === 'auth-success') {
|
||||||
|
if (conn.value === 'open' || conn.value === 'login') return
|
||||||
|
connectNow()
|
||||||
|
} else if (type === 'auth-logout') {
|
||||||
|
epoch++
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
props.state.open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mounted = true
|
||||||
|
// The first connection takes a staggered slot (see ./reconnect).
|
||||||
|
reconnectTimer = setTimeout(connect, socketSlot())
|
||||||
|
addEventListener('visibilitychange', onVisibility)
|
||||||
|
addEventListener('online', onOnline)
|
||||||
|
addEventListener('pageshow', onPageShow)
|
||||||
|
addEventListener('message', onAuthMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
mounted = false
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
clearTimeout(connectWatchdog)
|
||||||
|
removeEventListener('visibilitychange', onVisibility)
|
||||||
|
removeEventListener('online', onOnline)
|
||||||
|
removeEventListener('pageshow', onPageShow)
|
||||||
|
removeEventListener('message', onAuthMessage)
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const thread = computed(() => threads.value.find((t) => t.id === selected.value) || null)
|
||||||
|
|
||||||
|
// Auto-open the first thread once the list arrives.
|
||||||
|
watch(
|
||||||
|
() => threads.value.length,
|
||||||
|
(n) => {
|
||||||
|
if (n && !selected.value) selected.value = threads.value[0].id
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Opening a thread marks it read on the server (the updated payload
|
||||||
|
// comes back around and clears the dot).
|
||||||
|
watch(thread, (t) => {
|
||||||
|
if (!t) return
|
||||||
|
if (t.unread) wsSend({ type: 'read', id: t.id })
|
||||||
|
scrollDown()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => thread.value?.messages?.length, scrollDown)
|
||||||
|
|
||||||
|
async function scrollDown() {
|
||||||
|
await nextTick()
|
||||||
|
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMsg(t) {
|
||||||
|
return t.messages[t.messages.length - 1] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// List-row preview: the last message's visible text (the html is
|
||||||
|
// server-rendered strict markdown, safe to parse here).
|
||||||
|
function preview(t) {
|
||||||
|
const m = lastMsg(t)
|
||||||
|
if (!m) return ''
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = m.html
|
||||||
|
const who = m.admin ? `${m.admin}: ` : ''
|
||||||
|
return (who + (div.textContent || '')).slice(0, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative timestamps like elsewhere in analytics.
|
||||||
|
const fmtTime = (t) => formatWhen(t)
|
||||||
|
|
||||||
|
function send(md) {
|
||||||
|
// First reply doubles as the notification-permission gesture.
|
||||||
|
askNotifyPermission()
|
||||||
|
if (thread.value) wsSend({ type: 'reply', id: thread.value.id, text: md })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ConnNote :text="connNote" />
|
||||||
|
<div class="messages-view">
|
||||||
|
<aside class="thread-list">
|
||||||
|
<p v-if="!threads.length" class="empty">no messages yet</p>
|
||||||
|
<button
|
||||||
|
v-for="t in threads"
|
||||||
|
:key="t.id"
|
||||||
|
type="button"
|
||||||
|
class="thread-row"
|
||||||
|
:class="{ active: t.id === selected, archived: t.closed }"
|
||||||
|
@click="selected = t.id"
|
||||||
|
>
|
||||||
|
<span class="dot" :class="{ unread: t.unread && !t.closed }"></span>
|
||||||
|
<span class="peek">
|
||||||
|
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
|
||||||
|
<em v-if="t.closed" class="archived-tag">archived</em>
|
||||||
|
{{ preview(t) }}
|
||||||
|
</span>
|
||||||
|
<time>{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
<section v-if="thread" class="thread">
|
||||||
|
<header v-if="thread.name" class="thread-head">
|
||||||
|
<strong>{{ thread.name }}</strong>
|
||||||
|
<a v-if="thread.email" :href="`mailto:${thread.email}`">{{ thread.email }}</a>
|
||||||
|
</header>
|
||||||
|
<div ref="scroll" class="thread-messages">
|
||||||
|
<div
|
||||||
|
v-for="m in thread.messages"
|
||||||
|
:key="m.id"
|
||||||
|
class="contact-msg"
|
||||||
|
:class="{ mine: m.admin }"
|
||||||
|
>
|
||||||
|
<!-- Server-rendered strict markdown only (see ContactPanel). -->
|
||||||
|
<header>
|
||||||
|
<span class="who">{{ m.admin || thread.name || 'visitor' }}</span>
|
||||||
|
<time>{{ fmtTime(m.t) }}</time>
|
||||||
|
</header>
|
||||||
|
<div class="contact-msg-body" v-html="m.html"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Composer
|
||||||
|
:placeholder="thread.closed ? 'archived — the visitor closed this conversation' : 'Reply…'"
|
||||||
|
:send-disabled="thread.closed"
|
||||||
|
@send="send"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section v-else class="thread">
|
||||||
|
<p class="empty">select a thread</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.messages-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
width: 16rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.thread-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: none;
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.thread-row.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.thread-row .dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.thread-row .dot.unread {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.thread-row .peek {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.thread-row .peek strong {
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.thread-row.archived {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.thread-row .archived-tag {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.6em;
|
||||||
|
padding: 0 0.4em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.thread-row time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.thread-messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.thread-head a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.thread-head a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-msg {
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
}
|
||||||
|
.contact-msg + .contact-msg {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.contact-msg-body {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.contact-msg-body :deep(p) {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
.contact-msg-body :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
.contact-msg-body :deep(a.attachment) {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1em;
|
||||||
|
background: var(--bg, Canvas);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.contact-msg header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.contact-msg.mine header {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.contact-msg header .who {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, CanvasText);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.messages-view {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.thread-list {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 30%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -38,7 +38,7 @@ import { flagFor, langName, langSort } from './langs'
|
|||||||
import { editorLang, pagePrimary } from './editorLang'
|
import { editorLang, pagePrimary } from './editorLang'
|
||||||
import LangSelect from './LangSelect.vue'
|
import LangSelect from './LangSelect.vue'
|
||||||
import ConnNote from './ConnNote.vue'
|
import ConnNote from './ConnNote.vue'
|
||||||
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||||
import { dropPageCache, loadPlain } from './swapdoc'
|
import { dropPageCache, loadPlain } from './swapdoc'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -75,13 +75,14 @@ let sessionDoc = null
|
|||||||
// input until the page's document has arrived: typing before the accept
|
// input until the page's document has arrived: typing before the accept
|
||||||
// would be clobbered by it. While merely DISconnected the editor stays
|
// would be clobbered by it. While merely DISconnected the editor stays
|
||||||
// editable — text stashes and pending saves flush on reconnect.
|
// 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 retryIn = ref(0)
|
||||||
const docReady = ref(false)
|
const docReady = ref(false)
|
||||||
const editable = new Compartment()
|
const editable = new Compartment()
|
||||||
const connNote = computed(() =>
|
const connNote = computed(() =>
|
||||||
conn.value === 'connecting' ? 'connecting to the server…'
|
conn.value === 'connecting' ? 'connecting to the server…'
|
||||||
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
: 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…',
|
: docReady.value ? '' : 'loading the page…',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,6 +97,9 @@ let connectWatchdog = null
|
|||||||
// retries trip the browser's WebSocket throttling (sockets stuck "pending"
|
// retries trip the browser's WebSocket throttling (sockets stuck "pending"
|
||||||
// for minutes), which is what kept hollowing out the editor.
|
// for minutes), which is what kept hollowing out the editor.
|
||||||
const reconnects = reconnectPolicy()
|
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)
|
const dirty = ref(false) // unsaved text exists (drives the 💾 button)
|
||||||
let syncingScroll = false
|
let syncingScroll = false
|
||||||
|
|
||||||
@@ -990,6 +994,7 @@ function consumePendingLine() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
|
connectEpoch++
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
conn.value = 'connecting'
|
conn.value = 'connecting'
|
||||||
if (ws) {
|
if (ws) {
|
||||||
@@ -1021,19 +1026,40 @@ function connect() {
|
|||||||
openPath(normPath(props.pagePath))
|
openPath(normPath(props.pagePath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ws.onclose = (ev) => {
|
ws.onclose = async (ev) => {
|
||||||
// 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or
|
// 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or
|
||||||
// the connecting watchdog fired); worth seeing since a dead socket
|
// the connecting watchdog fired); worth seeing since a dead socket
|
||||||
// before the first doc bricks the editor until a retry lands one.
|
// 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 || '')
|
console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '')
|
||||||
|
const gen = connectEpoch
|
||||||
const wait = reconnects.closed()
|
const wait = reconnects.closed()
|
||||||
retryIn.value = Math.max(1, Math.round(wait / 1000))
|
// An expired session fails the handshake like a dead network, but blind
|
||||||
conn.value = 'waiting'
|
// retries loop forever — the WS API cannot open the login dialog. Probe
|
||||||
reconnectTimer = setTimeout(connect, wait)
|
// /_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(() => {
|
onMounted(() => {
|
||||||
// The first connection takes a staggered slot (see ./reconnect): page
|
// The first connection takes a staggered slot (see ./reconnect): page
|
||||||
// load opens several sockets at once, and the burst trips throttling.
|
// load opens several sockets at once, and the burst trips throttling.
|
||||||
@@ -1090,9 +1116,11 @@ onMounted(() => {
|
|||||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||||
// A section pen clicked while the page editor is already open.
|
// A section pen clicked while the page editor is already open.
|
||||||
addEventListener('pagerite:edit-section', consumePendingLine)
|
addEventListener('pagerite:edit-section', consumePendingLine)
|
||||||
|
addEventListener('message', onAuthMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
connectEpoch++ // invalidate a pending onclose auth probe
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
clearTimeout(connectWatchdog)
|
clearTimeout(connectWatchdog)
|
||||||
if (ws) {
|
if (ws) {
|
||||||
@@ -1106,6 +1134,7 @@ onUnmounted(() => {
|
|||||||
removeEventListener('keydown', onKeydown)
|
removeEventListener('keydown', onKeydown)
|
||||||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||||||
removeEventListener('pagerite:edit-section', consumePendingLine)
|
removeEventListener('pagerite:edit-section', consumePendingLine)
|
||||||
|
removeEventListener('message', onAuthMessage)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { html } from '@codemirror/lang-html'
|
|||||||
import { cmHighlight, cmTheme } from './cmtheme'
|
import { cmHighlight, cmTheme } from './cmtheme'
|
||||||
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
|
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
|
||||||
import { apiFetch, apiJson } from 'paskia'
|
import { apiFetch, apiJson } from 'paskia'
|
||||||
|
import { editorLang } from './editorLang'
|
||||||
|
import LangSelect from './LangSelect.vue'
|
||||||
|
import { flagFor, langName, langSort } from './langs'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pagePath: { type: String, default: '' },
|
pagePath: { type: String, default: '' },
|
||||||
@@ -57,6 +60,103 @@ async function errorDetail(res) {
|
|||||||
// empty brand removes the header link and the title suffix entirely.
|
// empty brand removes the header link and the title suffix entirely.
|
||||||
const brand = ref('')
|
const brand = ref('')
|
||||||
const theme = 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
|
// Theme options come from the backend (theme folders on disk, see GET
|
||||||
// /_api/settings), so added themes need no frontend changes.
|
// /_api/settings), so added themes need no frontend changes.
|
||||||
const themeOptions = ref([{ value: '', label: '☀️ none' }])
|
const themeOptions = ref([{ value: '', label: '☀️ none' }])
|
||||||
@@ -88,6 +188,16 @@ async function loadSettings() {
|
|||||||
]
|
]
|
||||||
transition.value = s.transition || 'cube'
|
transition.value = s.transition || 'cube'
|
||||||
transitionOptions.value = s.transitions || []
|
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 = [
|
fontOptions.value = [
|
||||||
...BASE_FONT_OPTIONS,
|
...BASE_FONT_OPTIONS,
|
||||||
...(s.fonts || []).map((f) => ({
|
...(s.fonts || []).map((f) => ({
|
||||||
@@ -255,6 +365,12 @@ async function saveSettings(opts = {}) {
|
|||||||
custom_css: customCss.value,
|
custom_css: customCss.value,
|
||||||
brand_html: brandHtml.value,
|
brand_html: brandHtml.value,
|
||||||
transition: transition.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,
|
...opts,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -264,6 +380,7 @@ async function saveSettings(opts = {}) {
|
|||||||
} else {
|
} else {
|
||||||
saveError.value = '⚠️ changes could not be saved'
|
saveError.value = '⚠️ changes could not be saved'
|
||||||
}
|
}
|
||||||
|
return res.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onThemeChange() {
|
async function onThemeChange() {
|
||||||
@@ -528,6 +645,9 @@ onMounted(async () => {
|
|||||||
parent: brandEl.value,
|
parent: brandEl.value,
|
||||||
})
|
})
|
||||||
await loadSettings()
|
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)
|
parseFonts(customCss.value)
|
||||||
setCssDocument(customCss.value)
|
setCssDocument(customCss.value)
|
||||||
applyCustomCss(customCss.value)
|
applyCustomCss(customCss.value)
|
||||||
@@ -655,6 +775,88 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="msg-grid">
|
||||||
|
<div class="msg-flag">
|
||||||
|
<span class="field-label" aria-hidden="true"></span>
|
||||||
|
<LangSelect
|
||||||
|
v-model="lang"
|
||||||
|
:options="contactLangOptions"
|
||||||
|
title="Language of the button label and email subject strings (edits the translation override)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">contact button label</span>
|
||||||
|
<input
|
||||||
|
v-model="contactLabel"
|
||||||
|
class="text-input"
|
||||||
|
:placeholder="translating ? contactLabelOrig : ''"
|
||||||
|
title="The {contact:} button label and visitor dialog title — empty disables messaging"
|
||||||
|
:readonly="pmGuard"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
|
@pointerdown="pmUnlock"
|
||||||
|
@focus="pmUnlock"
|
||||||
|
@input="onContactInput"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">email subject</span>
|
||||||
|
<input
|
||||||
|
v-model="contactSubject"
|
||||||
|
class="text-input"
|
||||||
|
:placeholder="translating ? contactSubjectOrig : ''"
|
||||||
|
title="Subject line for the mailto link — empty = the button label"
|
||||||
|
:readonly="pmGuard"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
|
@pointerdown="pmUnlock"
|
||||||
|
@focus="pmUnlock"
|
||||||
|
@input="onContactInput"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">our email</span>
|
||||||
|
<input
|
||||||
|
v-model="contactEmail"
|
||||||
|
class="text-input"
|
||||||
|
title="A mailto link in the visitor dialog, revealed after the visitor solves the proof of work — empty = no link"
|
||||||
|
:disabled="!!translating"
|
||||||
|
:readonly="pmGuard"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
|
@pointerdown="pmUnlock"
|
||||||
|
@focus="pmUnlock"
|
||||||
|
@input="debounce('contact-email', () => saveSettings())"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<!-- Mail server (SMTP notification backend): plumbing in place
|
||||||
|
(contactMailserver ref, settings load/save, Data field) but
|
||||||
|
the sending is not implemented yet — the field stays hidden
|
||||||
|
until then. Re-adds a fourth column to .msg-grid.
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">mail server</span>
|
||||||
|
<input
|
||||||
|
v-model="contactMailserver"
|
||||||
|
class="text-input"
|
||||||
|
title="Server for email notifications — smtp://user:pass@host:port, a bare host[:port], or a backend URL like sendgrid:API-KEY — empty = no email"
|
||||||
|
:disabled="!!translating"
|
||||||
|
:readonly="pmGuard"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
|
@pointerdown="pmUnlock"
|
||||||
|
@focus="pmUnlock"
|
||||||
|
@input="debounce('contact-mailserver', () => saveSettings())"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="block grow" @paste="onBrandPaste">
|
<section class="block grow" @paste="onBrandPaste">
|
||||||
<div class="block-head">
|
<div class="block-head">
|
||||||
<span class="field-label">brand code (replaces the brand link)</span>
|
<span class="field-label">brand code (replaces the brand link)</span>
|
||||||
@@ -785,6 +987,48 @@ onUnmounted(() => {
|
|||||||
font-size: 1rem;
|
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 {
|
.text-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 4rem;
|
min-width: 4rem;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
||||||
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
|
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
|
||||||
// {slug, path, title, translated, order, published, has_content, language,
|
// {slug, path, title, translated, order, published, has_content, empty,
|
||||||
|
// language,
|
||||||
// primary, children}. The row's flag
|
// primary, children}. The row's flag
|
||||||
// (LangSelect) sets the node's primary language (language; '' = inherit —
|
// (LangSelect) sets the node's primary language (language; '' = inherit —
|
||||||
// dimmed, showing the resolved flag); the setting covers the whole subtree.
|
// dimmed, showing the resolved flag); the setting covers the whole subtree.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const title = computed(() => {
|
|||||||
if (hasError.value) {
|
if (hasError.value) {
|
||||||
parts.push(`${props.step.status}`)
|
parts.push(`${props.step.status}`)
|
||||||
}
|
}
|
||||||
|
if (props.step.contact) {
|
||||||
|
parts.push('contact panel opened, proof of work solved')
|
||||||
|
}
|
||||||
return parts.filter(Boolean).join(' — ')
|
return parts.filter(Boolean).join(' — ')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -39,6 +42,7 @@ const title = computed(() => {
|
|||||||
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
||||||
<img v-if="favicon" class="favicon" :src="favicon" alt="" />
|
<img v-if="favicon" class="favicon" :src="favicon" alt="" />
|
||||||
<span>{{ step.slug }}</span>
|
<span>{{ step.slug }}</span>
|
||||||
|
<span v-if="step.contact" class="contact-mark">✉️</span>
|
||||||
<span v-for="(f, fi) in flags" :key="fi" class="flag" v-html="f"></span>
|
<span v-for="(f, fi) in flags" :key="fi" class="flag" v-html="f"></span>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,6 +55,12 @@ const title = computed(() => {
|
|||||||
vertical-align: -0.1em;
|
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). */
|
/* Same flag chip as the visitor cells (VisitorCell.vue). */
|
||||||
.flag {
|
.flag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TNODE_W,
|
TNODE_W,
|
||||||
TNODE_H,
|
TNODE_H,
|
||||||
BEAD_R,
|
BEAD_R,
|
||||||
|
BEAD_LEN,
|
||||||
buildTransitionGraph,
|
buildTransitionGraph,
|
||||||
} from './analytics/transitions.js'
|
} from './analytics/transitions.js'
|
||||||
|
|
||||||
@@ -121,9 +122,16 @@ const syncBeads = (flows) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tick = (t) => {
|
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
|
lastTick = t
|
||||||
for (const e of emitters.values()) {
|
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) {
|
while (e.next <= t) {
|
||||||
live.push({ e, p: 0 })
|
live.push({ e, p: 0 })
|
||||||
e.next += e.interval
|
e.next += e.interval
|
||||||
@@ -138,7 +146,17 @@ const tick = (t) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const f = b.e.flow
|
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
|
beads.value = out
|
||||||
rafId = requestAnimationFrame(tick)
|
rafId = requestAnimationFrame(tick)
|
||||||
@@ -177,6 +195,21 @@ const countLabel = (n) =>
|
|||||||
<section v-if="graph">
|
<section v-if="graph">
|
||||||
<svg class="tmap" :style="{ maxWidth: `${graph.bounds.x1 - graph.bounds.x0}px` }" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
<svg class="tmap" :style="{ maxWidth: `${graph.bounds.x1 - graph.bounds.x0}px` }" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
||||||
role="img" aria-label="map of transitions between pages">
|
role="img" aria-label="map of transitions between pages">
|
||||||
|
<defs>
|
||||||
|
<!-- Pulse gradient: opaque head at the bead position (local origin),
|
||||||
|
fading tail trailing behind it (stop-color comes from CSS so
|
||||||
|
theme vars resolve). -->
|
||||||
|
<linearGradient id="tbead-grad">
|
||||||
|
<stop offset="0" class="tbead-stop" stop-opacity="0" />
|
||||||
|
<stop offset="0.55" class="tbead-stop" stop-opacity="0.5" />
|
||||||
|
<stop offset="1" class="tbead-stop" stop-opacity="1" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="tbead-grad-exit">
|
||||||
|
<stop offset="0" class="tbead-stop-exit" stop-opacity="0" />
|
||||||
|
<stop offset="0.55" class="tbead-stop-exit" stop-opacity="0.5" />
|
||||||
|
<stop offset="1" class="tbead-stop-exit" stop-opacity="1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||||
:id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" />
|
:id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" />
|
||||||
<template v-for="(a, i) in graph.arcs" :key="'t' + i">
|
<template v-for="(a, i) in graph.arcs" :key="'t' + i">
|
||||||
@@ -187,8 +220,11 @@ const countLabel = (n) =>
|
|||||||
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
||||||
<title>{{ e.title }}</title>
|
<title>{{ e.title }}</title>
|
||||||
</path>
|
</path>
|
||||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
<rect v-for="(b, i) in beads" :key="'b' + i"
|
||||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
:x="-BEAD_LEN" :y="-BEAD_R" :width="BEAD_LEN" :height="2 * BEAD_R" :rx="BEAD_R"
|
||||||
|
:fill="b.ext ? 'url(#tbead-grad-exit)' : 'url(#tbead-grad)'"
|
||||||
|
:class="['tbead', b.ext && 'tbead-exit']"
|
||||||
|
:transform="`translate(${b.x} ${b.y}) rotate(${b.deg})`" />
|
||||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
||||||
<clipPath :id="`xclip${i}`">
|
<clipPath :id="`xclip${i}`">
|
||||||
<rect :x="x.x - TNODE_W/2 + 6" :y="x.y - TNODE_H/2" :width="TNODE_W - 12"
|
<rect :x="x.x - TNODE_W/2 + 6" :y="x.y - TNODE_H/2" :width="TNODE_W - 12"
|
||||||
@@ -252,10 +288,13 @@ const countLabel = (n) =>
|
|||||||
fill: var(--text);
|
fill: var(--text);
|
||||||
}
|
}
|
||||||
.tmap .tbead {
|
.tmap .tbead {
|
||||||
fill: var(--accent);
|
|
||||||
opacity: 0.85;
|
|
||||||
filter: drop-shadow(0 0 2.5px var(--accent));
|
filter: drop-shadow(0 0 2.5px var(--accent));
|
||||||
}
|
}
|
||||||
|
.tmap .tbead-exit {
|
||||||
|
filter: drop-shadow(0 0 2.5px var(--text));
|
||||||
|
}
|
||||||
|
.tmap .tbead-stop { stop-color: var(--accent); }
|
||||||
|
.tmap .tbead-stop-exit { stop-color: var(--text); }
|
||||||
.tmap .txnode {
|
.tmap .txnode {
|
||||||
/* External source/exit pills: plain white on every theme, with a hairline
|
/* External source/exit pills: plain white on every theme, with a hairline
|
||||||
so the pill stays visible on a white page. */
|
so the pill stays visible on a white page. */
|
||||||
|
|||||||
@@ -656,6 +656,8 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
|
|||||||
if (item.read) step.readSeconds = item.read
|
if (item.read) step.readSeconds = item.read
|
||||||
if (item.status) step.status = item.status
|
if (item.status) step.status = item.status
|
||||||
if (item.lang) step.lang = item.lang
|
if (item.lang) step.lang = item.lang
|
||||||
|
// Contact panel opened here and its PoW solved (✉️ marker).
|
||||||
|
if (item.contact) step.contact = true
|
||||||
}
|
}
|
||||||
return step
|
return step
|
||||||
})
|
})
|
||||||
@@ -667,6 +669,7 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
|
|||||||
if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang)
|
if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang)
|
||||||
if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds
|
if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds
|
||||||
if (step.status) prev.status = step.status
|
if (step.status) prev.status = step.status
|
||||||
|
if (step.contact) prev.contact = true
|
||||||
} else {
|
} else {
|
||||||
step.langs = step.lang ? [step.lang] : []
|
step.langs = step.lang ? [step.lang] : []
|
||||||
trail.push(step)
|
trail.push(step)
|
||||||
|
|||||||
@@ -190,11 +190,11 @@ export function makeSeries(buckets, rangeKey) {
|
|||||||
* Every bounded range is a rolling span ending at now; the charts instead
|
* Every bounded range is a rolling span ending at now; the charts instead
|
||||||
* align week to Monday 00:00 UTC (overlaying previous weeks) and month+
|
* align week to Monday 00:00 UTC (overlaying previous weeks) and month+
|
||||||
* to UTC day boundaries, so their x windows differ from the stats range
|
* to UTC day boundaries, so their x windows differ from the stats range
|
||||||
* on purpose.
|
* on purpose. `now` is passed in by reactive callers so the window slides
|
||||||
|
* as time passes instead of freezing at mount.
|
||||||
* Returns { t0, t1 } where null means unbounded.
|
* Returns { t0, t1 } where null means unbounded.
|
||||||
*/
|
*/
|
||||||
export function rangeWindow(rangeKey) {
|
export function rangeWindow(rangeKey, now = Date.now()) {
|
||||||
const now = Date.now()
|
|
||||||
if (rangeKey === 'all') {
|
if (rangeKey === 'all') {
|
||||||
return { t0: null, t1: null }
|
return { t0: null, t1: null }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
*
|
*
|
||||||
* Site map following the menu structure: top-level items in a row at the
|
* Site map following the menu structure: top-level items in a row at the
|
||||||
* top (below the external source row), each item's subtree fanning out
|
* top (below the external source row), each item's subtree fanning out
|
||||||
* below it in menu order along a slightly circular downward arc. Index
|
* below it in menu order along a slightly circular downward arc. Every
|
||||||
* pages with no views are omitted, their children moving up in their
|
* page of the site is shown (from /_api/pages), whether or not it had
|
||||||
* place. All pages of the site are shown (from /_api/pages), plus any
|
* views in the window — only index pages without content of their own
|
||||||
* extra paths seen in transitions (deleted pages); these form their own
|
* (category labels and empty pages — the ones the site navigation itself
|
||||||
* top-level groups. Internal path -> path transitions join opposite
|
* bypasses in favor of the first child) are omitted, their children
|
||||||
|
* moving up 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, materializing missing ancestors as
|
||||||
|
* content-less category lanes. Internal path -> path transitions join opposite
|
||||||
* directions into straight connections (middle width = total
|
* directions into straight connections (middle width = total
|
||||||
* count; connectors flare into the node pills at both ends and wrap
|
* count; connectors flare into the node pills at both ends and wrap
|
||||||
* around their backs, surrounding them; the pills are drawn on top). Connection width grows
|
* around their backs, surrounding them; the pills are drawn on top). Connection width grows
|
||||||
@@ -144,8 +149,10 @@ const MIN_WMID = 0.4
|
|||||||
// from real time to keep the animation lightweight. The component
|
// from real time to keep the animation lightweight. The component
|
||||||
// simulates every bead independently in JS with a constant traversal
|
// simulates every bead independently in JS with a constant traversal
|
||||||
// time per edge (speed relative to span length), with no limit on beads
|
// time per edge (speed relative to span length), with no limit on beads
|
||||||
// in flight.
|
// in flight. A bead renders as an elongated pulse trailing its direction
|
||||||
|
// of travel: BEAD_R is its half-thickness, BEAD_LEN its full length.
|
||||||
export const BEAD_R = 3.2
|
export const BEAD_R = 3.2
|
||||||
|
export const BEAD_LEN = BEAD_R * 5.5
|
||||||
const BEAD_RATE = 0.0084 // beads per second per hit/day
|
const BEAD_RATE = 0.0084 // beads per second per hit/day
|
||||||
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
||||||
|
|
||||||
@@ -153,33 +160,6 @@ const MAX_EXT_IN = 8 // referer nodes in the top row
|
|||||||
const MAX_EXT_OUT = 12 // exit nodes in the bottom row
|
const MAX_EXT_OUT = 12 // exit nodes in the bottom row
|
||||||
const EXT_GAP = 12 // vertical margin of the source/exit rows to the map
|
const EXT_GAP = 12 // vertical margin of the source/exit rows to the map
|
||||||
|
|
||||||
/** Flatten the site tree into navigation order via DFS. */
|
|
||||||
function buildNavigationOrder(pageTree) {
|
|
||||||
const order = new Map()
|
|
||||||
const walk = (items) => {
|
|
||||||
for (const item of items || []) {
|
|
||||||
const p = `/${item.path}`
|
|
||||||
if (!order.has(p)) order.set(p, order.size)
|
|
||||||
walk(item.children)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(pageTree)
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map page paths to their article titles from the site tree. */
|
|
||||||
function buildTitleMap(pageTree) {
|
|
||||||
const titles = new Map()
|
|
||||||
const walk = (items) => {
|
|
||||||
for (const item of items || []) {
|
|
||||||
titles.set(`/${item.path}`, item.title)
|
|
||||||
walk(item.children)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(pageTree)
|
|
||||||
return titles
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract internal page-to-page transitions, excluding self-loops. */
|
/** Extract internal page-to-page transitions, excluding self-loops. */
|
||||||
function collectInternalTransitions(transitions) {
|
function collectInternalTransitions(transitions) {
|
||||||
const internal = []
|
const internal = []
|
||||||
@@ -222,43 +202,70 @@ function collectExitPairs(transitions) {
|
|||||||
return [...pairs.values()]
|
return [...pairs.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build nodes with depth and a path lookup map; children are wired to parents. */
|
/**
|
||||||
function buildNodeTree(internal, navOrder) {
|
* Build the node tree: every page of the current site tree (from
|
||||||
const paths = new Set(['/', ...navOrder.keys()])
|
* /_api/pages), nested by URL and ordered by the menu, plus a ghost for
|
||||||
for (const e of internal) { paths.add(e.fr); paths.add(e.to) }
|
* every path the traffic names but the tree no longer has (deleted or
|
||||||
|
* renamed pages). A ghost is inserted where it would have been: under
|
||||||
|
* its URL parent, with missing ancestors materialized as content-less
|
||||||
|
* ghost categories — never drawn themselves, their children move up in
|
||||||
|
* their place and they still define branch lanes, exactly like live
|
||||||
|
* index pages without content (see annotateNodes/layoutGroups).
|
||||||
|
* Ghost pills sort after the real siblings, alphabetically among
|
||||||
|
* themselves (their original menu position is unknowable).
|
||||||
|
*/
|
||||||
|
function buildNodeTree(pageTree, extraPaths) {
|
||||||
|
// The front page anchors the map and is always shown, even with no
|
||||||
|
// views in the window (and even when the site has no front page node).
|
||||||
|
const root = { path: '/', depth: 0, title: '', content: true, ghost: false, children: [] }
|
||||||
|
const byPath = new Map([['/', root]])
|
||||||
|
const nodes = [root]
|
||||||
|
|
||||||
const depth = (p) => (p === '/' ? 0 : p.split('/').length - 1)
|
const walk = (items, parent) => {
|
||||||
const nodes = [...paths].map((p) => ({
|
for (const item of items || []) {
|
||||||
path: p, depth: depth(p), angle: 0, children: [],
|
const p = `/${item.path}`
|
||||||
}))
|
let n = byPath.get(p) // the front page item (path "") maps to "/"
|
||||||
const byPath = new Map(nodes.map((n) => [n.path, n]))
|
if (!n) {
|
||||||
|
n = {
|
||||||
// Parent is the nearest ancestor present in the map, front page last.
|
path: p, depth: parent.depth + 1, title: '',
|
||||||
const parentOf = (p) => {
|
content: !item.empty, ghost: false, children: [],
|
||||||
let q = p
|
}
|
||||||
while (q !== '/') {
|
byPath.set(p, n)
|
||||||
q = q.slice(0, q.lastIndexOf('/')) || '/'
|
parent.children.push(n)
|
||||||
if (byPath.has(q)) return byPath.get(q)
|
nodes.push(n)
|
||||||
|
}
|
||||||
|
n.title = item.title
|
||||||
|
walk(item.children, n)
|
||||||
}
|
}
|
||||||
return byPath.get('/')
|
|
||||||
}
|
}
|
||||||
for (const n of nodes) {
|
walk(pageTree, root)
|
||||||
if (n.path !== '/') parentOf(n.path).children.push(n)
|
|
||||||
|
// Ghosts: placed under their URL parent, materializing missing
|
||||||
|
// ancestors as content-less ghost categories (not drawn; their
|
||||||
|
// children move up and they still define branch lanes).
|
||||||
|
const materialize = (p) => {
|
||||||
|
let n = byPath.get(p)
|
||||||
|
if (n) return n
|
||||||
|
const parent = materialize(p.slice(0, p.lastIndexOf('/')) || '/')
|
||||||
|
n = { path: p, depth: parent.depth + 1, title: '', content: false, ghost: true, children: [] }
|
||||||
|
byPath.set(p, n)
|
||||||
|
parent.children.push(n)
|
||||||
|
nodes.push(n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
for (const p of extraPaths) {
|
||||||
|
const n = materialize(p)
|
||||||
|
if (n.ghost) n.content = true // traffic seen: show the pill
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, byPath, root: byPath.get('/') }
|
// Real siblings keep menu order; ghosts follow them, alphabetical.
|
||||||
}
|
const sortChildren = (n) => {
|
||||||
|
n.children.sort((a, b) => (a.ghost - b.ghost) || (a.ghost ? a.path.localeCompare(b.path) : 0))
|
||||||
/** Sort each node's children by navigation order, recursively. */
|
n.children.forEach(sortChildren)
|
||||||
function sortByNav(root, navOrder) {
|
|
||||||
const byNav = (a, b) =>
|
|
||||||
(navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity)
|
|
||||||
|| a.path.localeCompare(b.path)
|
|
||||||
const walk = (n) => {
|
|
||||||
n.children.sort(byNav)
|
|
||||||
n.children.forEach(walk)
|
|
||||||
}
|
}
|
||||||
walk(root)
|
sortChildren(root)
|
||||||
|
|
||||||
|
return { nodes, byPath, root }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute median reading time per article in seconds. */
|
/** Compute median reading time per article in seconds. */
|
||||||
@@ -283,7 +290,7 @@ function buildReadSeconds(visits) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Compute view counts, labels and hidden flags for each node. */
|
/** Compute view counts, labels and hidden flags for each node. */
|
||||||
function annotateNodes(nodes, viewsData, titles, readSeconds) {
|
function annotateNodes(nodes, viewsData, readSeconds) {
|
||||||
const viewCount = (p) => {
|
const viewCount = (p) => {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
||||||
@@ -294,12 +301,17 @@ function annotateNodes(nodes, viewsData, titles, readSeconds) {
|
|||||||
n.views = viewCount(n.path)
|
n.views = viewCount(n.path)
|
||||||
n.readSec = readSeconds[n.path] || 0
|
n.readSec = readSeconds[n.path] || 0
|
||||||
// Article title inside the pill (clipped at the pill border on
|
// Article title inside the pill (clipped at the pill border on
|
||||||
// render), slug as fallback for pages missing from the site tree.
|
// render), slug as fallback for ghost pages missing from the tree.
|
||||||
n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
|
n.label = n.title || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
|
||||||
n.title = titles.get(n.path) || ''
|
// Index pages without content of their own — category labels (404
|
||||||
// Category (non-leaf) pages with no views in this window are omitted:
|
// placeholders), empty pages and materialized ghost categories — are
|
||||||
// their children move up in their place (see layoutGroups).
|
// the nodes the site navigation itself bypasses in favor of the first
|
||||||
n.hidden = n.children.length > 0 && n.views === 0
|
// child page, so they are omitted from the map too, their children
|
||||||
|
// moving up in their place (see layoutGroups). A childless empty page
|
||||||
|
// is a real navigation stop and stays, and every real page stays
|
||||||
|
// whether or not it had views in the window: the map always shows
|
||||||
|
// the full site structure.
|
||||||
|
n.hidden = !n.content && n.children.length > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,10 +640,12 @@ function buildRibbon(a, b, ab, ba, wMid, external = false) {
|
|||||||
* at the surround), plus the emission interval (seconds between beads,
|
* at the surround), plus the emission interval (seconds between beads,
|
||||||
* inverse of the daily hit rate * BEAD_RATE). Each segment is offset to the
|
* inverse of the daily hit rate * BEAD_RATE). Each segment is offset to the
|
||||||
* right-hand side of its travel direction, so opposing flows on the same
|
* right-hand side of its travel direction, so opposing flows on the same
|
||||||
* edge run on parallel lanes instead of colliding. The component turns
|
* edge run on parallel lanes instead of colliding. `external` tags flows on
|
||||||
* these into independently simulated beads.
|
* external-link edges so the component can render their beads in the
|
||||||
|
* connector color (the exit/source connectors use --text, not --accent).
|
||||||
|
* The component turns these into independently simulated beads.
|
||||||
*/
|
*/
|
||||||
function buildFlows(a, b, ab, ba, dayScale = 1) {
|
function buildFlows(a, b, ab, ba, dayScale = 1, external = false) {
|
||||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||||
const ux = (b.x - a.x) / len
|
const ux = (b.x - a.x) / len
|
||||||
const uy = (b.y - a.y) / len
|
const uy = (b.y - a.y) / len
|
||||||
@@ -655,6 +669,7 @@ function buildFlows(a, b, ab, ba, dayScale = 1) {
|
|||||||
y2: a.y + toT * uy + s * ry,
|
y2: a.y + toT * uy + s * ry,
|
||||||
len: span,
|
len: span,
|
||||||
interval: 1 / (count * BEAD_RATE * dayScale),
|
interval: 1 / (count * BEAD_RATE * dayScale),
|
||||||
|
external,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const flows = []
|
const flows = []
|
||||||
@@ -884,7 +899,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
|
|||||||
const wMid = width(p.in)
|
const wMid = width(p.in)
|
||||||
if (wMid < MIN_WMID) continue
|
if (wMid < MIN_WMID) continue
|
||||||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
|
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
|
||||||
flows.push(...buildFlows(xn, page, p.in, 0, dayScale))
|
flows.push(...buildFlows(xn, page, p.in, 0, dayScale, true))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -934,7 +949,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
|
|||||||
const wMid = width(p.out)
|
const wMid = width(p.out)
|
||||||
if (wMid < MIN_WMID) continue
|
if (wMid < MIN_WMID) continue
|
||||||
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
|
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
|
||||||
flows.push(...buildFlows(page, xn, p.out, 0, dayScale))
|
flows.push(...buildFlows(page, xn, p.out, 0, dayScale, true))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -953,15 +968,19 @@ export function buildTransitionGraph(data, pageTree, visits = [], dayScale = 1)
|
|||||||
const internal = collectInternalTransitions(data?.transitions)
|
const internal = collectInternalTransitions(data?.transitions)
|
||||||
const sources = collectSourcePairs(visits)
|
const sources = collectSourcePairs(visits)
|
||||||
const exits = collectExitPairs(data?.transitions)
|
const exits = collectExitPairs(data?.transitions)
|
||||||
const navOrder = buildNavigationOrder(pageTree)
|
|
||||||
const titles = buildTitleMap(pageTree)
|
|
||||||
const readSeconds = buildReadSeconds(visits)
|
const readSeconds = buildReadSeconds(visits)
|
||||||
|
|
||||||
if (!internal.length && !navOrder.size) return null
|
// Every internal path the traffic names, for ghost placement:
|
||||||
|
// transition endpoints, plus pages with views but no internal
|
||||||
|
// transition (entries from outside — a deleted page still visited).
|
||||||
|
const extraPaths = new Set()
|
||||||
|
for (const e of internal) { extraPaths.add(e.fr); extraPaths.add(e.to) }
|
||||||
|
for (const p of Object.keys(data?.views || {})) extraPaths.add(p)
|
||||||
|
|
||||||
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
if (!pageTree?.length && !extraPaths.size) return null
|
||||||
sortByNav(root, navOrder)
|
|
||||||
annotateNodes(nodes, data?.views, titles, readSeconds)
|
const { nodes, byPath, root } = buildNodeTree(pageTree, extraPaths)
|
||||||
|
annotateNodes(nodes, data?.views, readSeconds)
|
||||||
const { arcs, arcLeft } = layoutGroups(root)
|
const { arcs, arcLeft } = layoutGroups(root)
|
||||||
const placed = nodes.filter((n) => !n.hidden)
|
const placed = nodes.filter((n) => !n.hidden)
|
||||||
const pairs = aggregatePairs(internal)
|
const pairs = aggregatePairs(internal)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@
|
|||||||
--font-heading: var(--font-source-serif);
|
--font-heading: var(--font-source-serif);
|
||||||
/* The brand follows the heading font unless overridden separately. */
|
/* The brand follows the heading font unless overridden separately. */
|
||||||
--font-brand: var(--font-heading);
|
--font-brand: var(--font-heading);
|
||||||
|
/* Navbar typography (family and weight only — size/letter-case stay
|
||||||
|
per-theme #nav rules); the {contact:} trigger button matches it. */
|
||||||
|
--font-nav: var(--font-body);
|
||||||
|
--nav-weight: 400;
|
||||||
--font-code: var(--font-fira-code);
|
--font-code: var(--font-fira-code);
|
||||||
/* Target x-height ratio for code text: set to the body font's ratio
|
/* Target x-height ratio for code text: set to the body font's ratio
|
||||||
(here Source Sans 3's 0.478) so font-size-adjust can scale the code
|
(here Source Sans 3's 0.478) so font-size-adjust can scale the code
|
||||||
@@ -258,6 +262,8 @@ body {
|
|||||||
|
|
||||||
/* Nav overlaid at the bottom of the banner */
|
/* Nav overlaid at the bottom of the banner */
|
||||||
#nav {
|
#nav {
|
||||||
|
font-family: var(--font-nav);
|
||||||
|
font-weight: var(--nav-weight);
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
padding: 0.35rem 1.25rem;
|
padding: 0.35rem 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -407,6 +413,22 @@ body.editing #sidebar {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The corner ✉️ (docs/messages.md) is in the pens row for everyone, so
|
||||||
|
needs its own positioning context. Unread (a new reply for visitors,
|
||||||
|
unanswered threads for admins) lifts it out of the icon-btn dimming
|
||||||
|
and animates it (see contact-nudge by the trigger button).
|
||||||
|
✉️'s envelope glyph runs smaller than its emoji neighbors (📊, 🔐, the
|
||||||
|
flag) at the same font size; bump it to match their height (scoped to
|
||||||
|
.editor-pens to out-specify the row's `font: inherit` reset). */
|
||||||
|
.editor-pens .contact-icon {
|
||||||
|
position: relative;
|
||||||
|
font-size: 1.38em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-icon.has-unread {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
/* Pinned to the page's start edge (not the article's) and kept in view
|
/* Pinned to the page's start edge (not the article's) and kept in view
|
||||||
@@ -878,6 +900,76 @@ article {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* {contact: Label} directive: the button opening the visitor contact
|
||||||
|
panel (docs/messages.md). A standalone accent button in the article
|
||||||
|
flow; .has-unread (set by contact-main) marks a new admin reply.
|
||||||
|
Filled with the accent and hovered like the accent-filled navbar tab:
|
||||||
|
text in --ink (nitro; --bg elsewhere), hover switches to the bright
|
||||||
|
--tab-hover with the same glow (text-shadow, which fades dimmer, so
|
||||||
|
the color stays bright). */
|
||||||
|
.contact-trigger {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
margin: 0.3em 0.1em;
|
||||||
|
padding: 0.35em 1.1em;
|
||||||
|
/* Same font and weight as the navbar, size/leading as the article text. */
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
font-family: var(--font-nav);
|
||||||
|
font-weight: var(--nav-weight);
|
||||||
|
color: var(--ink, var(--bg));
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-trigger .icon {
|
||||||
|
/* Match the corner ✉️'s optical size against the button text. The
|
||||||
|
reduced line-height keeps the larger glyph from inflating the
|
||||||
|
button: 1.38 × 1.2 ≈ the label text's own 1.65 line box. */
|
||||||
|
font-size: 1.38em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-trigger:hover {
|
||||||
|
color: var(--tab-hover, var(--bg));
|
||||||
|
text-shadow: 0 0 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unread (a new admin reply, or unanswered threads on the corner icon —
|
||||||
|
the .has-unread class, set by contact-main): a scale-up with a shake
|
||||||
|
every few seconds to draw the eye, stopping when read (the class goes
|
||||||
|
away). No static badge; reduced-motion users just get the lift above. */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.contact-trigger.has-unread,
|
||||||
|
.contact-icon.has-unread {
|
||||||
|
animation: contact-nudge 4.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contact-nudge {
|
||||||
|
0%, 16%, 100% { transform: none; }
|
||||||
|
2% { transform: scale(1.15) rotate(-8deg); }
|
||||||
|
5% { transform: scale(1.15) rotate(7deg); }
|
||||||
|
8% { transform: scale(1.15) rotate(-5deg); }
|
||||||
|
11% { transform: scale(1.15) rotate(4deg); }
|
||||||
|
14% { transform: scale(1.12); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Any button in the article flow (the {contact:} trigger and raw
|
||||||
|
<button>s in the markup — not the .icon-btn editor pens or .copy):
|
||||||
|
a slight lift on hover. */
|
||||||
|
article button:not(.icon-btn):not(.copy) {
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
article button:not(.icon-btn):not(.copy):hover {
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
.edit-link {
|
.edit-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
// Messaging overlay entry: imported on demand by pagerite.js when a
|
||||||
|
// {contact:} trigger button or the corner ✉️ icon is clicked — and right
|
||||||
|
// at page load when there is a live message session (a visitor's open
|
||||||
|
// conversation, flagged in localStorage, resumes checking for replies
|
||||||
|
// without waiting for the panel; for admins the always-on inbox watch
|
||||||
|
// keeps the badge and notifications live while messaging is enabled).
|
||||||
|
// The icon opens the admin inbox for admins, the visitor thread for
|
||||||
|
// visitors; the trigger buttons always open the visitor thread (so
|
||||||
|
// admins can test both sides). The visitor thread rides the activity
|
||||||
|
// socket through the bridge pagerite.js hands over ({send, register}) —
|
||||||
|
// no extra connection; the admin inbox connects its own admin-gated
|
||||||
|
// socket (MessagesView while open, the background watch otherwise). The
|
||||||
|
// session token is a localStorage random; the server stores only its
|
||||||
|
// hash. Closing a conversation archives the thread server-side, wipes
|
||||||
|
// the local session keys and rotates the token, so the next message
|
||||||
|
// starts an all-new thread.
|
||||||
|
import { createApp, reactive, watch } from 'vue'
|
||||||
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
import ContactOverlay from './ContactOverlay.vue'
|
||||||
|
import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
|
||||||
|
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect.js'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'pagerite-contact-token'
|
||||||
|
const SEEN_KEY = 'pagerite-contact-seen'
|
||||||
|
// The newest admin reply seen, so the corner icon's unread dot survives
|
||||||
|
// page loads without any socket activity (pagerite.js reads it too).
|
||||||
|
const LASTADMIN_KEY = 'pagerite-contact-lastadmin'
|
||||||
|
// An open conversation: set while the visitor has a live thread, so page
|
||||||
|
// loads resume checking for replies immediately (cleared on close).
|
||||||
|
const SESSION_KEY = 'pagerite-contact-session'
|
||||||
|
|
||||||
|
let app = null
|
||||||
|
const state = reactive({
|
||||||
|
open: false,
|
||||||
|
mode: 'visitor', // 'visitor' | 'admin'
|
||||||
|
thread: null,
|
||||||
|
unread: false, // visitor: an unread admin reply
|
||||||
|
adminUnread: 0, // admin: unanswered threads
|
||||||
|
viewerAdmin: false, // set once the inbox has been opened: icon badge = adminUnread
|
||||||
|
// The session token (a localStorage random; the server stores only its
|
||||||
|
// hash). Reactive in state because closing a conversation rotates it
|
||||||
|
// and the composer's uploads send it as a header.
|
||||||
|
token: '',
|
||||||
|
// Localized site contact strings (pagerite:contact-* asset config,
|
||||||
|
// refreshed on every open — they change with the page language):
|
||||||
|
// label doubles as the visitor dialog's title; email gets a mailto
|
||||||
|
// link with subject prefilled.
|
||||||
|
label: 'Contact us',
|
||||||
|
email: '',
|
||||||
|
mailtoTo: '', // RFC-encoded "Brand <email>" recipient for the mailto href
|
||||||
|
subject: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// The localized contact strings ride the page's asset config (the
|
||||||
|
// production JSON block, or pagerite:* meta tags in dev).
|
||||||
|
function readContactStrings() {
|
||||||
|
let assets = {}
|
||||||
|
const el = document.getElementById('pagerite-assets')
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
assets = JSON.parse(el.textContent)
|
||||||
|
} catch { /* keep empty */ }
|
||||||
|
} else {
|
||||||
|
for (const m of document.querySelectorAll('meta[name^="pagerite:contact-"]')) {
|
||||||
|
assets[m.name] = m.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.label = assets['pagerite:contact-label'] || 'Contact us'
|
||||||
|
state.subject = assets['pagerite:contact-subject'] || state.label
|
||||||
|
// state.email is NOT reset here: it arrives over the socket after the
|
||||||
|
// PoW is solved and stays valid for the session.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page swaps (fetch navigation, editor re-renders after settings saves)
|
||||||
|
// carry fresh per-language config: keep the dialog strings in sync
|
||||||
|
// without reopening.
|
||||||
|
addEventListener('pagerite:page-fetched', readContactStrings)
|
||||||
|
|
||||||
|
// The panel is main content, not a sidebar: while open it holds paskia's
|
||||||
|
// shared blur backdrop (refcounted, z-index 1099; the overlay itself sits
|
||||||
|
// at 1100, below the auth iframe's 9999).
|
||||||
|
watch(() => state.open, (open) => {
|
||||||
|
if (open) holdGlobalBackdrop()
|
||||||
|
else releaseGlobalBackdrop()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => state.adminUnread, () => refreshBadges())
|
||||||
|
|
||||||
|
function ensureToken() {
|
||||||
|
let token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
if (!token) {
|
||||||
|
token = crypto.randomUUID()
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
function seenAt() {
|
||||||
|
return Number(localStorage.getItem(SEEN_KEY) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSeen() {
|
||||||
|
localStorage.setItem(SEEN_KEY, String(Date.now()))
|
||||||
|
state.unread = false
|
||||||
|
refreshBadges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoW (easypow, docs/messages.md): the server issues a fresh single-use
|
||||||
|
// challenge on registration/resync and whenever the quota runs out. The
|
||||||
|
// solver runs in the background and the solution is submitted as soon as
|
||||||
|
// it is ready; a validated proof grants a quota of messages and uploads
|
||||||
|
// and reveals the site's contact email (never carried in the page's
|
||||||
|
// asset config). The solver module is served by the backend from the
|
||||||
|
// easypow package, so both sides always match.
|
||||||
|
const SOLVER_URL = '/_contact/easypow.js' // backend-served; kept in a variable so Rollup leaves the import dynamic
|
||||||
|
let bridge = null
|
||||||
|
let solverPromise = null
|
||||||
|
let powSolution = null // Promise<string> solving the current challenge
|
||||||
|
let powAborter = null
|
||||||
|
let unsent = null // last message frame, retried once when its send races the quota
|
||||||
|
let quotaWaiters = [] // uploads waiting for a re-granted quota
|
||||||
|
|
||||||
|
// The solved solution goes out immediately, ahead of any send.
|
||||||
|
async function submitProof() {
|
||||||
|
const p = powSolution
|
||||||
|
if (!p) return
|
||||||
|
try {
|
||||||
|
const pow = await p
|
||||||
|
if (pow && p === powSolution) bridge.send({ token: state.token, pow })
|
||||||
|
} catch { /* aborted: a newer solve is in flight */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(md, name = '', email = '') {
|
||||||
|
// The first message of a fresh thread doubles as the
|
||||||
|
// notification-permission gesture (denied is never re-asked; a
|
||||||
|
// dismissed prompt comes up again on the next fresh thread).
|
||||||
|
if (!localStorage.getItem(SESSION_KEY)) askNotifyPermission()
|
||||||
|
unsent = { md, name, email, retried: false }
|
||||||
|
bridge.send({ token: state.token, msg: md, ...(name && { name, email }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPow(msg) {
|
||||||
|
if (typeof msg.quota === 'number') {
|
||||||
|
// Proof accepted, quota granted: queued uploads and the racing send
|
||||||
|
// (rejected for a stale/empty quota) continue.
|
||||||
|
const waiters = quotaWaiters
|
||||||
|
quotaWaiters = []
|
||||||
|
for (const resolve of waiters) resolve(true)
|
||||||
|
if (unsent && !unsent.retried) {
|
||||||
|
const { md, name, email } = unsent
|
||||||
|
unsent.retried = true
|
||||||
|
bridge.send({ token: state.token, msg: md, ...(name && { name, email }) })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
powAborter?.abort() // superseded challenge: stop the stale solve
|
||||||
|
const ac = (powAborter = new AbortController())
|
||||||
|
solverPromise ??= import(/* @vite-ignore */ SOLVER_URL)
|
||||||
|
solverPromise.then(({ solve }) => {
|
||||||
|
powSolution = solve(msg.challenge, ac.signal)
|
||||||
|
powSolution.catch(() => {}) // aborts are expected
|
||||||
|
submitProof()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEmail(msg) {
|
||||||
|
state.email = msg.email || ''
|
||||||
|
state.mailtoTo = msg.to || '' // RFC-encoded "Brand <email>" recipient
|
||||||
|
}
|
||||||
|
|
||||||
|
// An upload hit the end of the PoW quota: re-register (the server pushes
|
||||||
|
// a fresh challenge, solved and proven in the background) and resolve
|
||||||
|
// once the new quota lands.
|
||||||
|
function requestQuota() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
quotaWaiters.push(resolve)
|
||||||
|
bridge.send({ token: state.token }) // re-registration: a fresh challenge follows
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread markers: the has-unread class (a periodic nudge animation, see
|
||||||
|
// pagerite.css) on every contact trigger button and on the corner ✉️ icon
|
||||||
|
// — a new admin reply for visitors, any unanswered thread for admins.
|
||||||
|
// Called on thread pushes and (from pagerite.js) after navigations, whose
|
||||||
|
// swap rebuilds the buttons.
|
||||||
|
export function refreshBadges() {
|
||||||
|
for (const btn of document.querySelectorAll('.contact-trigger')) {
|
||||||
|
btn.classList.toggle('has-unread', state.unread)
|
||||||
|
}
|
||||||
|
const icon = document.getElementById('contact-icon')
|
||||||
|
if (!icon) return
|
||||||
|
icon.classList.toggle('has-unread', state.viewerAdmin ? state.adminUnread > 0 : state.unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
let notifyBaseline = '' // the first push after load is history, only baselines
|
||||||
|
|
||||||
|
function onThread(thread) {
|
||||||
|
state.thread = thread
|
||||||
|
unsent = null // the send landed (a thread push acknowledges it)
|
||||||
|
// A thread with messages is a live session: page loads resume checking
|
||||||
|
// it right away (cleared when the conversation is closed).
|
||||||
|
if (thread.messages.length) localStorage.setItem(SESSION_KEY, '1')
|
||||||
|
const lastAdmin = [...thread.messages].reverse().find((m) => m.admin)
|
||||||
|
if (lastAdmin) localStorage.setItem(LASTADMIN_KEY, lastAdmin.t)
|
||||||
|
const viewing = state.open && state.mode === 'visitor'
|
||||||
|
state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !viewing
|
||||||
|
// ISO timestamps compare lexicographically.
|
||||||
|
if (lastAdmin && lastAdmin.t > notifyBaseline) {
|
||||||
|
const fresh = Boolean(notifyBaseline)
|
||||||
|
notifyBaseline = lastAdmin.t
|
||||||
|
if (fresh && !viewing) {
|
||||||
|
notifyMessage(state.label, messageText(lastAdmin.html), () => open(bridge))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (viewing) markSeen()
|
||||||
|
else refreshBadges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visitor ended the conversation: archive it server-side (the admin
|
||||||
|
// inbox keeps it), wipe the local session and rotate the token, so the
|
||||||
|
// next message starts an all-new thread. The panel stays open on the
|
||||||
|
// fresh empty state.
|
||||||
|
function closeConversation() {
|
||||||
|
if (!confirm('Close this conversation? A fresh thread starts with your next message.')) return
|
||||||
|
bridge.send({ token: state.token, close: true })
|
||||||
|
for (const key of [TOKEN_KEY, SEEN_KEY, LASTADMIN_KEY, SESSION_KEY]) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
state.token = ensureToken() // TOKEN_KEY is gone: a fresh random
|
||||||
|
state.thread = null
|
||||||
|
state.unread = false
|
||||||
|
notifyBaseline = ''
|
||||||
|
unsent = null
|
||||||
|
bridge.register(state.token, onThread, onPow, onEmail)
|
||||||
|
refreshBadges()
|
||||||
|
}
|
||||||
|
|
||||||
|
let registered = false
|
||||||
|
|
||||||
|
// Registration doubles as the resync: the server pushes the thread state
|
||||||
|
// back, and pagerite.js re-registers on every socket reconnect. Done once
|
||||||
|
// per page (also without opening the panel — resume()), in either mode —
|
||||||
|
// the visitor thread is also the admin's own (the trigger buttons open it
|
||||||
|
// for them).
|
||||||
|
function ensureRegistered(br) {
|
||||||
|
bridge = br
|
||||||
|
if (registered) return
|
||||||
|
registered = true
|
||||||
|
state.token = ensureToken()
|
||||||
|
bridge.register(state.token, onThread, onPow, onEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live session found at page load: check for replies immediately,
|
||||||
|
// without opening the panel (pagerite.js calls this when the session flag
|
||||||
|
// is set; with a granted notification permission new replies notify right
|
||||||
|
// away).
|
||||||
|
export function resume(br) {
|
||||||
|
ensureRegistered(br)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function open(br, { admin = false } = {}) {
|
||||||
|
ensureRegistered(br)
|
||||||
|
if (admin) state.viewerAdmin = true
|
||||||
|
if (!app) {
|
||||||
|
app = createApp(ContactOverlay, {
|
||||||
|
state,
|
||||||
|
reauthorize: requestQuota,
|
||||||
|
onSend: ({ md, name, email }) => sendMessage(md, name, email),
|
||||||
|
onClose: () => {
|
||||||
|
state.open = false
|
||||||
|
refreshBadges()
|
||||||
|
},
|
||||||
|
onCloseThread: closeConversation,
|
||||||
|
})
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.id = 'contact-root'
|
||||||
|
document.body.append(el)
|
||||||
|
app.mount(el)
|
||||||
|
}
|
||||||
|
state.mode = admin ? 'admin' : 'visitor'
|
||||||
|
readContactStrings()
|
||||||
|
state.open = true
|
||||||
|
if (!admin) markSeen()
|
||||||
|
else refreshBadges()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Admin watch -------------------------------------------------------------
|
||||||
|
// For editors, while messaging is enabled on the site: a background
|
||||||
|
// /_api/ws/analytics connection keeps the corner icon's badge live and
|
||||||
|
// notifies new visitor messages right away — the admin always checks,
|
||||||
|
// without waiting for the inbox to open (pagerite.js starts the watch at
|
||||||
|
// page load). While the inbox is open MessagesView owns its own socket and
|
||||||
|
// the notifications; the watch then only tracks the badge. Reconnect
|
||||||
|
// pacing and the auth-aware close handling are the shared reconnect.js
|
||||||
|
// policy, same as MessagesView's.
|
||||||
|
let watchWs = null
|
||||||
|
let watchStarted = false
|
||||||
|
let watchTimer = null
|
||||||
|
let watchEpoch = 0
|
||||||
|
let watchHiddenAt = 0
|
||||||
|
const watchReconnects = reconnectPolicy()
|
||||||
|
const watchThreads = new Map() // id -> thread payload (badge + notify baseline)
|
||||||
|
const WATCH_WAKE_MS = 30_000
|
||||||
|
|
||||||
|
function watchBadge() {
|
||||||
|
state.adminUnread = [...watchThreads.values()].filter(
|
||||||
|
(t) => t.unread && !t.closed
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchThread(t) {
|
||||||
|
const last = t.messages[t.messages.length - 1]
|
||||||
|
const prev = watchThreads.get(t.id)?.messages?.at(-1)?.id
|
||||||
|
watchThreads.set(t.id, t)
|
||||||
|
watchBadge()
|
||||||
|
// No notification for history (the first 'threads' push baselines), the
|
||||||
|
// admin's own replies, non-message updates (prev === last.id — or an
|
||||||
|
// unread:false push, e.g. a read marking for a thread beyond the
|
||||||
|
// baseline cap), closed threads — or while the inbox is open
|
||||||
|
// (MessagesView notifies then).
|
||||||
|
if (!last || last.admin || !t.unread || prev === last.id || t.closed) return
|
||||||
|
if (state.open && state.mode === 'admin') return
|
||||||
|
notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html), () =>
|
||||||
|
open(bridge, { admin: true })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchConnect() {
|
||||||
|
if (watchWs) return
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const ws = (watchWs = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`))
|
||||||
|
watchConnecting(ws, 'contact-watch')
|
||||||
|
ws.onopen = () => watchReconnects.opened()
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg = null
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg?.type === 'threads') {
|
||||||
|
watchThreads.clear()
|
||||||
|
for (const t of msg.threads) watchThreads.set(t.id, t)
|
||||||
|
watchBadge()
|
||||||
|
} else if (msg?.type === 'thread') {
|
||||||
|
watchThread(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onclose = async () => {
|
||||||
|
if (watchWs !== ws) return
|
||||||
|
watchWs = null
|
||||||
|
const gen = watchEpoch
|
||||||
|
const wait = watchReconnects.closed()
|
||||||
|
const outcome = await authReconnect()
|
||||||
|
if (gen !== watchEpoch) return
|
||||||
|
if (outcome === 'auth') watchConnect()
|
||||||
|
else if (outcome === 'retry') watchTimer = setTimeout(watchConnect, wait)
|
||||||
|
// 'abort': no session — the next auth-success restarts the watch.
|
||||||
|
}
|
||||||
|
ws.onerror = () => ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchConnectNow() {
|
||||||
|
watchEpoch++
|
||||||
|
clearTimeout(watchTimer)
|
||||||
|
if (watchWs) {
|
||||||
|
watchWs.onclose = null
|
||||||
|
watchWs.close()
|
||||||
|
watchWs = null
|
||||||
|
}
|
||||||
|
watchConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watchAdmin(br) {
|
||||||
|
bridge = br
|
||||||
|
if (watchStarted) return
|
||||||
|
watchStarted = true
|
||||||
|
watchTimer = setTimeout(watchConnect, socketSlot())
|
||||||
|
addEventListener('online', () => {
|
||||||
|
if (!watchWs) watchConnectNow()
|
||||||
|
})
|
||||||
|
addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
watchHiddenAt = Date.now()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A long-hidden tab's socket is a zombie: reconnect to catch up.
|
||||||
|
if (watchHiddenAt && Date.now() - watchHiddenAt > WATCH_WAKE_MS) watchConnectNow()
|
||||||
|
watchHiddenAt = 0
|
||||||
|
})
|
||||||
|
// A completed login (re)starts the watch at once; a logout stops it.
|
||||||
|
addEventListener('message', (ev) => {
|
||||||
|
const type = ev.data?.type
|
||||||
|
if (type === 'auth-success') {
|
||||||
|
if (!watchWs) watchConnectNow()
|
||||||
|
} else if (type === 'auth-logout') {
|
||||||
|
watchEpoch++
|
||||||
|
clearTimeout(watchTimer)
|
||||||
|
if (watchWs) {
|
||||||
|
watchWs.onclose = null
|
||||||
|
watchWs.close()
|
||||||
|
watchWs = null
|
||||||
|
}
|
||||||
|
watchThreads.clear()
|
||||||
|
state.adminUnread = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Browser notifications for contact messaging (docs/messages.md): the
|
||||||
|
// visitor is notified of admin replies, the admin of new visitor
|
||||||
|
// messages. Permission is requested at one entry point only — right
|
||||||
|
// after the first message of a fresh thread (the Notification API
|
||||||
|
// requires a user gesture). A denied permission is permanent in the
|
||||||
|
// browser, so we never re-ask; a dismissed prompt stays "default" and is
|
||||||
|
// asked again on the next fresh thread.
|
||||||
|
|
||||||
|
export function askNotifyPermission() {
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'default') return
|
||||||
|
Notification.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire a notification (a no-op without a granted permission). Clicking
|
||||||
|
// it focuses the site tab and runs onclick (opening the thread/inbox
|
||||||
|
// view), so the user reliably lands on what was new.
|
||||||
|
export function notifyMessage(title, body, onclick) {
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted') return
|
||||||
|
const n = new Notification(title, { body, icon: '/favicon.ico' })
|
||||||
|
n.onclick = () => {
|
||||||
|
window.focus()
|
||||||
|
onclick?.()
|
||||||
|
n.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visible text of a server-rendered strict-markdown message (safe to
|
||||||
|
// parse: the stored html is markdown-it output, never user markup).
|
||||||
|
export function messageText(html) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = html
|
||||||
|
return (div.textContent || '').trim()
|
||||||
|
}
|
||||||
+223
-41
@@ -182,6 +182,37 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The corner ✉️ (for everyone): opens the messaging overlay — the
|
||||||
|
// visitor's own thread, or the admin inbox for editors (the {contact:}
|
||||||
|
// trigger buttons always open the visitor side, so admins can test it).
|
||||||
|
// The unread marker is seeded without any socket: for visitors from
|
||||||
|
// localStorage (the newest admin reply vs. the last look, both written
|
||||||
|
// by the contact bundle), for editors from the /_api/settings probe's
|
||||||
|
// contact_unread. Once the bundle has loaded, its refreshBadges keeps
|
||||||
|
// the marker live.
|
||||||
|
let adminUnreadSeed = 0;
|
||||||
|
// Messaging disabled in the site settings (no pagerite:contact-label in
|
||||||
|
// the asset config): no icon for visitors — admins keep theirs, the
|
||||||
|
// inbox still holds the existing threads.
|
||||||
|
const contactEnabled = !!assets["pagerite:contact-label"];
|
||||||
|
function makeContactIcon(admin) {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.id = "contact-icon";
|
||||||
|
b.className = "contact-icon icon-btn";
|
||||||
|
b.title = admin ? "messages" : (assets["pagerite:contact-label"] || "contact us");
|
||||||
|
b.textContent = "✉️";
|
||||||
|
if (admin) {
|
||||||
|
if (adminUnreadSeed > 0) b.classList.add("has-unread");
|
||||||
|
} else {
|
||||||
|
const seen = Number(localStorage.getItem("pagerite-contact-seen") || 0);
|
||||||
|
const last = localStorage.getItem("pagerite-contact-lastadmin") || "";
|
||||||
|
if (last && Date.parse(last) > seen) b.classList.add("has-unread");
|
||||||
|
}
|
||||||
|
b.addEventListener("click", () => openContactPanel({ admin }));
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
// The banner top-right corner container: the language selector (first
|
// The banner top-right corner container: the language selector (first
|
||||||
// item) plus the admin pens and auth links. renderAuthUi rebuilds it from
|
// item) plus the admin pens and auth links. renderAuthUi rebuilds it from
|
||||||
// scratch; the selector's state lives in the shared store, not the DOM,
|
// scratch; the selector's state lives in the shared store, not the DOM,
|
||||||
@@ -213,13 +244,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
||||||
// Paskia SSO is detected at all.
|
// Paskia SSO is detected at all.
|
||||||
const canEdit = isAdmin || !ssoAvailable;
|
const canEdit = isAdmin || !ssoAvailable;
|
||||||
// The analytics page is a read-only dashboard: editing pens and the side
|
// The analytics page is a read-only dashboard: editing pens and the
|
||||||
// panel do not apply there. Login/logout links are still useful.
|
// side panel do not apply there. Login/logout links are still useful.
|
||||||
const onAnalytics = currentPath === "/_a";
|
const onToolPage = currentPath === "/_a";
|
||||||
if (document.getElementById("page-banner")) {
|
if (document.getElementById("page-banner")) {
|
||||||
const pens = pensContainer();
|
const pens = pensContainer();
|
||||||
if (canEdit && !onAnalytics) {
|
// The contact icon is there for everyone — except visitors when
|
||||||
// Analytics viewer is now a normal page at /_a.
|
// messaging is disabled (admins keep the inbox).
|
||||||
|
if (canEdit || contactEnabled) pens.append(makeContactIcon(canEdit));
|
||||||
|
if (canEdit && !onToolPage) {
|
||||||
|
// Analytics viewer is a normal page at /_a.
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.className = "edit-link analytics-link icon-btn";
|
a.className = "edit-link analytics-link icon-btn";
|
||||||
a.href = "/_a";
|
a.href = "/_a";
|
||||||
@@ -231,7 +265,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
if (ssoAvailable) pens.append(makeAuthButton(isAdmin));
|
if (ssoAvailable) pens.append(makeAuthButton(isAdmin));
|
||||||
if (!pens.firstElementChild) pens.remove();
|
if (!pens.firstElementChild) pens.remove();
|
||||||
}
|
}
|
||||||
if (canEdit && !onAnalytics) injectPagePen();
|
if (canEdit && !onToolPage) injectPagePen();
|
||||||
}
|
}
|
||||||
// Re-mount the selector into the fresh container (no-op until the
|
// Re-mount the selector into the fresh container (no-op until the
|
||||||
// bundle has been loaded once).
|
// bundle has been loaded once).
|
||||||
@@ -263,8 +297,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
|
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
try {
|
try {
|
||||||
await fetchJson("/_api/settings");
|
const settings = await fetchJson("/_api/settings");
|
||||||
isAdmin = true;
|
isAdmin = true;
|
||||||
|
// Seeds the contact icon's admin badge (docs/messages.md).
|
||||||
|
adminUnreadSeed = settings.contact_unread || 0;
|
||||||
} catch {
|
} catch {
|
||||||
// Anonymous, no pagerite:admin, or no auth proxy / dev.
|
// Anonymous, no pagerite:admin, or no auth proxy / dev.
|
||||||
}
|
}
|
||||||
@@ -285,6 +321,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
renderAuthUi();
|
renderAuthUi();
|
||||||
placeEditPen();
|
placeEditPen();
|
||||||
pingEntryOnce();
|
pingEntryOnce();
|
||||||
|
contactAutoStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returning to the page via history back/forward may restore a cached
|
// Returning to the page via history back/forward may restore a cached
|
||||||
@@ -293,6 +330,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
if (ev.persisted) setupAuth();
|
if (ev.persisted) setupAuth();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// paskia's auth iframe announces its result as a window message: a login
|
||||||
|
// or logout completed anywhere in the page (e.g. the analytics view's
|
||||||
|
// login flow) re-probes auth and re-renders the pens. setupAuth is
|
||||||
|
// idempotent, so the profile button's own re-probe after the same login
|
||||||
|
// is a harmless duplicate.
|
||||||
|
addEventListener("message", (ev) => {
|
||||||
|
const type = ev.data?.type;
|
||||||
|
if (type === "auth-success" || type === "auth-logout") setupAuth();
|
||||||
|
});
|
||||||
|
|
||||||
function runScripts(root) {
|
function runScripts(root) {
|
||||||
// Scripts inserted via DOM swapping do not execute; re-create them.
|
// Scripts inserted via DOM swapping do not execute; re-create them.
|
||||||
for (const old of root.querySelectorAll("script")) {
|
for (const old of root.querySelectorAll("script")) {
|
||||||
@@ -446,6 +493,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
// Editor-driven swaps don't go through load(): re-evaluate the
|
// Editor-driven swaps don't go through load(): re-evaluate the
|
||||||
// language selector from the fresh copy too.
|
// language selector from the fresh copy too.
|
||||||
mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html"));
|
mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html"));
|
||||||
|
contactMod?.refreshBadges();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editors mutate site-wide state (theme, structure, headings, banners),
|
// Editors mutate site-wide state (theme, structure, headings, banners),
|
||||||
@@ -619,6 +667,22 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
wsPolicy.opened();
|
wsPolicy.opened();
|
||||||
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
|
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
|
||||||
|
// Re-registering resyncs the contact thread after a reconnect.
|
||||||
|
if (contactToken) ws.send(JSON.stringify({ token: contactToken }));
|
||||||
|
};
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
// Contact thread pushes ride this socket (docs/messages.md).
|
||||||
|
if (!contactListener) return;
|
||||||
|
let msg = null;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg?.type === "thread") contactListener(msg);
|
||||||
|
else if (msg?.type === "pow") contactPowListener?.(msg);
|
||||||
|
else if (msg?.type === "contact-email") contactEmailListener?.(msg);
|
||||||
|
else if (msg?.type === "contact-error") console.warn("contact:", msg.detail);
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
ws = null;
|
ws = null;
|
||||||
@@ -630,7 +694,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
|
|
||||||
function report(msg) {
|
function report(msg) {
|
||||||
if (document.body.classList.contains("editing")) return;
|
if (document.body.classList.contains("editing")) return;
|
||||||
if (msg.to === "/_a") return;
|
if (isToolPage(msg.to)) return;
|
||||||
if (ssoAvailable && isAdmin) msg.hide = true;
|
if (ssoAvailable && isAdmin) msg.hide = true;
|
||||||
activityWs();
|
activityWs();
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
@@ -643,9 +707,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ping({ to, fr = currentPath, read = 0, lang } = {}) {
|
function ping({ to, fr = currentPath, read = 0, lang } = {}) {
|
||||||
// Reading-time updates from the analytics page itself are not tracked
|
// Reading-time updates from the tool pages themselves are not tracked
|
||||||
// (/_a is admin machinery; the server would reject the path anyway).
|
// (admin machinery; the server would reject the path anyway).
|
||||||
if (!to && currentPath === "/_a") return;
|
if (!to && isToolPage(currentPath)) return;
|
||||||
const msg = {};
|
const msg = {};
|
||||||
if (fr) msg.fr = fr;
|
if (fr) msg.fr = fr;
|
||||||
if (to) msg.to = to;
|
if (to) msg.to = to;
|
||||||
@@ -674,16 +738,34 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
let reading = true;
|
let reading = true;
|
||||||
let readInactivityTimer = null;
|
let readInactivityTimer = null;
|
||||||
let idleTimer = null;
|
let idleTimer = null;
|
||||||
|
let lastActivityAt = performance.now();
|
||||||
|
|
||||||
function markReadActivity() {
|
function markReadActivity() {
|
||||||
if (!reading) {
|
const t = performance.now();
|
||||||
|
if (reading) {
|
||||||
|
// A gap longer than the inactivity timeout means the cutoff timer
|
||||||
|
// could not run on time (the computer slept, or the tab was throttled
|
||||||
|
// hard): close the span at its intended cutoff — the gap itself was
|
||||||
|
// not reading time, and performance.now() keeps running through sleep
|
||||||
|
// on some platforms.
|
||||||
|
const cutoff = lastActivityAt + INACTIVE_MS;
|
||||||
|
if (t > cutoff) {
|
||||||
|
readElapsed += Math.max(0, cutoff - readStart);
|
||||||
|
readStart = t;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
reading = true;
|
reading = true;
|
||||||
readStart = performance.now();
|
readStart = t;
|
||||||
}
|
}
|
||||||
|
lastActivityAt = t;
|
||||||
clearTimeout(readInactivityTimer);
|
clearTimeout(readInactivityTimer);
|
||||||
|
// Same sleep guard, other ordering: when this timer itself fires late
|
||||||
|
// (waking from sleep before any user event), clamp the accumulation to
|
||||||
|
// its intended fire time.
|
||||||
|
const stopAt = t + INACTIVE_MS;
|
||||||
readInactivityTimer = setTimeout(() => {
|
readInactivityTimer = setTimeout(() => {
|
||||||
if (reading) {
|
if (reading) {
|
||||||
readElapsed += performance.now() - readStart;
|
readElapsed += Math.max(0, Math.min(performance.now(), stopAt) - readStart);
|
||||||
reading = false;
|
reading = false;
|
||||||
}
|
}
|
||||||
}, INACTIVE_MS);
|
}, INACTIVE_MS);
|
||||||
@@ -746,27 +828,39 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
ping({ to: currentPath, fr: "" });
|
ping({ to: currentPath, fr: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Analytics page mount/unmount --------------------------------------
|
// --- Admin tool pages mount/unmount ------------------------------------
|
||||||
// The analytics page is a normal page whose body is rendered by the server
|
// /_a (analytics) is a normal page whose body is rendered by the server
|
||||||
// but whose content is a Vue app. In dev the entry module is imported from
|
// but whose content is a Vue app. In dev the
|
||||||
// the Vite dev server on demand; in production it is inlined into the /_a
|
// entry module is imported from the Vite dev server on demand; in
|
||||||
// page as script#pagerite-js-analytics, which a fetch-navigation swap does
|
// production it is inlined into the page as a script element, which a
|
||||||
// not execute — re-create the element so the fresh module auto-mounts on
|
// fetch-navigation swap does not execute — re-create the element so the
|
||||||
// #analytics-app (see analytics-main.js). The module exposes its unmount
|
// fresh module auto-mounts on its container. Each module exposes its
|
||||||
// as window.__pageriteAnalyticsUnmount.
|
// unmount as a window global (see analytics-main.js).
|
||||||
function teardownAnalytics() {
|
const TOOL_PAGES = {
|
||||||
// Remove even the server-rendered script element so a later return to
|
"/_a": { container: "analytics-app", script: "pagerite-js-analytics",
|
||||||
// /_a re-mounts from a fresh copy (the module has torn itself down).
|
asset: "pagerite:analytics-src", unmount: "__pageriteAnalyticsUnmount" },
|
||||||
document.getElementById("pagerite-js-analytics")?.remove();
|
};
|
||||||
window.__pageriteAnalyticsUnmount?.();
|
|
||||||
window.__pageriteAnalyticsUnmount = null;
|
function isToolPage(path) {
|
||||||
|
return Object.hasOwn(TOOL_PAGES, path || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mountAnalytics(doc) {
|
function teardownTools() {
|
||||||
if (!doc.getElementById("analytics-app")) return;
|
// Remove even the server-rendered script element so a later return
|
||||||
// Already mounted: on a full /_a load the inline script has run.
|
// re-mounts from a fresh copy (the module has torn itself down).
|
||||||
if (document.getElementById("pagerite-js-analytics")) return;
|
for (const p of Object.values(TOOL_PAGES)) {
|
||||||
const inline = doc.getElementById("pagerite-js-analytics");
|
document.getElementById(p.script)?.remove();
|
||||||
|
window[p.unmount]?.();
|
||||||
|
window[p.unmount] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountTool(doc) {
|
||||||
|
const page = Object.values(TOOL_PAGES).find((p) => doc.getElementById(p.container));
|
||||||
|
if (!page) return;
|
||||||
|
// Already mounted: on a full page load the inline script has run.
|
||||||
|
if (document.getElementById(page.script)) return;
|
||||||
|
const inline = doc.getElementById(page.script);
|
||||||
if (inline) {
|
if (inline) {
|
||||||
const s = document.createElement("script");
|
const s = document.createElement("script");
|
||||||
for (const a of inline.attributes) s.setAttribute(a.name, a.value);
|
for (const a of inline.attributes) s.setAttribute(a.name, a.value);
|
||||||
@@ -778,11 +872,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
// Dev: the cached module auto-mounts only on its first evaluation,
|
// Dev: the cached module auto-mounts only on its first evaluation,
|
||||||
// so call mount() explicitly for repeat visits (it no-ops when the
|
// so call mount() explicitly for repeat visits (it no-ops when the
|
||||||
// app is already up).
|
// app is already up).
|
||||||
const mod = await import(/* @vite-ignore */ assets["pagerite:analytics-src"]);
|
const mod = await import(/* @vite-ignore */ assets[page.asset]);
|
||||||
const container = document.getElementById("analytics-app");
|
const container = document.getElementById(page.container);
|
||||||
if (container) mod.mount(container);
|
if (container) mod.mount(container);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("analytics mount failed:", e);
|
console.error("tool page mount failed:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +935,91 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
ping({ to: currentPath, lang: tag });
|
ping({ to: currentPath, lang: tag });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Messaging overlay ---------------------------------------------------
|
||||||
|
// {contact: Label} directives in articles render .contact-trigger buttons,
|
||||||
|
// and the corner ✉️ icon (rendered by renderAuthUi for everyone) opens the
|
||||||
|
// same overlay: the visitor's thread, or the admin inbox for editors
|
||||||
|
// (docs/messages.md). The bundle (Vue overlay + composer) loads on first
|
||||||
|
// click and rides the activity socket for realtime thread state — no extra
|
||||||
|
// connection for visitors. Registration re-sends on every socket
|
||||||
|
// (re)connect, and the server pushes the thread back as the resync.
|
||||||
|
let contactMod = null;
|
||||||
|
let contactToken = "";
|
||||||
|
let contactListener = null;
|
||||||
|
let contactPowListener = null;
|
||||||
|
let contactEmailListener = null;
|
||||||
|
const contactBridge = {
|
||||||
|
register(token, onThread, onPow, onEmail) {
|
||||||
|
contactToken = token;
|
||||||
|
contactListener = onThread;
|
||||||
|
contactPowListener = onPow;
|
||||||
|
contactEmailListener = onEmail;
|
||||||
|
contactBridge.send({ token });
|
||||||
|
},
|
||||||
|
send(msg) {
|
||||||
|
msg.fr = currentPath;
|
||||||
|
activityWs();
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
return;
|
||||||
|
} catch { /* fall through to queueing */ }
|
||||||
|
}
|
||||||
|
wsQueue.push(msg);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadContact() {
|
||||||
|
contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]);
|
||||||
|
for (const css of (assets["pagerite:contact-css"] || "").split(",")) {
|
||||||
|
if (css && !document.querySelector(`link[href="${css}"]`)) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = css;
|
||||||
|
link.dataset.pagerite = "contact-css";
|
||||||
|
document.head.append(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contactMod;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openContactPanel(opts) {
|
||||||
|
try {
|
||||||
|
(await loadContact()).open(contactBridge, opts);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("contact panel load failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message sessions follow the page load, not the panel: a visitor with
|
||||||
|
// an open conversation (the bundle's localStorage session flag) resumes
|
||||||
|
// checking for replies right away — with a granted notification
|
||||||
|
// permission new replies notify immediately; an admin's inbox watch
|
||||||
|
// (badge + notifications) runs whenever messaging is enabled.
|
||||||
|
async function contactAutoStart() {
|
||||||
|
if (!contactEnabled) return;
|
||||||
|
const wantAdmin = isAdmin;
|
||||||
|
const wantResume = !!localStorage.getItem("pagerite-contact-session");
|
||||||
|
if (!wantAdmin && !wantResume) return;
|
||||||
|
try {
|
||||||
|
const mod = await loadContact();
|
||||||
|
if (wantAdmin) mod.watchAdmin(contactBridge);
|
||||||
|
if (wantResume) mod.resume(contactBridge);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("contact session resume failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener("click", (ev) => {
|
||||||
|
const btn = ev.target.closest?.(".contact-trigger");
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
// In the editor preview the button is inert.
|
||||||
|
if (document.body.classList.contains("editing")) return;
|
||||||
|
// Always the visitor side, even for admins (testing both sides).
|
||||||
|
openContactPanel({ admin: false });
|
||||||
|
});
|
||||||
|
|
||||||
// --- Fetch navigation ------------------------------------------------
|
// --- Fetch navigation ------------------------------------------------
|
||||||
async function load(url, push = true, back = false) {
|
async function load(url, push = true, back = false) {
|
||||||
// Navigating with the editor open keeps it open: the shell retargets to
|
// Navigating with the editor open keeps it open: the shell retargets to
|
||||||
@@ -851,10 +1030,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
// bypassed while editing because the editor may have invalidated the
|
// bypassed while editing because the editor may have invalidated the
|
||||||
// prefetched copies of other pages.
|
// prefetched copies of other pages.
|
||||||
const editing = document.body.classList.contains("editing");
|
const editing = document.body.classList.contains("editing");
|
||||||
if (editing && new URL(url, location.href).pathname === "/_a") {
|
if (editing && isToolPage(new URL(url, location.href).pathname)) {
|
||||||
await editorModule?.then((m) => m.closeEditor({ navigating: true }));
|
await editorModule?.then((m) => m.closeEditor({ navigating: true }));
|
||||||
}
|
}
|
||||||
teardownAnalytics();
|
teardownTools();
|
||||||
let doc;
|
let doc;
|
||||||
let finalUrl = url;
|
let finalUrl = url;
|
||||||
const cached = !editing && pageCache.get(cacheKey(url));
|
const cached = !editing && pageCache.get(cacheKey(url));
|
||||||
@@ -948,8 +1127,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
runScripts(document.getElementById("page-banner"));
|
runScripts(document.getElementById("page-banner"));
|
||||||
runScripts(document.getElementById("main"));
|
runScripts(document.getElementById("main"));
|
||||||
applyEffects();
|
applyEffects();
|
||||||
mountAnalytics(doc);
|
mountTool(doc);
|
||||||
mountLangselect(doc);
|
mountLangselect(doc);
|
||||||
|
// The swap rebuilt any contact-trigger buttons: re-apply the
|
||||||
|
// "new reply" dot to the fresh elements.
|
||||||
|
contactMod?.refreshBadges();
|
||||||
};
|
};
|
||||||
// Rotating cube page transition (styles injected as #pagerite-transition
|
// Rotating cube page transition (styles injected as #pagerite-transition
|
||||||
// from the selected design's transition.css, e.g. themes/cube/);
|
// from the selected design's transition.css, e.g. themes/cube/);
|
||||||
@@ -1057,7 +1239,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
}
|
}
|
||||||
// Machinery and auth endpoints are never fetch-navigated, except the
|
// Machinery and auth endpoints are never fetch-navigated, except the
|
||||||
// public analytics viewer page at /_a.
|
// public analytics viewer page at /_a.
|
||||||
if ((url.pathname.startsWith("/_") && url.pathname !== "/_a")
|
if ((url.pathname.startsWith("/_") && !isToolPage(url.pathname))
|
||||||
|| url.pathname.startsWith("/auth")) return;
|
|| url.pathname.startsWith("/auth")) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
// Capture the source now: load() updates currentPath before pinging.
|
// Capture the source now: load() updates currentPath before pinging.
|
||||||
@@ -1177,6 +1359,6 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
|
|
||||||
setupAuth();
|
setupAuth();
|
||||||
applyEffects();
|
applyEffects();
|
||||||
mountAnalytics(document);
|
mountTool(document);
|
||||||
mountLangselect(document);
|
mountLangselect(document);
|
||||||
})();
|
})();
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
// (a few seconds, doubling to half a minute), reset only after a
|
// (a few seconds, doubling to half a minute), reset only after a
|
||||||
// connection stayed open long enough to count as healthy. A socket
|
// connection stayed open long enough to count as healthy. A socket
|
||||||
// that closes right after opening must NOT reset the backoff.
|
// that closes right after opening must NOT reset the backoff.
|
||||||
|
import { ApiError, NetworkError, apiJson, fetchJson } from 'paskia'
|
||||||
|
|
||||||
export function reconnectPolicy({ min = 2000, max = 30000, healthyAfter = 30000 } = {}) {
|
export function reconnectPolicy({ min = 2000, max = 30000, healthyAfter = 30000 } = {}) {
|
||||||
let delay = min
|
let delay = min
|
||||||
let openedAt = 0
|
let openedAt = 0
|
||||||
@@ -54,3 +56,36 @@ export function watchConnecting(ws, label) {
|
|||||||
}
|
}
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth-aware close handling for the admin sockets (/_api/...). An expired
|
||||||
|
// session fails the WS handshake exactly like a dead network, but retrying
|
||||||
|
// the socket loops forever — the WS API cannot open the login dialog. So
|
||||||
|
// probe /_api first (plain fetchJson, which never pops a dialog) and:
|
||||||
|
//
|
||||||
|
// 'retry' — the session is fine or the trouble is a network/server one:
|
||||||
|
// take the policy's paced backoff.
|
||||||
|
// 'auth' — the session was gone, so the paskia login flow ran (apiJson
|
||||||
|
// pops the auth iframe and retries through it) and completed:
|
||||||
|
// reconnect at once, no backoff.
|
||||||
|
// 'abort' — the login was declined or the proxy offers no paskia flow:
|
||||||
|
// stop retrying; the caller decides what its UI does next.
|
||||||
|
//
|
||||||
|
// onLogin fires when the login flow starts, so the caller can say so.
|
||||||
|
export async function authReconnect(onLogin) {
|
||||||
|
try {
|
||||||
|
await fetchJson('/_api/settings')
|
||||||
|
return 'retry'
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof ApiError && (e.status === 401 || e.status === 403))) return 'retry'
|
||||||
|
}
|
||||||
|
onLogin?.()
|
||||||
|
try {
|
||||||
|
await apiJson('/_api/settings')
|
||||||
|
return 'auth'
|
||||||
|
} catch (e) {
|
||||||
|
return e instanceof NetworkError
|
||||||
|
|| (e instanceof ApiError && e.status !== 401 && e.status !== 403)
|
||||||
|
? 'retry'
|
||||||
|
: 'abort'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Serialize the constrained contenteditable composer DOM to the contact
|
||||||
|
// message Markdown subset (emphasis, links, lists, code, /_f/ images and
|
||||||
|
// file chips). The wire format is text: the server re-renders it with a
|
||||||
|
// strict, html-less MarkdownIt (pagerite/messages.py), so no user markup
|
||||||
|
// of any kind ever reaches a page. This serializer is for fidelity, not
|
||||||
|
// safety — anything it does not know is simply unwrapped.
|
||||||
|
|
||||||
|
const ESCAPE_RE = /([\\`*_[\]])/g
|
||||||
|
|
||||||
|
export function escapeMd(text) {
|
||||||
|
return text.replace(ESCAPE_RE, '\\$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags that open a new block at the composer's top level. Everything else
|
||||||
|
// there — text, B/I/A/SPAN formatting elements, inline images — is an
|
||||||
|
// inline run and joins the paragraph being accumulated: browsers happily
|
||||||
|
// put formatting directly under the editable root (a bolded segment of an
|
||||||
|
// undivided first line is a top-level <b>), 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 ? `` : ''
|
||||||
|
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 <span style="font-weight: bold">): 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
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate"] }),
|
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate", "/_contact"] }),
|
||||||
vue(),
|
vue(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
],
|
],
|
||||||
@@ -51,6 +51,7 @@ export default defineConfig({
|
|||||||
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
||||||
analytics: fileURLToPath(new URL('./src/analytics-main.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)),
|
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
|
// Only the base CSS is built; theme/banner-design stylesheets live
|
||||||
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
||||||
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ class Msg(msgspec.Struct, omit_defaults=True):
|
|||||||
#: Rendered language reported by the client for the page the activity
|
#: Rendered language reported by the client for the page the activity
|
||||||
#: happened on.
|
#: happened on.
|
||||||
lang: str = ""
|
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):
|
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
|
#: page falling back to its GET's rendered language; "" when unknown
|
||||||
#: (old clients or data from before language recording).
|
#: (old clients or data from before language recording).
|
||||||
lang: str = ""
|
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):
|
class Visit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -711,6 +718,34 @@ class Store:
|
|||||||
self._save()
|
self._save()
|
||||||
return client_hash if new else None
|
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]:
|
def favicon_origins_needed(self) -> list[str]:
|
||||||
"""External https origins seen in the raw events whose favicon needs
|
"""External https origins seen in the raw events whose favicon needs
|
||||||
fetching.
|
fetching.
|
||||||
@@ -775,6 +810,9 @@ class Store:
|
|||||||
rendered language), and a page re-visited in a different rendered
|
rendered language), and a page re-visited in a different rendered
|
||||||
language becomes a distinct trail step. The entry referer and
|
language becomes a distinct trail step. The entry referer and
|
||||||
UTM tags come from the GET that loaded the entry page.
|
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.
|
Hidden (admin) clients are excluded from every list and aggregate.
|
||||||
``multilingual`` and ``primary_lang`` are site context (translation
|
``multilingual`` and ``primary_lang`` are site context (translation
|
||||||
@@ -871,6 +909,19 @@ class Store:
|
|||||||
visit: Visit | None = None
|
visit: Visit | None = None
|
||||||
last_t: datetime | None = None
|
last_t: datetime | None = None
|
||||||
for m in msgs:
|
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 m.to:
|
||||||
if visit is None or (
|
if visit is None or (
|
||||||
last_t is not None and m.t - last_t > _SESSION_GAP
|
last_t is not None and m.t - last_t > _SESSION_GAP
|
||||||
|
|||||||
+94
-20
@@ -23,8 +23,8 @@ from fastapi import (
|
|||||||
from html5tagger import E
|
from html5tagger import E
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from pagerite import i18n, views
|
from pagerite import i18n, messages, views
|
||||||
from pagerite.chunks import store_chunks
|
from pagerite.chunks import chunk_key, store_chunks
|
||||||
from pagerite.data import (
|
from pagerite.data import (
|
||||||
Node,
|
Node,
|
||||||
append_order,
|
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
|
Nested by slug; each node carries its full path, menu order, flags and
|
||||||
language settings (``language`` is the node's own primary-language
|
language settings (``language`` is the node's own primary-language
|
||||||
setting, "" = inherit; ``primary`` is the resolved effective one).
|
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
|
With a ``?lang=`` translation, titles come out in that language where a
|
||||||
translation exists (``translated`` flags it — true trivially for rows
|
translation exists (``translated`` flags it — true trivially for rows
|
||||||
whose primary language IS the selected one; other rows fall back to
|
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,
|
"order": node.order,
|
||||||
"published": node.published,
|
"published": node.published,
|
||||||
"has_content": node.chunks is not None,
|
"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,
|
"language": node.language,
|
||||||
"primary": primary,
|
"primary": primary,
|
||||||
"children": dump(node.children, path, primary),
|
"children": dump(node.children, path, primary),
|
||||||
@@ -268,12 +276,18 @@ async def update_structure(op: StructureOp, request: Request) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/_api/settings")
|
@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
|
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
||||||
the themes, banner designs and user fonts available on disk for the
|
the themes, banner designs and user fonts available on disk for the
|
||||||
selectors, the translator service keys and the wanted translation
|
selectors, the translator service keys and the wanted translation
|
||||||
languages (for the /_translate socket)."""
|
languages (for the /_translate socket), and the contact-messaging
|
||||||
return {
|
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": data.brand,
|
||||||
"brand_html": data.brand_html,
|
"brand_html": data.brand_html,
|
||||||
"theme": data.theme,
|
"theme": data.theme,
|
||||||
@@ -285,11 +299,27 @@ async def get_settings() -> dict:
|
|||||||
"transition": data.transition,
|
"transition": data.transition,
|
||||||
"transitions": views._transition_names(),
|
"transitions": views._transition_names(),
|
||||||
"translate_keys": data.translate_keys,
|
"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
|
# The site default primary language: the front page's resolved
|
||||||
# setting (every page may override it, inherited down the tree).
|
# setting (every page may override it, inherited down the tree).
|
||||||
"primary_lang": i18n.primary_lang(data.menu, ""),
|
"primary_lang": i18n.primary_lang(data.menu, ""),
|
||||||
"translate_langs": sorted(data.translate_langs),
|
"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):
|
class SettingsIn(BaseModel):
|
||||||
@@ -302,17 +332,54 @@ class SettingsIn(BaseModel):
|
|||||||
transition: str = "cube"
|
transition: str = "cube"
|
||||||
translate_langs: list[str] | None = None # None keeps the current set
|
translate_langs: list[str] | None = None # None keeps the current set
|
||||||
translate_keys: dict[str, str] | None = None # None keeps the current keys
|
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)
|
@router.put("/_api/settings", status_code=204)
|
||||||
async def put_settings(settings: SettingsIn, request: Request) -> None:
|
async def put_settings(
|
||||||
"""Update site-wide settings; invalidates cached pages and ETags."""
|
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")):
|
with kanta.transaction("settings", user=request.headers.get("remote-user")):
|
||||||
data.brand = settings.brand
|
data.brand = settings.brand
|
||||||
data.brand_html = settings.brand_html
|
data.brand_html = settings.brand_html
|
||||||
data.theme = settings.theme
|
data.theme = settings.theme
|
||||||
data.custom_css = settings.custom_css
|
data.custom_css = settings.custom_css
|
||||||
data.transition = settings.transition
|
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:
|
if settings.translate_langs is not None:
|
||||||
# Any language may be a target — including the site default
|
# Any language may be a target — including the site default
|
||||||
# (an article in another language can be translated INTO it);
|
# (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", "")
|
markdown = msg.get("markdown", "")
|
||||||
chain = resolve(data.menu, path)
|
chain = resolve(data.menu, path)
|
||||||
node = chain[-1] if chain else None
|
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
|
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(
|
rendered = render(
|
||||||
markdown,
|
markdown,
|
||||||
path,
|
path,
|
||||||
@@ -557,15 +639,7 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
if node
|
if node
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
directives=(
|
directives=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
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
html = rendered.html
|
html = rendered.html
|
||||||
if node is not None and not has_cards_tag:
|
if node is not None and not has_cards_tag:
|
||||||
|
|||||||
+6
-2
@@ -11,6 +11,9 @@ The routes live in specialized modules, included below as APIRouters:
|
|||||||
(``/_api/*``, ``/_translate/{clientkey}``).
|
(``/_api/*``, ``/_translate/{clientkey}``).
|
||||||
- ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``,
|
- ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``,
|
||||||
the ``/_a`` viewer page).
|
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``,
|
- ``pagerite.feeds`` — machine-readable site exports: ``/llms.txt``,
|
||||||
``/feed.json`` (JSON Feed) and ``/feed.xml`` (RSS).
|
``/feed.json`` (JSON Feed) and ``/feed.xml`` (RSS).
|
||||||
- ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``,
|
- ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``,
|
||||||
@@ -41,7 +44,7 @@ from fastapi.responses import Response
|
|||||||
from fastapi_vue import Frontend, env
|
from fastapi_vue import Frontend, env
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
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.files import file_store
|
||||||
from pagerite.state import analytics_store, config, kanta
|
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
|
# 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(api.router)
|
||||||
app.include_router(tracking.router)
|
app.include_router(tracking.router)
|
||||||
|
app.include_router(messages.router)
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
|
|
||||||
# Vue build asset routes are inserted at this position during load(): the
|
# Vue build asset routes are inserted at this position during load(): the
|
||||||
|
|||||||
@@ -50,6 +50,50 @@ class LangEdits(msgspec.Struct, omit_defaults=True):
|
|||||||
adds: dict[str, str] = {}
|
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):
|
class Node(msgspec.Struct, omit_defaults=True):
|
||||||
"""One item of the site hierarchy.
|
"""One item of the site hierarchy.
|
||||||
|
|
||||||
@@ -167,6 +211,27 @@ class Data(msgspec.Struct):
|
|||||||
#: path -> lang -> LangEdits (paths without leading slash). Replaces
|
#: path -> lang -> LangEdits (paths without leading slash). Replaces
|
||||||
#: the old "patches" key (ignored on decode, discarding that data).
|
#: the old "patches" key (ignored on decode, discarding that data).
|
||||||
overrides: dict[str, dict[str, LangEdits]] = {}
|
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:
|
def node_markdown(data: Data, node: Node) -> str | None:
|
||||||
|
|||||||
+3
-2
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
- ``/llms.txt`` (llmstxt.org convention): a Markdown map of the site for
|
- ``/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
|
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.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)
|
- ``/feed.xml``: the same as RSS 2.0 (with an atom:link self reference)
|
||||||
for older feed readers.
|
for older feed readers.
|
||||||
@@ -78,7 +79,7 @@ def _iso(node: Node) -> str:
|
|||||||
def _render_llms(base: str) -> str:
|
def _render_llms(base: str) -> str:
|
||||||
lines = [f"# {data.brand}", "", "## Pages", ""]
|
lines = [f"# {data.brand}", "", "## Pages", ""]
|
||||||
for path, node in _articles():
|
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)
|
excerpt = _description(_body_html(node, base), 120)
|
||||||
suffix = f": {excerpt}" if excerpt else ""
|
suffix = f": {excerpt}" if excerpt else ""
|
||||||
lines.append(f"- [{node.title or path}]({url}){suffix}")
|
lines.append(f"- [{node.title or path}]({url}){suffix}")
|
||||||
|
|||||||
+69
-19
@@ -8,7 +8,11 @@ images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
|
|||||||
``<hash>.orig<ext>`` (never served). Routes: upload/delete under
|
``<hash>.orig<ext>`` (never served). Routes: upload/delete under
|
||||||
``/_api/files``, the favicon settings endpoints, the /favicon.ico
|
``/_api/files``, the favicon settings endpoints, the /favicon.ico
|
||||||
redirect to the configured icon, the ``/_f/`` server with
|
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 <img>/<video> subresource fetches) with
|
||||||
|
``nosniff``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -215,6 +219,20 @@ def store_image(
|
|||||||
return digest
|
return digest
|
||||||
|
|
||||||
|
|
||||||
|
def store_attachment(body: bytes) -> str:
|
||||||
|
"""Store a non-media upload as an inert ``<hash>.file`` blob.
|
||||||
|
|
||||||
|
The original extension is deliberately dropped on disk (a stored
|
||||||
|
``.exe``/``.html`` could otherwise be accidentally run from the
|
||||||
|
filesystem); the original name lives only in the serving URL
|
||||||
|
(``/_f/{hash}/{name}``), and ``.file`` blobs are served nowhere
|
||||||
|
else. Returns the store name.
|
||||||
|
"""
|
||||||
|
name = blake3.blake3(body).hexdigest()[:12] + ".file"
|
||||||
|
file_store.put(name, body)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
#: Pasted-URL image fetches (fetch_image) refuse bodies over this size.
|
#: Pasted-URL image fetches (fetch_image) refuse bodies over this size.
|
||||||
FETCH_MAXSIZE = 20 * 1024 * 1024
|
FETCH_MAXSIZE = 20 * 1024 * 1024
|
||||||
|
|
||||||
@@ -369,6 +387,20 @@ async def user_font_file(name: str, filename: str, request: Request) -> Response
|
|||||||
|
|
||||||
@router.get("/_f/{name}")
|
@router.get("/_f/{name}")
|
||||||
async def stored_file(name: str, request: Request) -> Response:
|
async def stored_file(name: str, request: Request) -> Response:
|
||||||
|
return _stored_response(name, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/_f/{name}/{filename}")
|
||||||
|
async def stored_file_download(name: str, filename: str, request: Request) -> Response:
|
||||||
|
"""The attachment URL shape. ``{name}`` identifies a ``{name}.file``
|
||||||
|
blob (inert on disk, never served without the filename segment);
|
||||||
|
``{filename}`` is the original upload name and lives only in this
|
||||||
|
URL — the browser names the download after the last path segment.
|
||||||
|
"""
|
||||||
|
return _stored_response(name, request, attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_response(name: str, request: Request, *, attachment: bool = False) -> Response:
|
||||||
"""Serve a file from the content-addressed store (immutable: the name
|
"""Serve a file from the content-addressed store (immutable: the name
|
||||||
is its own hash, so cache forever). Bodies are served from the RAM
|
is its own hash, so cache forever). Bodies are served from the RAM
|
||||||
cache, zstd-compressed when the client accepts it and compression
|
cache, zstd-compressed when the client accepts it and compression
|
||||||
@@ -379,33 +411,51 @@ async def stored_file(name: str, request: Request) -> Response:
|
|||||||
only when the Accept header lists it explicitly — ``image/avif`` →
|
only when the Accept header lists it explicitly — ``image/avif`` →
|
||||||
AVIF, ``image/webp`` → WebP, anything else (including ``image/*`` and
|
AVIF, ``image/webp`` → WebP, anything else (including ``image/*`` and
|
||||||
``*/*``) → JPEG. An explicit extension pins the format. ``.orig.``
|
``*/*``) → JPEG. An explicit extension pins the format. ``.orig.``
|
||||||
originals are internal (they may carry EXIF data) and never served."""
|
originals are internal (they may carry EXIF data) and never served,
|
||||||
|
and ``.file`` attachment blobs are only served through the
|
||||||
|
filename-carrying URL (stored_file_download), never by hash.
|
||||||
|
|
||||||
|
Every response is ``content-disposition: attachment`` with
|
||||||
|
``nosniff``: subresource fetches (<img>, <video>, favicons) ignore
|
||||||
|
the disposition, but a direct navigation always downloads and the
|
||||||
|
body is never sniffed into something executable in this origin."""
|
||||||
if ".orig." in name:
|
if ".orig." in name:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
etag = name
|
etag = name
|
||||||
vary = ""
|
vary = ""
|
||||||
entry = file_store.get(name)
|
if attachment:
|
||||||
if entry is None and "." not in name:
|
etag = f"{name}.file"
|
||||||
# Extension-less image link: negotiate avif/webp/jpg by Accept.
|
entry = file_store.get(etag)
|
||||||
vary = "accept"
|
else:
|
||||||
accept = request.headers.get("accept", "")
|
if name.endswith(".file"):
|
||||||
if "image/avif" in accept:
|
raise HTTPException(404)
|
||||||
order = ("avif", "webp", "jpg")
|
entry = file_store.get(name)
|
||||||
elif "image/webp" in accept:
|
if entry is None and "." not in name:
|
||||||
order = ("webp", "jpg", "avif")
|
# Extension-less image link: negotiate avif/webp/jpg by Accept.
|
||||||
else:
|
vary = "accept"
|
||||||
order = ("jpg", "webp", "avif")
|
accept = request.headers.get("accept", "")
|
||||||
for ext in order:
|
if "image/avif" in accept:
|
||||||
etag = f"{name}.{ext}"
|
order = ("avif", "webp", "jpg")
|
||||||
entry = file_store.get(etag)
|
elif "image/webp" in accept:
|
||||||
if entry is not None:
|
order = ("webp", "jpg", "avif")
|
||||||
break
|
else:
|
||||||
|
order = ("jpg", "webp", "avif")
|
||||||
|
for ext in order:
|
||||||
|
etag = f"{name}.{ext}"
|
||||||
|
entry = file_store.get(etag)
|
||||||
|
if entry is not None:
|
||||||
|
break
|
||||||
if entry is None:
|
if entry is None:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304)
|
return Response(status_code=304)
|
||||||
body, compressed = entry
|
body, compressed = entry
|
||||||
headers = {"etag": etag, "cache-control": "public, max-age=31536000, immutable"}
|
headers = {
|
||||||
|
"etag": etag,
|
||||||
|
"cache-control": "public, max-age=31536000, immutable",
|
||||||
|
"content-disposition": "attachment",
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
}
|
||||||
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
|
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
|
||||||
headers["content-encoding"] = "zstd"
|
headers["content-encoding"] = "zstd"
|
||||||
vary = f"{vary}, accept-encoding".lstrip(", ")
|
vary = f"{vary}, accept-encoding".lstrip(", ")
|
||||||
|
|||||||
@@ -407,6 +407,36 @@ def set_title_translation(data: Data, node: Node, lang: str, title: str) -> bool
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def site_string(data: Data, lang: str, text: str) -> str:
|
||||||
|
"""A site-level settings string (not article content — the contact
|
||||||
|
label/subject) in ``lang``: its translation from the trans store,
|
||||||
|
chunk-keyed like titles (the translator service picks these up via
|
||||||
|
pending_items), falling back to the original. The site default
|
||||||
|
language always gets the original."""
|
||||||
|
if not text or not lang or lang == primary_lang(data.menu, ""):
|
||||||
|
return text
|
||||||
|
return data.trans.get(chunk_key(text), {}).get(lang) or text
|
||||||
|
|
||||||
|
|
||||||
|
def set_string_translation(data: Data, lang: str, original: str, text: str) -> bool:
|
||||||
|
"""Record (or drop) a site-string translation (i18n.site_string): a
|
||||||
|
fragment in ``Data.trans`` keyed by the original's chunk hash — the
|
||||||
|
same storage machine translations use, overriding them. Empty or the
|
||||||
|
original's text drops the override. Returns True when anything changed.
|
||||||
|
Pure data ops — the caller wraps in a transaction and invalidates."""
|
||||||
|
key = chunk_key(original)
|
||||||
|
current = data.trans.get(key, {}).get(lang)
|
||||||
|
if not text or text == original:
|
||||||
|
if current is None:
|
||||||
|
return False
|
||||||
|
del data.trans[key][lang]
|
||||||
|
return True
|
||||||
|
if current == text:
|
||||||
|
return False
|
||||||
|
data.trans.setdefault(key, {})[lang] = text
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def clear_translations(data: Data) -> None:
|
def clear_translations(data: Data) -> None:
|
||||||
"""Drop all machine translations (``Data.trans``) and rebuild the
|
"""Drop all machine translations (``Data.trans``) and rebuild the
|
||||||
availability index (``node.langs``) from the surviving user overrides —
|
availability index (``node.langs``) from the surviving user overrides —
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"""Visitor contact messages: storage, strict rendering, socket protocol.
|
||||||
|
|
||||||
|
Anonymous visitors open a panel from a ``{contact: Label}`` button in an
|
||||||
|
article (views._contact_tag). The panel rides the public /_ws activity
|
||||||
|
socket: tracking.activity_ws routes frames carrying a ``token`` field to
|
||||||
|
``visitor_frame``. The token (a localStorage random, generated on first
|
||||||
|
use) identifies the visitor's thread; only its blake3 hash is stored.
|
||||||
|
Admins watch and reply from the same overlay panel (the corner ✉️ icon
|
||||||
|
opens the inbox for them, the visitor thread for visitors) over the
|
||||||
|
analytics socket (tracking.analytics_websocket), admin-gated like the
|
||||||
|
rest of /_api; their frames go to ``admin_frame``. Both sides get
|
||||||
|
pushed full thread payloads (threads are small); there is no REST
|
||||||
|
polling anywhere.
|
||||||
|
|
||||||
|
Sends are proof-of-work gated (easypow): the /_ws handler
|
||||||
|
(tracking.activity_ws) issues a fresh single-use challenge on
|
||||||
|
registration/resync and whenever the quota runs out; the browser solves
|
||||||
|
it in the background (bundled solver, served at /_contact/easypow.js)
|
||||||
|
and submits the solution as soon as it is ready (``visitor_proven``),
|
||||||
|
which grants a quota of messages and uploads (``_quota``, RAM only,
|
||||||
|
keyed by token hash so the HTTP upload route shares it). The solution
|
||||||
|
is single-use — a fresh challenge is pushed when the quota hits zero,
|
||||||
|
which the client solves before continuing. A validated proof also
|
||||||
|
unlocks the site's contact email (sent over the socket, never in the
|
||||||
|
page's asset config) and records the visit's contact trail marker in
|
||||||
|
analytics. Uploads without quota are answered 429; the client re-proves
|
||||||
|
and retries.
|
||||||
|
|
||||||
|
Message bodies are a Markdown subset (emphasis, links, lists, code plus
|
||||||
|
/_f/ image and file references) serialized client-side from a constrained
|
||||||
|
contenteditable composer. They are rendered here with a strict MarkdownIt
|
||||||
|
instance: html=False, so not a single byte of user markup can reach a
|
||||||
|
page — the stored html is always markdown-it output from the constrained
|
||||||
|
grammar, never sanitized user HTML (sanitization is unwinnable; grammar
|
||||||
|
restriction is not). Links get rel="nofollow ugc noopener"; images are
|
||||||
|
limited to /_f/ store names (the upload endpoint is the only way in).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import blake3
|
||||||
|
import easypow
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from markdown_it import MarkdownIt
|
||||||
|
from markdown_it.common.utils import escapeHtml
|
||||||
|
|
||||||
|
from pagerite.data import ContactMessage, ContactThread
|
||||||
|
from pagerite.files import _ext, file_store, store_attachment, store_image
|
||||||
|
from pagerite.state import data, kanta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
#: Session-token shape: crypto-random from the browser (uuid or hex).
|
||||||
|
TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
|
||||||
|
|
||||||
|
#: Message source length cap (the rendered html is bounded by it too).
|
||||||
|
MAX_MESSAGE = 8000
|
||||||
|
|
||||||
|
#: Live sockets: visitor token hash -> panels, and every connected admin.
|
||||||
|
_visitor_ws: dict[bytes, set[WebSocket]] = {}
|
||||||
|
_admin_ws: set[WebSocket] = set()
|
||||||
|
|
||||||
|
#: Uploaded-but-unreferenced file digests per session (RAM only) — deleted
|
||||||
|
#: when the session's last socket closes. The PoW challenge state is a
|
||||||
|
#: per-connection local in the WS handler (tracking.activity_ws).
|
||||||
|
_pending_uploads: dict[bytes, set[str]] = {}
|
||||||
|
|
||||||
|
#: Remaining message/upload actions per session (token hash), granted by a
|
||||||
|
#: solved PoW challenge (RAM only — single worker; the challenge itself is
|
||||||
|
#: a per-connection local in tracking.activity_ws). Keyed by token hash
|
||||||
|
#: rather than connection so the HTTP upload route (/_contact/files)
|
||||||
|
#: shares the same budget, and a reconnect keeps it.
|
||||||
|
_quota: dict[bytes, int] = {}
|
||||||
|
|
||||||
|
#: easypow solving cost per challenge, the actions a solution grants
|
||||||
|
#: (messages and uploads alike), and the pending-upload quota.
|
||||||
|
POW_SECONDS = 5.0
|
||||||
|
POW_QUOTA = 10
|
||||||
|
MAX_PENDING_UPLOADS = 10
|
||||||
|
|
||||||
|
|
||||||
|
# --- Strict rendering -----------------------------------------------------
|
||||||
|
# A separate instance from markdown.py's page renderer: no raw HTML (the
|
||||||
|
# trusted-author passthrough would be an XSS hole here), no plugins, no
|
||||||
|
# typographer. markdown-it escapes all markup in the source and validates
|
||||||
|
# link protocols (javascript: etc. never become hrefs).
|
||||||
|
|
||||||
|
_md = MarkdownIt("default", {"html": False, "typographer": False, "breaks": True})
|
||||||
|
|
||||||
|
#: Images may only reference the content-addressed store (uploads land
|
||||||
|
#: there via /_contact/files); anything else collapses to its alt text.
|
||||||
|
_IMG_SRC = re.compile(r"/_f/[0-9a-f]{12}(?:\.[a-z0-9]+)?")
|
||||||
|
|
||||||
|
|
||||||
|
def _link_rule(self, tokens, idx, options, env):
|
||||||
|
"""Open external links in a new tab without window.opener; store-file
|
||||||
|
links (uploaded attachments) render as download chips."""
|
||||||
|
token = tokens[idx]
|
||||||
|
href = token.attrs.get("href", "")
|
||||||
|
if href.startswith("/_f/"):
|
||||||
|
token.attrSet("class", "attachment")
|
||||||
|
token.attrSet("download", "")
|
||||||
|
else:
|
||||||
|
token.attrSet("rel", "nofollow ugc noopener")
|
||||||
|
token.attrSet("target", "_blank")
|
||||||
|
return self.renderToken(tokens, idx, options, env)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_rule(self, tokens, idx, options, env):
|
||||||
|
"""Drop images that do not point at a /_f/ store file (escaped alt)."""
|
||||||
|
token = tokens[idx]
|
||||||
|
if not _IMG_SRC.fullmatch(token.attrs.get("src", "")):
|
||||||
|
return escapeHtml(self.renderInlineAsText(token.children, options, env))
|
||||||
|
token.attrSet("loading", "lazy")
|
||||||
|
return self.renderToken(tokens, idx, options, env)
|
||||||
|
|
||||||
|
|
||||||
|
_md.add_render_rule("link_open", _link_rule)
|
||||||
|
_md.add_render_rule("image", _image_rule)
|
||||||
|
|
||||||
|
|
||||||
|
def render_message(text: str) -> str:
|
||||||
|
"""Render message-source Markdown to thread HTML (the only HTML a
|
||||||
|
visitor's message ever becomes)."""
|
||||||
|
return _md.render(text)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Thread store ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def thread_key(token: str) -> bytes:
|
||||||
|
"""The Data.contact key for a session token (hash, never the token)."""
|
||||||
|
return blake3.blake3(token.encode()).digest(12)
|
||||||
|
|
||||||
|
|
||||||
|
def _new_id(now: datetime) -> str:
|
||||||
|
"""Sortable message id: timestamp plus a random tiebreak."""
|
||||||
|
return f"{now:%Y%m%d%H%M%S%f}-{secrets.token_hex(3)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _unread(thread: ContactThread) -> bool:
|
||||||
|
"""Admin perspective: the last message is the visitor's and newer than
|
||||||
|
the admin's last read."""
|
||||||
|
for mid in reversed(thread.messages):
|
||||||
|
m = thread.messages[mid]
|
||||||
|
if m.admin:
|
||||||
|
return False
|
||||||
|
return thread.admin_read is None or m.t > thread.admin_read
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(key: bytes, thread: ContactThread) -> dict:
|
||||||
|
"""Full thread state for the wire (same shape for visitors and admins;
|
||||||
|
clients replace/upsert by id). Messages are capped to the last 200.
|
||||||
|
Admin messages are labeled with the site name as currently configured
|
||||||
|
(not the stored SSO name, which stays in the database for audit)."""
|
||||||
|
brand = data.brand or "admin"
|
||||||
|
return {
|
||||||
|
"type": "thread",
|
||||||
|
"id": key.hex(),
|
||||||
|
"created": thread.created.isoformat(),
|
||||||
|
"unread": _unread(thread),
|
||||||
|
"closed": thread.closed,
|
||||||
|
"name": thread.name,
|
||||||
|
"email": thread.email,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": mid,
|
||||||
|
"t": m.t.isoformat(),
|
||||||
|
"admin": brand if m.admin else "",
|
||||||
|
"html": m.html,
|
||||||
|
"page": m.page,
|
||||||
|
}
|
||||||
|
for mid, m in list(thread.messages.items())[-200:]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_payload(key: bytes) -> dict:
|
||||||
|
return {"type": "thread", "id": key.hex(), "created": "", "unread": False,
|
||||||
|
"closed": False, "name": "", "email": "", "messages": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def _push(sockets, payload: dict) -> None:
|
||||||
|
"""Send a payload to a socket set, dropping the ones that fail."""
|
||||||
|
closed = set()
|
||||||
|
for ws in sockets:
|
||||||
|
try:
|
||||||
|
await ws.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Message push failed; dropping client")
|
||||||
|
closed.add(ws)
|
||||||
|
for ws in closed:
|
||||||
|
sockets.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
async def _broadcast(key: bytes, thread: ContactThread) -> None:
|
||||||
|
"""Push a thread update to its visitor's panels and every admin."""
|
||||||
|
payload = _payload(key, thread)
|
||||||
|
if key in _visitor_ws:
|
||||||
|
await _push(_visitor_ws[key], payload)
|
||||||
|
await _push(_admin_ws, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _append(
|
||||||
|
key: bytes,
|
||||||
|
msg: ContactMessage,
|
||||||
|
action: str,
|
||||||
|
user: str | None,
|
||||||
|
name: str = "",
|
||||||
|
email: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Append a message to the thread (created on first use), in one
|
||||||
|
transaction so the database diff is just the new entry. A visitor's
|
||||||
|
name/email are recorded alongside their first message."""
|
||||||
|
with kanta.transaction(action, user=user, extra=key.hex()):
|
||||||
|
thread = data.contact.get(key)
|
||||||
|
if thread is None:
|
||||||
|
thread = data.contact[key] = ContactThread(created=msg.t)
|
||||||
|
if name and not thread.name:
|
||||||
|
thread.name = name
|
||||||
|
thread.email = email
|
||||||
|
thread.messages[_new_id(msg.t)] = msg
|
||||||
|
|
||||||
|
|
||||||
|
# --- Visitor protocol (/_ws frames carrying "token") ------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
"""Messaging on/off: the site contact label doubles as the switch —
|
||||||
|
empty disables the feature (docs/messages.md)."""
|
||||||
|
return bool(data.contact_label.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def quota_left(token: str) -> int:
|
||||||
|
"""Remaining PoW-granted actions for a session token (0 for malformed
|
||||||
|
tokens — they fail validation later anyway)."""
|
||||||
|
if not TOKEN_RE.fullmatch(token):
|
||||||
|
return 0
|
||||||
|
return _quota.get(thread_key(token), 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def visitor_proven(ws: WebSocket, raw: dict) -> bytes | None:
|
||||||
|
"""A solved PoW challenge submitted ahead of sending ({token, pow} —
|
||||||
|
the solution itself is already validated and burned by the WS
|
||||||
|
handler): grant the message/upload quota, acknowledge it, reveal the
|
||||||
|
site's contact email (never carried in the page's asset config, so
|
||||||
|
scrapers cannot lift it without solving) and return the thread key
|
||||||
|
(the handler records the visit's contact trail marker in analytics).
|
||||||
|
None for a malformed token."""
|
||||||
|
token = str(raw.get("token") or "")
|
||||||
|
if not TOKEN_RE.fullmatch(token):
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "bad session token"})
|
||||||
|
return None
|
||||||
|
key = thread_key(token)
|
||||||
|
_visitor_ws.setdefault(key, set()).add(ws)
|
||||||
|
_quota[key] = POW_QUOTA
|
||||||
|
await ws.send_json({"type": "pow", "quota": POW_QUOTA})
|
||||||
|
if data.contact_email:
|
||||||
|
# The mailto recipient is "Brand <email>" (RFC-encoded here; the
|
||||||
|
# link text is the bare address, the subject is the client's
|
||||||
|
# localized contact_subject).
|
||||||
|
recipient = (
|
||||||
|
quote(f"{data.brand} <{data.contact_email}>", safe="@")
|
||||||
|
if data.brand.strip()
|
||||||
|
else quote(data.contact_email, safe="@")
|
||||||
|
)
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "contact-email", "email": data.contact_email, "to": recipient}
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _spend(key: bytes) -> None:
|
||||||
|
"""Burn one message/upload of the session's PoW quota."""
|
||||||
|
if _quota.get(key, 0) > 0:
|
||||||
|
_quota[key] -= 1
|
||||||
|
|
||||||
|
|
||||||
|
async def visitor_frame(ws: WebSocket, raw: dict) -> None:
|
||||||
|
"""Handle one visitor frame: ``{token}`` registers/resyncs (the thread
|
||||||
|
state is pushed back), ``{token, msg, fr?}`` appends a message and
|
||||||
|
broadcasts the updated thread to both sides, ``{token, close}`` ends
|
||||||
|
the conversation (archived in the admin inbox; a fresh thread needs a
|
||||||
|
new token, which the client rotates itself). The PoW quota check
|
||||||
|
happens in the WS handler (tracking.activity_ws) — message frames
|
||||||
|
arrive here with quota remaining, spent here per accepted message."""
|
||||||
|
token = str(raw.get("token") or "")
|
||||||
|
if not TOKEN_RE.fullmatch(token):
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "bad session token"})
|
||||||
|
return
|
||||||
|
key = thread_key(token)
|
||||||
|
_visitor_ws.setdefault(key, set()).add(ws)
|
||||||
|
if raw.get("close"):
|
||||||
|
thread = data.contact.get(key)
|
||||||
|
if thread and not thread.closed:
|
||||||
|
with kanta.transaction("contact:close", extra=key.hex()):
|
||||||
|
thread.closed = True
|
||||||
|
await _push(_admin_ws, _payload(key, thread))
|
||||||
|
# No more pushes to this token: drop the socket from its registry
|
||||||
|
# and clean up its pending uploads like a disconnect would.
|
||||||
|
sockets = _visitor_ws.get(key)
|
||||||
|
if sockets is not None:
|
||||||
|
sockets.discard(ws)
|
||||||
|
if not sockets:
|
||||||
|
del _visitor_ws[key]
|
||||||
|
for digest in _pending_uploads.pop(key, ()):
|
||||||
|
file_store.delete(digest)
|
||||||
|
return
|
||||||
|
text = str(raw.get("msg") or "").strip()
|
||||||
|
if not text:
|
||||||
|
thread = data.contact.get(key)
|
||||||
|
# A closed thread resyncs as empty: the conversation is over, and a
|
||||||
|
# stale client (same token) starts from a clean slate.
|
||||||
|
if thread and not thread.closed:
|
||||||
|
await ws.send_json(_payload(key, thread))
|
||||||
|
else:
|
||||||
|
await ws.send_json(_empty_payload(key))
|
||||||
|
return
|
||||||
|
if len(text) > MAX_MESSAGE:
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "message too long"})
|
||||||
|
return
|
||||||
|
if not enabled():
|
||||||
|
# Messaging off (no contact label configured): sends are rejected
|
||||||
|
# (the UI is already hidden); existing threads and admin replies
|
||||||
|
# stay.
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "messaging is disabled"})
|
||||||
|
return
|
||||||
|
# The first message must introduce the visitor (name; email optional,
|
||||||
|
# unvalidated). The stored identity is fixed from then on.
|
||||||
|
thread = data.contact.get(key)
|
||||||
|
if thread and thread.closed:
|
||||||
|
# Defensive: the client rotates its token on close, so a message
|
||||||
|
# for a closed thread only comes from a stale client.
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "thread closed"})
|
||||||
|
return
|
||||||
|
name = email = ""
|
||||||
|
if thread is None or not thread.name:
|
||||||
|
name = str(raw.get("name") or "").strip()[:100]
|
||||||
|
email = str(raw.get("email") or "").strip()[:200]
|
||||||
|
if not name:
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "name required"})
|
||||||
|
return
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
_append(
|
||||||
|
key,
|
||||||
|
ContactMessage(
|
||||||
|
t=now,
|
||||||
|
md=text,
|
||||||
|
html=render_message(text),
|
||||||
|
page=str(raw.get("fr") or "")[:200],
|
||||||
|
),
|
||||||
|
"contact",
|
||||||
|
None,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
_spend(key)
|
||||||
|
# Files the message references are no longer pending cleanup.
|
||||||
|
if pending := _pending_uploads.get(key):
|
||||||
|
for digest in re.findall(r"/_f/([0-9a-f]{12})", text):
|
||||||
|
pending.discard(digest)
|
||||||
|
await _broadcast(key, data.contact[key])
|
||||||
|
|
||||||
|
|
||||||
|
def visitor_disconnect(ws: WebSocket) -> None:
|
||||||
|
"""Drop a closed /_ws socket from every registry. When a session's
|
||||||
|
last socket closes, its uploaded files that no sent message ended up
|
||||||
|
referencing are deleted from the store."""
|
||||||
|
empty = [key for key, sockets in _visitor_ws.items() if not sockets - {ws}]
|
||||||
|
for sockets in _visitor_ws.values():
|
||||||
|
sockets.discard(ws)
|
||||||
|
for key in empty:
|
||||||
|
del _visitor_ws[key]
|
||||||
|
for digest in _pending_uploads.pop(key, ()):
|
||||||
|
file_store.delete(digest)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin protocol (/_api/ws/analytics frames) -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def unread_count() -> int:
|
||||||
|
"""Threads with an unanswered visitor message — the admin inbox icon's
|
||||||
|
badge, seeded by /_api/settings (no polling; live while the inbox's
|
||||||
|
socket is open). Visitor-closed (archived) threads do not count."""
|
||||||
|
return sum(1 for t in data.contact.values() if _unread(t) and not t.closed)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_from_hex(value) -> bytes | None:
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(str(value or ""))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return key if len(key) == 12 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _last_t(thread: ContactThread) -> datetime:
|
||||||
|
"""The thread's last-activity time (its newest message, else creation)."""
|
||||||
|
if thread.messages:
|
||||||
|
return list(thread.messages.values())[-1].t
|
||||||
|
return thread.created
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_connect(ws: WebSocket) -> None:
|
||||||
|
"""Register an admin socket and send the inbox: all threads, most
|
||||||
|
recently active first, visitor-closed (archived) ones last (capped at
|
||||||
|
100)."""
|
||||||
|
_admin_ws.add(ws)
|
||||||
|
threads = sorted(data.contact.items(), key=lambda kv: _last_t(kv[1]), reverse=True)
|
||||||
|
threads = [kv for kv in threads if not kv[1].closed] + [
|
||||||
|
kv for kv in threads if kv[1].closed
|
||||||
|
]
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "threads", "threads": [_payload(k, t) for k, t in threads[:100]]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def admin_disconnect(ws: WebSocket) -> None:
|
||||||
|
_admin_ws.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_frame(ws: WebSocket, raw: dict) -> None:
|
||||||
|
"""Handle one admin frame: ``{type: reply, id, text}`` appends an admin
|
||||||
|
message, ``{type: read, id}`` marks a thread read."""
|
||||||
|
match raw.get("type"):
|
||||||
|
case "reply":
|
||||||
|
key = _key_from_hex(raw.get("id"))
|
||||||
|
text = str(raw.get("text") or "").strip()
|
||||||
|
thread = data.contact.get(key) if key else None
|
||||||
|
if thread is None:
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "no such thread"})
|
||||||
|
return
|
||||||
|
if thread.closed:
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "contact-error", "detail": "the visitor closed this conversation"}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not text or len(text) > MAX_MESSAGE:
|
||||||
|
await ws.send_json({"type": "contact-error", "detail": "bad message"})
|
||||||
|
return
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
user = ws.headers.get("remote-user")
|
||||||
|
_append(
|
||||||
|
key,
|
||||||
|
ContactMessage(
|
||||||
|
t=now,
|
||||||
|
md=text,
|
||||||
|
html=render_message(text),
|
||||||
|
admin=user or "admin",
|
||||||
|
),
|
||||||
|
"contact:reply",
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
with kanta.transaction("contact:read", user=user, extra=key.hex()):
|
||||||
|
thread.admin_read = now
|
||||||
|
await _broadcast(key, thread)
|
||||||
|
case "read":
|
||||||
|
key = _key_from_hex(raw.get("id"))
|
||||||
|
thread = data.contact.get(key) if key else None
|
||||||
|
if thread is None:
|
||||||
|
return
|
||||||
|
with kanta.transaction(
|
||||||
|
"contact:read", user=ws.headers.get("remote-user"), extra=key.hex()
|
||||||
|
):
|
||||||
|
thread.admin_read = datetime.now(UTC)
|
||||||
|
await _push(_admin_ws, _payload(key, thread))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Uploads -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Contact-panel uploads: images (raster and SVG) go through the usual
|
||||||
|
#: derivative pipeline and inline in the message — SVGs included: the
|
||||||
|
#: extension-less link negotiates raster derivatives, and even an
|
||||||
|
#: as-is-served .svg is inert inside an <img> (and downloads when
|
||||||
|
#: navigated to). Every other type is stored as an inert {hash}.file
|
||||||
|
#: blob (store_attachment — no original extension on disk) and links as
|
||||||
|
#: /_f/{hash}/{original-name}; the .file suffix never appears in URLs.
|
||||||
|
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".heic", ".heif"}
|
||||||
|
UPLOAD_MAX_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/_contact/files/{name}")
|
||||||
|
async def contact_upload(name: str, request: Request) -> dict:
|
||||||
|
"""Store a contact-panel upload in the content-addressed store.
|
||||||
|
|
||||||
|
Public like the messaging socket (only /_api is gated): the caller
|
||||||
|
must present a session-shaped token and size is capped. Uploads spend
|
||||||
|
the same PoW quota as messages (a solved challenge covers both): with
|
||||||
|
no quota left the answer is 429 "pow" (429, not 403 — no auth flows
|
||||||
|
here) and the client re-proves and retries. The file additionally
|
||||||
|
stays pending per session and is deleted when the session's last
|
||||||
|
socket closes without a sent message referencing it, and a separate
|
||||||
|
quota caps those pending files.
|
||||||
|
Images (raster and SVG) get the usual derivative treatment and are
|
||||||
|
referenced extension-less; any other file is stored as an inert
|
||||||
|
{hash}.file blob and referenced as /_f/{hash}/{original-name}, which
|
||||||
|
is always served as a forced download. Returns the /_f/ path and
|
||||||
|
whether the file is an inline-able image.
|
||||||
|
"""
|
||||||
|
token = request.headers.get("x-contact-token", "")
|
||||||
|
if not TOKEN_RE.fullmatch(token):
|
||||||
|
raise HTTPException(403, "no session")
|
||||||
|
key = thread_key(token)
|
||||||
|
if _quota.get(key, 0) <= 0:
|
||||||
|
raise HTTPException(429, "pow")
|
||||||
|
pending = _pending_uploads.setdefault(key, set())
|
||||||
|
if len(pending) >= MAX_PENDING_UPLOADS:
|
||||||
|
raise HTTPException(429, "too many un-sent uploads — send your message first")
|
||||||
|
try:
|
||||||
|
length = int(request.headers.get("content-length") or 0)
|
||||||
|
except ValueError:
|
||||||
|
length = 0
|
||||||
|
if length > UPLOAD_MAX_BYTES:
|
||||||
|
raise HTTPException(413, "file too large")
|
||||||
|
body = await request.body()
|
||||||
|
if not body:
|
||||||
|
raise HTTPException(400, "empty file")
|
||||||
|
if len(body) > UPLOAD_MAX_BYTES:
|
||||||
|
raise HTTPException(413, "file too large")
|
||||||
|
_spend(key)
|
||||||
|
orig = Path(name).name[:100] or "file"
|
||||||
|
ext = _ext(orig)
|
||||||
|
if ext in IMAGE_EXTS:
|
||||||
|
stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif")
|
||||||
|
# Decodable images come back extension-less (GIFs and as-is SVGs
|
||||||
|
# keep their extension); an undecodable body attaches like any file.
|
||||||
|
if "." not in stored or ext in (".gif", ".svg"):
|
||||||
|
pending.add(stored.partition(".")[0])
|
||||||
|
return {"path": f"/_f/{stored}", "image": True, "name": orig}
|
||||||
|
stored = await asyncio.to_thread(store_attachment, body)
|
||||||
|
pending.add(stored.removesuffix(".file"))
|
||||||
|
return {
|
||||||
|
"path": f"/_f/{stored.removesuffix('.file')}/{quote(orig, safe='')}",
|
||||||
|
"image": False,
|
||||||
|
"name": orig,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/_contact/easypow.js")
|
||||||
|
async def pow_solver() -> Response:
|
||||||
|
"""The easypow browser solver as an ES module, served from the
|
||||||
|
installed package so the client solver and the server validator are
|
||||||
|
always the same version."""
|
||||||
|
return Response(
|
||||||
|
easypow.js,
|
||||||
|
media_type="text/javascript",
|
||||||
|
headers={"cache-control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
+52
-3
@@ -19,7 +19,9 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from fastapi.responses import RedirectResponse, Response
|
from fastapi.responses import RedirectResponse, Response
|
||||||
|
|
||||||
from pagerite import i18n, state
|
from pagerite import i18n, state
|
||||||
from pagerite.data import Node, resolve, sorted_nodes
|
from pagerite.chunks import chunk_key
|
||||||
|
from pagerite.data import Node, node_markdown, resolve, sorted_nodes
|
||||||
|
from pagerite.markdown import has_h1
|
||||||
from pagerite.state import (
|
from pagerite.state import (
|
||||||
SITE_URL,
|
SITE_URL,
|
||||||
_html_response,
|
_html_response,
|
||||||
@@ -47,6 +49,40 @@ def _is_trackable_path(path: str) -> bool:
|
|||||||
return not _is_reserved(path)
|
return not _is_reserved(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown_response(
|
||||||
|
request: Request, path: str, node: Node, lang: str, original: str
|
||||||
|
) -> Response:
|
||||||
|
"""The page as Markdown (text/markdown): the same source the HTML page
|
||||||
|
renders (per-language hybrid assembly), with the page title injected as
|
||||||
|
``# title`` when the content has no h1 of its own — mirroring
|
||||||
|
markdown.render's title injection, so the .md reads like the page.
|
||||||
|
|
||||||
|
Served at the page's ``.md`` URL, and also at the plain URL when the
|
||||||
|
request's Accept header explicitly names text/markdown (Vary: accept).
|
||||||
|
Language selection is the page's own (?lang= or Accept-Language), so
|
||||||
|
``/page.md?lang=fi`` is the Finnish markdown."""
|
||||||
|
content = node_markdown(data, node) or ""
|
||||||
|
title = node.title
|
||||||
|
if lang != original and lang in node.langs:
|
||||||
|
content = i18n.hybrid_markdown(data, node, path, lang)
|
||||||
|
if t := data.trans.get(chunk_key(node.title), {}).get(lang):
|
||||||
|
title = t
|
||||||
|
if title and not has_h1(content):
|
||||||
|
content = f"# {title}\n\n{content}"
|
||||||
|
etag = f'"{path}@{node.modified.timestamp()}g{state._render_gen}l{lang}md"'
|
||||||
|
headers = {
|
||||||
|
"etag": etag,
|
||||||
|
"last-modified": _http_date(node.modified),
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"vary": "accept",
|
||||||
|
}
|
||||||
|
if request.headers.get("if-none-match") == etag:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
if _is_trackable_path(path):
|
||||||
|
_record_get(request, lang=lang)
|
||||||
|
return Response(content, media_type="text/markdown", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def front_page(request: Request) -> Response:
|
async def front_page(request: Request) -> Response:
|
||||||
"""Render the front page (slug path "")."""
|
"""Render the front page (slug path "")."""
|
||||||
@@ -164,6 +200,14 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
"""
|
"""
|
||||||
path = path.strip("/")
|
path = path.strip("/")
|
||||||
accept_language = request.headers.get("accept-language", "")
|
accept_language = request.headers.get("accept-language", "")
|
||||||
|
# A .md suffix (or an Accept header explicitly naming text/markdown)
|
||||||
|
# asks for the page's Markdown instead of HTML. Slugs never contain a
|
||||||
|
# dot, so "page.md" unambiguously means the markdown of "page"; the
|
||||||
|
# front page's markdown URL is "/.md".
|
||||||
|
markdown = False
|
||||||
|
if path.endswith(".md"):
|
||||||
|
path = path[:-3]
|
||||||
|
markdown = True
|
||||||
if path and _is_reserved(path):
|
if path and _is_reserved(path):
|
||||||
# Invalid slug shape: not a content URL, let FastAPI return its
|
# Invalid slug shape: not a content URL, let FastAPI return its
|
||||||
# built-in 404 instead of rendering an editable article page.
|
# built-in 404 instead of rendering an editable article page.
|
||||||
@@ -179,18 +223,20 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
# Accept-Language header regardless of the selection, and record
|
# Accept-Language header regardless of the selection, and record
|
||||||
# the resolved language as the GET's rendered language.
|
# the resolved language as the GET's rendered language.
|
||||||
query_lang = request.query_params.get("lang")
|
query_lang = request.query_params.get("lang")
|
||||||
|
original = i18n.primary_lang(data.menu, path)
|
||||||
lang = i18n.select_language(
|
lang = i18n.select_language(
|
||||||
query_lang,
|
query_lang,
|
||||||
accept_language,
|
accept_language,
|
||||||
lambda tag: tag in node.langs,
|
lambda tag: tag in node.langs,
|
||||||
original=i18n.primary_lang(data.menu, path),
|
original=original,
|
||||||
)
|
)
|
||||||
# A ?lang= override is replicated onto the page's navigation links
|
# A ?lang= override is replicated onto the page's navigation links
|
||||||
# (link_lang), so clicks and prefetches stay in the chosen language.
|
# (link_lang), so clicks and prefetches stay in the chosen language.
|
||||||
# Query and header-selected renders of the same language differ in
|
# Query and header-selected renders of the same language differ in
|
||||||
# their links, so link_lang is part of the ETag and body cache key.
|
# their links, so link_lang is part of the ETag and body cache key.
|
||||||
link_lang = i18n.base_tag(query_lang or "")
|
link_lang = i18n.base_tag(query_lang or "")
|
||||||
# no-cache forbids serving a stored page without revalidation
|
if markdown or "text/markdown" in request.headers.get("accept", ""):
|
||||||
|
return _markdown_response(request, path, node, lang, original)
|
||||||
# (browsers would otherwise cache heuristically and serve stale
|
# (browsers would otherwise cache heuristically and serve stale
|
||||||
# pages, e.g. after a theme change). In-session speed instead comes
|
# pages, e.g. after a theme change). In-session speed instead comes
|
||||||
# from pagerite.js's in-memory page cache (preload everything, never
|
# from pagerite.js's in-memory page cache (preload everything, never
|
||||||
@@ -209,6 +255,9 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": _http_date(node.modified),
|
"last-modified": _http_date(node.modified),
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
|
# The same URL serves text/markdown when the Accept header
|
||||||
|
# explicitly asks for it (_markdown_response).
|
||||||
|
"vary": "accept",
|
||||||
},
|
},
|
||||||
lang=lang,
|
lang=lang,
|
||||||
link_lang=link_lang,
|
link_lang=link_lang,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
--line: #12203f14;
|
--line: #12203f14;
|
||||||
--font-body: var(--font-inter);
|
--font-body: var(--font-inter);
|
||||||
--font-heading: var(--font-montserrat);
|
--font-heading: var(--font-montserrat);
|
||||||
|
--nav-weight: 600;
|
||||||
--code-x-height: 0.546; /* Inter's x-height ratio */
|
--code-x-height: 0.546; /* Inter's x-height ratio */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,6 @@
|
|||||||
|
|
||||||
#nav {
|
#nav {
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Nitro theme: racing/HUD style — saturated orange + cyan, console/HUD-style
|
/* Nitro theme: racing/HUD style — saturated orange + cyan, console/HUD-style
|
||||||
monospace display type, an orange racing-tab nav with a bezier clip-path,
|
monospace display type, an orange racing-tab nav with a bezier clip-path,
|
||||||
and a dark bezier banner. Follows prefers-color-scheme: the light scheme
|
matching slanted-parallelogram buttons, and a dark bezier banner. Follows
|
||||||
|
prefers-color-scheme: the light scheme
|
||||||
is a warm light-grey page, the dark scheme a deep violet page; the banner
|
is a warm light-grey page, the dark scheme a deep violet page; the banner
|
||||||
and other dark/bright elements carry over unchanged.
|
and other dark/bright elements carry over unchanged.
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@
|
|||||||
|
|
||||||
--font-body: var(--font-montserrat);
|
--font-body: var(--font-montserrat);
|
||||||
--font-heading: var(--font-literata);
|
--font-heading: var(--font-literata);
|
||||||
|
--font-nav: var(--font-heading);
|
||||||
|
--nav-weight: 700;
|
||||||
--code-x-height: 0.517; /* Montserrat's x-height ratio */
|
--code-x-height: 0.517; /* Montserrat's x-height ratio */
|
||||||
/* Neutral grey selection instead of the accent tint: accent-colored
|
/* Neutral grey selection instead of the accent tint: accent-colored
|
||||||
text (h2, links, markers) stays readable on it in both schemes. */
|
text (h2, links, markers) stays readable on it in both schemes. */
|
||||||
@@ -86,9 +89,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#nav {
|
#nav {
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
/* Racing tab: sitting at the bottom of the banner, with a bezier clip —
|
/* Racing tab: sitting at the bottom of the banner, with a bezier clip —
|
||||||
@@ -135,6 +136,70 @@
|
|||||||
color: var(--tab-muted);
|
color: var(--tab-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buttons in the article flow (the {contact:} trigger and any raw
|
||||||
|
<button> in the markup): a racing parallelogram with / slanted sides,
|
||||||
|
clipped with the same shape() language as the nav tab. All buttons
|
||||||
|
are forced to one fixed 1.7em height (inline-flex centering, nowrap,
|
||||||
|
no border — it would be invisible under the clip), so every button
|
||||||
|
gets the identical shape and the geometry is exact: a ~62° slant from
|
||||||
|
the 0.9em run. The obtuse top-left and bottom-right corners replicate
|
||||||
|
the nav tab's top-left corner: a wide bezier sweep with the same
|
||||||
|
0.8/0.6 control-handle split along the joining edges (over a 0.5em
|
||||||
|
tangent run), mirrored between the two corners. The sharp corners
|
||||||
|
keep small arcs, in balance with the wide sweeps (0.22em, arc
|
||||||
|
≈ 0.45em — about half the obtuse sweep's, so the sharp corners stay
|
||||||
|
subordinate). border-radius is the fallback for browsers without
|
||||||
|
shape(). Asymmetric inline padding keeps the text clear of the
|
||||||
|
slants (wider on the right: the wide bottom-right sweep cuts deeper
|
||||||
|
into the label's end side). Editor chrome (.icon-btn pens, .copy)
|
||||||
|
keeps its own styling. The {contact:} envelope's line-height is
|
||||||
|
reduced in the base stylesheet so it doesn't outgrow this height. */
|
||||||
|
.contact-trigger,
|
||||||
|
article button:not(.icon-btn):not(.copy) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 1.7em;
|
||||||
|
padding: 0 1.3em 0 1em;
|
||||||
|
white-space: nowrap;
|
||||||
|
/* Same typography as the contact trigger (raw <button>s otherwise get
|
||||||
|
the small UA font): nav family/weight, article size and leading. */
|
||||||
|
font-family: var(--font-nav);
|
||||||
|
font-weight: var(--nav-weight);
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
clip-path: shape(
|
||||||
|
/* bottom edge, left to right */
|
||||||
|
from 0.365em 100%,
|
||||||
|
hline to calc(100% - 1.4em),
|
||||||
|
/* bottom-right corner: nav-style bezier, mirrored from top-left */
|
||||||
|
curve to calc(100% - 0.666em) calc(100% - 0.442em)
|
||||||
|
with calc(100% - 1.1em) 100% / calc(100% - 0.853em) calc(100% - 0.088em),
|
||||||
|
/* right slant */
|
||||||
|
line to calc(100% - 0.171em) 0.323em,
|
||||||
|
/* top-right corner: sharp, small arc */
|
||||||
|
arc to calc(100% - 0.365em) 0% of 0.22em ccw,
|
||||||
|
/* top edge, right to left */
|
||||||
|
hline to 1.4em,
|
||||||
|
/* top-left corner: nav-style bezier */
|
||||||
|
curve to 0.666em 0.442em with 1.1em 0 / 0.853em 0.088em,
|
||||||
|
/* left slant */
|
||||||
|
line to 0.171em calc(100% - 0.323em),
|
||||||
|
/* bottom-left corner: sharp, small arc */
|
||||||
|
arc to 0.365em 100% of 0.22em ccw,
|
||||||
|
close);
|
||||||
|
}
|
||||||
|
|
||||||
|
article button:not(.icon-btn):not(.copy):hover {
|
||||||
|
color: var(--tab-hover);
|
||||||
|
text-shadow: 0 0 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Console-style headings: uppercase monospace. h1 in the page text color
|
/* Console-style headings: uppercase monospace. h1 in the page text color
|
||||||
with a hazard-stripe underline, h2 deep orange, h3 cyan. */
|
with a hazard-stripe underline, h2 deep orange, h3 cyan. */
|
||||||
article h1 {
|
article h1 {
|
||||||
|
|||||||
+94
-4
@@ -23,13 +23,14 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import easypow
|
||||||
import httpx
|
import httpx
|
||||||
import msgspec
|
import msgspec
|
||||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from uarite import uaparse
|
from uarite import uaparse
|
||||||
|
|
||||||
from pagerite import analytics, i18n
|
from pagerite import analytics, i18n, messages
|
||||||
from pagerite.data import resolve
|
from pagerite.data import resolve
|
||||||
from pagerite.files import _hash_name, file_store
|
from pagerite.files import _hash_name, file_store
|
||||||
from pagerite.state import SITE_URL, _html_response, analytics_store, data
|
from pagerite.state import SITE_URL, _html_response, analytics_store, data
|
||||||
@@ -447,6 +448,18 @@ async def activity_ws(ws: WebSocket) -> None:
|
|||||||
abusive IPs are filtered at display time, not here. The reverse-DNS and
|
abusive IPs are filtered at display time, not here. The reverse-DNS and
|
||||||
DB-IP geoip lookups happen in background tasks so message handling is
|
DB-IP geoip lookups happen in background tasks so message handling is
|
||||||
never delayed by slow DNS or the first MMDB decompress.
|
never delayed by slow DNS or the first MMDB decompress.
|
||||||
|
|
||||||
|
Frames carrying a ``token`` field are visitor contact messages
|
||||||
|
(docs/messages.md), routed to pagerite.messages — the same socket
|
||||||
|
serves the contact panel, which also receives thread pushes on it.
|
||||||
|
Sends and uploads spend a per-session quota granted by solving an
|
||||||
|
easypow challenge: a single-use challenge (a per-connection local) is
|
||||||
|
issued on registration and whenever the quota runs out; the client
|
||||||
|
submits the solution as soon as it is ready ({token, pow}), validated
|
||||||
|
and burned here, and the quota itself lives in pagerite.messages
|
||||||
|
keyed by token hash (shared with the HTTP upload route, kept across
|
||||||
|
reconnects). A validated proof also reveals the site's contact email
|
||||||
|
and records the visit's contact trail marker in analytics.
|
||||||
"""
|
"""
|
||||||
ip = _client_ip(ws)
|
ip = _client_ip(ws)
|
||||||
ua = ws.headers.get("user-agent", "")
|
ua = ws.headers.get("user-agent", "")
|
||||||
@@ -458,11 +471,70 @@ async def activity_ws(ws: WebSocket) -> None:
|
|||||||
part for part in (uaparse(ua).pretty, lang) if part
|
part for part in (uaparse(ua).pretty, lang) if part
|
||||||
)
|
)
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
|
challenge = "" # outstanding PoW challenge for contact sends (below)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
text = await ws.receive_text()
|
text = await ws.receive_text()
|
||||||
try:
|
try:
|
||||||
msg = msgspec.json.decode(text.encode(), type=analytics.Ping)
|
raw = msgspec.json.decode(text.encode())
|
||||||
|
except msgspec.DecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(raw, dict) and "token" in raw:
|
||||||
|
# Contact-messaging frames (docs/messages.md).
|
||||||
|
if raw.get("msg"):
|
||||||
|
# A send with no quota left gets a fresh challenge
|
||||||
|
# instead; the client solves it, re-proves and retries.
|
||||||
|
if messages.quota_left(str(raw.get("token") or "")) <= 0:
|
||||||
|
challenge = easypow.generate(messages.POW_SECONDS)
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "pow", "challenge": challenge, "required": True}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
await messages.visitor_frame(ws, raw)
|
||||||
|
if messages.quota_left(str(raw.get("token") or "")) <= 0:
|
||||||
|
# Quota just ran out: push the next challenge right
|
||||||
|
# away so the client can pre-solve it.
|
||||||
|
challenge = easypow.generate(messages.POW_SECONDS)
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "pow", "challenge": challenge, "required": False}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if "pow" in raw:
|
||||||
|
# A solved challenge, submitted as soon as ready:
|
||||||
|
# validate and burn it here, grant the quota in
|
||||||
|
# pagerite.messages (shared with the upload route).
|
||||||
|
try:
|
||||||
|
if not challenge:
|
||||||
|
raise ValueError("no outstanding challenge")
|
||||||
|
easypow.validate(challenge, str(raw.pop("pow", "") or ""))
|
||||||
|
except ValueError:
|
||||||
|
challenge = easypow.generate(messages.POW_SECONDS)
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "pow", "challenge": challenge, "required": True}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
challenge = "" # burned; a fresh one comes when the quota ends
|
||||||
|
key = await messages.visitor_proven(ws, raw)
|
||||||
|
if key is not None:
|
||||||
|
# Panel opened AND PoW solved: mark the visit's
|
||||||
|
# trail at the page it first happened on.
|
||||||
|
new_client = analytics_store.record_contact(
|
||||||
|
str(raw.get("fr") or ""), ip, ua, accept_language
|
||||||
|
)
|
||||||
|
if new_client is not None:
|
||||||
|
_schedule_client_enrichment([new_client])
|
||||||
|
_schedule_analytics_broadcast()
|
||||||
|
continue
|
||||||
|
# Registration/resync: the thread state is pushed back, and
|
||||||
|
# a fresh challenge is issued for the client to pre-solve.
|
||||||
|
await messages.visitor_frame(ws, raw)
|
||||||
|
challenge = easypow.generate(messages.POW_SECONDS)
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "pow", "challenge": challenge, "required": False}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = msgspec.convert(raw, type=analytics.Ping)
|
||||||
except msgspec.DecodeError:
|
except msgspec.DecodeError:
|
||||||
continue
|
continue
|
||||||
new_client = analytics_store.record_msg(
|
new_client = analytics_store.record_msg(
|
||||||
@@ -480,6 +552,8 @@ async def activity_ws(ws: WebSocket) -> None:
|
|||||||
_schedule_favicon_fetch()
|
_schedule_favicon_fetch()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
messages.visitor_disconnect(ws)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/_api/ws/analytics")
|
@router.websocket("/_api/ws/analytics")
|
||||||
@@ -488,15 +562,31 @@ async def analytics_websocket(ws: WebSocket) -> None:
|
|||||||
|
|
||||||
Admin-only via the /_api forward-auth gate, like every management
|
Admin-only via the /_api forward-auth gate, like every management
|
||||||
endpoint. Powers the analytics viewer rendered at /_a.
|
endpoint. Powers the analytics viewer rendered at /_a.
|
||||||
|
|
||||||
|
The same socket serves the admin contact inbox (the overlay opened
|
||||||
|
from the corner ✉️ icon, docs/messages.md):
|
||||||
|
on connect the thread list follows the snapshot, new visitor messages
|
||||||
|
are pushed as they arrive, and incoming frames (``reply``/``read``)
|
||||||
|
go to pagerite.messages. Analytics payloads carry no ``type`` field;
|
||||||
|
messaging payloads always do, so the client can tell them apart.
|
||||||
"""
|
"""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
await ws.send_text(_display_json())
|
await ws.send_text(_display_json())
|
||||||
_analytics_ws_clients.add(ws)
|
_analytics_ws_clients.add(ws)
|
||||||
|
await messages.admin_connect(ws)
|
||||||
try:
|
try:
|
||||||
# Receive until the client goes away; we only push.
|
# Receive until the client goes away; messaging frames are handled,
|
||||||
|
# anything else is ignored (analytics only pushes).
|
||||||
while True:
|
while True:
|
||||||
await ws.receive_text()
|
text = await ws.receive_text()
|
||||||
|
try:
|
||||||
|
raw = msgspec.json.decode(text.encode())
|
||||||
|
except msgspec.DecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
await messages.admin_frame(ws, raw)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.debug("Analytics WS client disconnected")
|
logger.debug("Analytics WS client disconnected")
|
||||||
finally:
|
finally:
|
||||||
_analytics_ws_clients.discard(ws)
|
_analytics_ws_clients.discard(ws)
|
||||||
|
messages.admin_disconnect(ws)
|
||||||
@@ -309,6 +309,15 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
|
|||||||
walk(node.children, path, node_lang)
|
walk(node.children, path, node_lang)
|
||||||
|
|
||||||
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
||||||
|
# Site-level messaging strings (Data.contact_label/contact_subject):
|
||||||
|
# translated like titles and looked up at render time by chunk key
|
||||||
|
# (i18n.site_string). Skipped when messaging is off (no label — the
|
||||||
|
# label doubles as the enable switch) and in the site default
|
||||||
|
# language, like titles in their article's primary language.
|
||||||
|
if data.contact_label.strip() and lang != i18n.primary_lang(data.menu, ""):
|
||||||
|
for text in (data.contact_label, data.contact_subject):
|
||||||
|
if text and "\n" not in text:
|
||||||
|
emit(chunk_key(text), text, "", "title")
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -536,6 +545,19 @@ class Dispatcher:
|
|||||||
walk(node.children, depth + 1, node_lang)
|
walk(node.children, depth + 1, node_lang)
|
||||||
|
|
||||||
walk(self.data.menu, 0, i18n.ORIGINAL_LANGUAGE)
|
walk(self.data.menu, 0, i18n.ORIGINAL_LANGUAGE)
|
||||||
|
# The site-level messaging strings ride the same list (pending_items
|
||||||
|
# covers them for scoped jobs): one round trip names the menu and
|
||||||
|
# the contact chrome alike.
|
||||||
|
if self.data.contact_label.strip() and lang != i18n.primary_lang(self.data.menu, ""):
|
||||||
|
for text in (self.data.contact_label, self.data.contact_subject):
|
||||||
|
if text and "\n" not in text:
|
||||||
|
key = chunk_key(text)
|
||||||
|
if (
|
||||||
|
lang not in self.data.trans.get(key, {})
|
||||||
|
and (lang, key) not in inflight
|
||||||
|
and (lang, key, "nav") not in self.validation_failures
|
||||||
|
):
|
||||||
|
titles.append((0, text, key))
|
||||||
if len(titles) < 2:
|
if len(titles) < 2:
|
||||||
return None
|
return None
|
||||||
md = "\n".join(f"{' ' * depth}- {title}" for depth, title, _ in titles)
|
md = "\n".join(f"{' ' * depth}- {title}" for depth, title, _ in titles)
|
||||||
|
|||||||
+98
-8
@@ -336,6 +336,8 @@ def _layout(
|
|||||||
lang: str = i18n.ORIGINAL_LANGUAGE,
|
lang: str = i18n.ORIGINAL_LANGUAGE,
|
||||||
canonical: str = "",
|
canonical: str = "",
|
||||||
alternates: list[tuple[str, str]] = (),
|
alternates: list[tuple[str, str]] = (),
|
||||||
|
markdown_url: str = "",
|
||||||
|
contact: dict[str, str] | None = None,
|
||||||
) -> Template:
|
) -> Template:
|
||||||
"""Page layout template with standard assets and ES-module scripts.
|
"""Page layout template with standard assets and ES-module scripts.
|
||||||
|
|
||||||
@@ -368,6 +370,9 @@ def _layout(
|
|||||||
``alternates`` ((hreflang, href) pairs) are the page's language URLs
|
``alternates`` ((hreflang, href) pairs) are the page's language URLs
|
||||||
(see docs/localization.md), emitted right after the viewport and before
|
(see docs/localization.md), emitted right after the viewport and before
|
||||||
the social tags: canonical first, then the hreflang alternates.
|
the social tags: canonical first, then the hreflang alternates.
|
||||||
|
|
||||||
|
``contact`` carries the localized contact-messaging strings
|
||||||
|
(_contact_config; None = messaging disabled) into the asset config.
|
||||||
"""
|
"""
|
||||||
doc = Document(
|
doc = Document(
|
||||||
E.Title, lang=lang, dir="rtl" if lang in i18n.RTL_LANGUAGES else "ltr"
|
E.Title, lang=lang, dir="rtl" if lang in i18n.RTL_LANGUAGES else "ltr"
|
||||||
@@ -391,6 +396,9 @@ def _layout(
|
|||||||
rel="alternate", type="application/rss+xml", title="RSS feed", href="/feed.xml"
|
rel="alternate", type="application/rss+xml", title="RSS feed", href="/feed.xml"
|
||||||
)
|
)
|
||||||
doc.link(rel="llms-txt", type="text/markdown", title="llms.txt", href="/llms.txt")
|
doc.link(rel="llms-txt", type="text/markdown", title="llms.txt", href="/llms.txt")
|
||||||
|
# The page's own Markdown counterpart (pages.show_page's .md mode).
|
||||||
|
if markdown_url:
|
||||||
|
doc.link(rel="alternate", type="text/markdown", href=markdown_url)
|
||||||
for key, value in (social or {}).items():
|
for key, value in (social or {}).items():
|
||||||
if value:
|
if value:
|
||||||
if key.startswith(("og:", "article:")):
|
if key.startswith(("og:", "article:")):
|
||||||
@@ -411,15 +419,27 @@ def _layout(
|
|||||||
vite_url = env.vite_url
|
vite_url = env.vite_url
|
||||||
editor_scripts, editor_css = _editor_assets()
|
editor_scripts, editor_css = _editor_assets()
|
||||||
langselect_scripts, langselect_css = _langselect_assets()
|
langselect_scripts, langselect_css = _langselect_assets()
|
||||||
|
contact_scripts, contact_css = _contact_assets()
|
||||||
config = {
|
config = {
|
||||||
"pagerite:editor-src": editor_scripts[-1],
|
"pagerite:editor-src": editor_scripts[-1],
|
||||||
"pagerite:analytics-src": _analytics_assets()[0][0],
|
"pagerite:analytics-src": _analytics_assets()[0][0],
|
||||||
"pagerite:langselect-src": langselect_scripts[-1],
|
"pagerite:langselect-src": langselect_scripts[-1],
|
||||||
|
"pagerite:contact-src": contact_scripts[-1],
|
||||||
}
|
}
|
||||||
if langselect_css:
|
if langselect_css:
|
||||||
config["pagerite:langselect-css"] = ",".join(langselect_css)
|
config["pagerite:langselect-css"] = ",".join(langselect_css)
|
||||||
|
if contact_css:
|
||||||
|
config["pagerite:contact-css"] = ",".join(contact_css)
|
||||||
if editor_css:
|
if editor_css:
|
||||||
config["pagerite:editor-css"] = editor_css
|
config["pagerite:editor-css"] = editor_css
|
||||||
|
# Localized contact-messaging strings (label = the {contact:} buttons'
|
||||||
|
# default and the visitor dialog's title, mailto subject — the address
|
||||||
|
# itself is revealed over the socket only after the PoW is solved).
|
||||||
|
# Absent entirely when messaging is disabled: pagerite.js then shows
|
||||||
|
# the corner ✉️ icon to admins only.
|
||||||
|
if contact is not None:
|
||||||
|
config["pagerite:contact-label"] = contact["label"]
|
||||||
|
config["pagerite:contact-subject"] = contact["subject"]
|
||||||
if vite_url:
|
if vite_url:
|
||||||
for key, value in config.items():
|
for key, value in config.items():
|
||||||
doc.meta(name=key, content=value)
|
doc.meta(name=key, content=value)
|
||||||
@@ -896,13 +916,18 @@ def page_content(
|
|||||||
# A {cards} tag places the card rows itself (possibly several);
|
# A {cards} tag places the card rows itself (possibly several);
|
||||||
# without one the children are appended after the content as before.
|
# without one the children are appended after the content as before.
|
||||||
has_cards_tag = _CARDS_TAG_RE.search(content) is not None
|
has_cards_tag = _CARDS_TAG_RE.search(content) is not None
|
||||||
directives = None
|
# Messaging disabled: {contact:} tags render nothing at all.
|
||||||
|
directives = {
|
||||||
|
"contact": (
|
||||||
|
(lambda args, _env: _contact_tag(args, _env, data, lang))
|
||||||
|
if data.contact_label.strip()
|
||||||
|
else (lambda _args, _env: "")
|
||||||
|
)
|
||||||
|
}
|
||||||
if has_cards_tag:
|
if has_cards_tag:
|
||||||
directives = {
|
directives["cards"] = lambda args, _env: _cards_tag(
|
||||||
"cards": lambda args, _env: _cards_tag(
|
menu, data, node, path, args, translation, link_lang, lang
|
||||||
menu, data, node, path, args, translation, link_lang, lang
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
rendered = render(
|
rendered = render(
|
||||||
content,
|
content,
|
||||||
path,
|
path,
|
||||||
@@ -980,6 +1005,43 @@ def _cards(
|
|||||||
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.MULTILINE)
|
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_config(data: Data, lang: str) -> dict[str, str]:
|
||||||
|
"""The localized contact-messaging strings for the page's asset config
|
||||||
|
(pagerite:contact-*): the {contact:} buttons' default label doubling as
|
||||||
|
the visitor dialog's title, and the mailto subject (the label when no
|
||||||
|
separate subject is configured). Translations come from the
|
||||||
|
chunk-keyed trans store (i18n.site_string). The email address itself
|
||||||
|
is NOT here — it is revealed over the messaging socket only after the
|
||||||
|
proof of work is solved, so scrapers cannot lift it from pages."""
|
||||||
|
label = i18n.site_string(data, lang, data.contact_label)
|
||||||
|
return {
|
||||||
|
"label": label,
|
||||||
|
"subject": i18n.site_string(data, lang, data.contact_subject) or label,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_tag(args: str, _env: dict, data: Data | None = None, lang: str = "") -> str:
|
||||||
|
"""Expand a ``{contact: Label}`` directive: the button that opens the
|
||||||
|
visitor contact panel (docs/messages.md). The args are a per-button
|
||||||
|
label overriding the configured site label (Data.contact_label,
|
||||||
|
localized here; "Contact us" is the last-resort fallback). Callers
|
||||||
|
expand the tag only when messaging is enabled — with a blank
|
||||||
|
contact_label the tag renders nothing at all. pagerite.js binds the
|
||||||
|
click and lazy-loads the panel bundle; in the editor preview the
|
||||||
|
button stays inert."""
|
||||||
|
label = args.strip()
|
||||||
|
if not label and data is not None:
|
||||||
|
label = i18n.site_string(data, lang, data.contact_label)
|
||||||
|
return str(
|
||||||
|
E.button(
|
||||||
|
E.span("✉️", class_="icon"),
|
||||||
|
label or "Contact us",
|
||||||
|
type="button",
|
||||||
|
class_="contact-trigger",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cards_tag(
|
def _cards_tag(
|
||||||
menu: dict[str, Node],
|
menu: dict[str, Node],
|
||||||
data: Data,
|
data: Data,
|
||||||
@@ -1119,7 +1181,7 @@ def _card(
|
|||||||
node.modified,
|
node.modified,
|
||||||
# Card heuristics only mine the prose: nested {cards} rows
|
# Card heuristics only mine the prose: nested {cards} rows
|
||||||
# would just be noise in the description extraction.
|
# would just be noise in the description extraction.
|
||||||
directives={"cards": lambda _args, _env: ""},
|
directives={"cards": lambda _args, _env: "", "contact": lambda _a, _e: ""},
|
||||||
).html
|
).html
|
||||||
image, _ = _media(html)
|
image, _ = _media(html)
|
||||||
if not image:
|
if not image:
|
||||||
@@ -1139,7 +1201,7 @@ def _card(
|
|||||||
path,
|
path,
|
||||||
node.created,
|
node.created,
|
||||||
node.modified,
|
node.modified,
|
||||||
directives={"cards": lambda _args, _env: ""},
|
directives={"cards": lambda _args, _env: "", "contact": lambda _a, _e: ""},
|
||||||
).html
|
).html
|
||||||
if not large and html:
|
if not large and html:
|
||||||
description = _description(html, 150)
|
description = _description(html, 150)
|
||||||
@@ -1397,6 +1459,9 @@ def render_page(
|
|||||||
lang,
|
lang,
|
||||||
canonical,
|
canonical,
|
||||||
alternates,
|
alternates,
|
||||||
|
# The page's Markdown counterpart (pages.show_page's .md mode).
|
||||||
|
f"/{path}.md",
|
||||||
|
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
|
||||||
)(
|
)(
|
||||||
Title=f"{title} – {brand}" if brand else title,
|
Title=f"{title} – {brand}" if brand else title,
|
||||||
Brand=_brand_link(brand, brand_html, link_lang),
|
Brand=_brand_link(brand, brand_html, link_lang),
|
||||||
@@ -1460,6 +1525,7 @@ def render_category(
|
|||||||
favicon,
|
favicon,
|
||||||
lang=lang,
|
lang=lang,
|
||||||
alternates=alternates,
|
alternates=alternates,
|
||||||
|
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
|
||||||
)(
|
)(
|
||||||
Title=f"{title} – {brand}" if brand else title,
|
Title=f"{title} – {brand}" if brand else title,
|
||||||
Brand=_brand_link(brand, brand_html, link_lang),
|
Brand=_brand_link(brand, brand_html, link_lang),
|
||||||
@@ -1580,6 +1646,30 @@ def _langselect_assets() -> tuple[list[str], list[str]]:
|
|||||||
return _asset_cache["langselect"]
|
return _asset_cache["langselect"]
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_assets() -> tuple[list[str], list[str]]:
|
||||||
|
"""Script and stylesheet URLs for the on-demand contact panel."""
|
||||||
|
vite_url = env.vite_url
|
||||||
|
if vite_url:
|
||||||
|
return [f"{vite_url}/src/contact-main.js"], []
|
||||||
|
if "contact" not in _asset_cache:
|
||||||
|
manifest = _manifest()
|
||||||
|
# import() loads no CSS automatically: collect the stylesheets of
|
||||||
|
# the entry and its imported chunks (same walk as _langselect).
|
||||||
|
scripts, stylesheets, seen = [], [], set()
|
||||||
|
queue = ["src/contact-main.js"]
|
||||||
|
for key in queue: # grows with imported chunks
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
entry = manifest[key]
|
||||||
|
if entry.get("isEntry"):
|
||||||
|
scripts.append(f"/{entry['file']}")
|
||||||
|
stylesheets += [f"/{css}" for css in entry.get("css", [])]
|
||||||
|
queue += entry.get("imports", [])
|
||||||
|
_asset_cache["contact"] = scripts, stylesheets
|
||||||
|
return _asset_cache["contact"]
|
||||||
|
|
||||||
|
|
||||||
def render_analytics(
|
def render_analytics(
|
||||||
menu: dict[str, Node],
|
menu: dict[str, Node],
|
||||||
brand: str = SITE_NAME,
|
brand: str = SITE_NAME,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3>=1.0.9",
|
"blake3>=1.0.9",
|
||||||
|
"easypow>=0.1.0",
|
||||||
"fastapi-vue~=1.7.2",
|
"fastapi-vue~=1.7.2",
|
||||||
"fastapi[standard]>=0.141.1",
|
"fastapi[standard]>=0.141.1",
|
||||||
"html5tagger>=2.0.0",
|
"html5tagger>=2.0.0",
|
||||||
|
|||||||
Reference in new issue
Block a user