diff --git a/AGENTS.md b/AGENTS.md index a01be2c..1fba27f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings). - `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`. - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames and gates sends with a per-connection single-use easypow challenge, the analytics socket the admin inbox. - - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols (PoW validation lives in `tracking.activity_ws` as a per-connection local; browser solver served at `/_contact/easypow.js`), the `/_m` admin inbox page route, and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). + - `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 validation lives in `tracking.activity_ws` as a per-connection local; browser solver served at `/_contact/easypow.js`), the admin unread count for `/_api/settings`, and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via ``, and llms.txt links point at the `.md` URLs. - `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `
` and the sitemap, recorded in analytics. - `data.py` — msgspec Structs for the kanta database. @@ -31,9 +31,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `frontend/src/` — Vue editor and public-page JS entries. - `main.js` — Vue editor app entry. - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). - - `messages-main.js` + `MessagesView.vue` — admin contact inbox page at `/_m` (docs/messages.md): thread list, replies and read marking over `/_api/ws/analytics`; owns its socket lifecycle like AnalyticsView. - `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` + `ContactPanel.vue` — public contact panel (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button is clicked; rides the `/_ws` activity socket through pagerite.js's bridge (register/send, shared queue and reconnect pacing) — no extra connection. + - `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: 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 only while open). - `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly. - `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state. - `pagerite.js` — public page entry. diff --git a/docs/messages.md b/docs/messages.md index 2986397..0a992c2 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -1,25 +1,30 @@ # Contact messages Visitor→admin messaging built into the site: a `{contact: Label}` directive -in article Markdown renders a button that opens a chat-style panel; messages -are stored in the kanta database and answered from the admin inbox on `/_m`. -Visitors are anonymous — a localStorage session token identifies their -thread — and everything realtime rides the two existing WebSockets; there is -no REST polling anywhere. +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 `/_m` inbox - page route and the `/_contact/files` upload route. + 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`). - `pagerite/tracking.py` — the sockets the protocol rides (see below). -- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`), the - `/_m` page render and the on-demand bundles' asset config. -- `frontend/src/contact-main.js` + `ContactPanel.vue` — the visitor panel, - imported on demand by `pagerite.js` on trigger clicks. +- `pagerite/api.py` — `/_api/settings` carries `contact_unread` (the admin + icon badge seed — the probe runs on every page load, no polling). +- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`) 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. -- `frontend/src/messages-main.js` + `MessagesView.vue` — the admin inbox - page at `/_m` (own pens-row icon, own socket lifecycle). ## Trigger @@ -38,8 +43,11 @@ 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`) drives the unread dot on trigger buttons when an -admin reply arrives while the panel is closed. +(`pagerite-contact-seen`) and the newest admin reply's timestamp +(`pagerite-contact-lastadmin`) drive the unread dot 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). The panel rides the public `/_ws` activity socket via a bridge in `pagerite.js` (`register`/`send`, sharing its queue and reconnect pacing — @@ -78,18 +86,24 @@ solves and retries once. Challenges are a per-connection local in the ## Admin side -Admins watch the inbox on its own page, **`/_m`** (✉️ icon in the pens -row), served by the existing `/_api/ws/analytics` socket (admin-gated like -all of `/_api`). 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); new messages and replies push single-thread updates. -Admin→server frames: `{type: "reply", id, text}` and `{type: "read", id}`. +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 only while the overlay is 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); 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. +message is the visitor's and newer than the admin's last read. The icon's +badge (a count of unanswered threads) is seeded by the `/_api/settings` +probe's `contact_unread` on every page load and kept live by the inbox +while it is open. ## Message format and the XSS model diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 820b1e9..fbb683b 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -3,9 +3,8 @@ // analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and // renders totals, smoothed visit/views curves, a transition map, and recent // visit/crawler tables. The same socket also carries the contact-messaging -// protocol (typed frames) for the admin inbox — that lives on its own /_m -// page now (MessagesView.vue, docs/messages.md), so typed frames are ignored -// here. +// 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. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { fetchJson } from 'paskia' @@ -89,7 +88,8 @@ function connect() { try { const msg = JSON.parse(event.data) // Contact-messaging frames (docs/messages.md) carry a type field and - // are for the /_m inbox; the type-less snapshot is analytics data. + // are for the admin inbox overlay; the type-less snapshot is + // analytics data. if (msg?.type) return data.value = msg if (!rangePinned) { diff --git a/frontend/src/ContactOverlay.vue b/frontend/src/ContactOverlay.vue new file mode 100644 index 0000000..38b00d9 --- /dev/null +++ b/frontend/src/ContactOverlay.vue @@ -0,0 +1,121 @@ + + + + + + + diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue index 927eca3..5a31c40 100644 --- a/frontend/src/ContactPanel.vue +++ b/frontend/src/ContactPanel.vue @@ -2,8 +2,9 @@ // 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, thread, unread}). -import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' +// store ({open, mode, thread, unread, ...}). The overlay shell (backdrop, +// dialog frame, close) is ContactOverlay.vue. +import { computed, nextTick, reactive, ref, watch } from 'vue' import { formatWhen } from './analytics/format.js' import Composer from './Composer.vue' @@ -11,7 +12,7 @@ const props = defineProps({ state: { type: Object, required: true }, token: { type: String, required: true }, }) -const emit = defineEmits(['send', 'close']) +const emit = defineEmits(['send']) const scroll = ref(null) @@ -26,13 +27,6 @@ watch(() => props.state.open, (open) => { if (open) scrollDown() }) // Relative timestamps like in analytics. const fmtTime = (t) => formatWhen(t) -// 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)) - const NAME_KEY = 'pagerite-contact-name' const EMAIL_KEY = 'pagerite-contact-email' @@ -60,104 +54,37 @@ function onSend(md) { -