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) { diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index a28e975..2fdad55 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -407,6 +407,47 @@ body.editing #sidebar { text-decoration: none; } +/* The corner ✉️ (docs/messages.md) is in the pens row for everyone, so + needs its own positioning context for the unread markers: the same + accent dot as the trigger buttons (visitors: a new reply) or a count + pill (admins: unanswered threads). The marker lifts the icon out of the + icon-btn dimming so it reads clearly. */ +.contact-icon { + position: relative; +} + +.contact-icon.has-unread { + opacity: 1; +} + +.contact-icon.has-unread::after { + content: ""; + position: absolute; + top: -0.2em; + right: -0.25em; + width: 0.5em; + height: 0.5em; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 0.12em var(--bg); +} + +.contact-icon .n { + position: absolute; + top: -0.5em; + right: -0.7em; + min-width: 1.3em; + padding: 0.05em 0.3em; + border-radius: 1em; + background: var(--accent); + color: var(--bg); + font-size: 0.62em; + font-weight: 700; + line-height: 1.3; + text-align: center; + opacity: 1; +} + #sidebar { grid-column: 1; /* Pinned to the page's start edge (not the article's) and kept in view diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index 9dc5753..d0ad406 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -1,26 +1,42 @@ -// Contact panel entry: imported on demand by pagerite.js when a -// {contact:} trigger button is clicked. The panel rides the activity -// socket through the bridge pagerite.js hands over ({send, register}) — -// no extra connection is opened. The session token is a localStorage -// random; the server stores only its hash. +// Messaging overlay entry: imported on demand by pagerite.js when a +// {contact:} trigger button or the corner ✉️ icon is clicked. 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 only while open +// (MessagesView). The session token is a localStorage random; the server +// stores only its hash. import { createApp, reactive, watch } from 'vue' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' -import ContactPanel from './ContactPanel.vue' +import ContactOverlay from './ContactOverlay.vue' 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' let app = null -const state = reactive({ open: false, thread: null, unread: false }) +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 panel is main content, not a sidebar: while open it holds paskia's -// shared blur backdrop (refcounted, z-index 1099; the panel itself sits at -// 1100, below the auth iframe's 9999). +// 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) { @@ -89,29 +105,50 @@ async function onPow(msg) { } } -// A "new reply" dot on every contact trigger button while the panel is -// closed and the newest admin message is younger than the last look. -// Called on thread pushes and (from pagerite.js) after navigations, -// whose swap rebuilds the buttons. +// Unread markers: a dot on every contact trigger button while the panel +// is closed and the newest admin message is younger than the last look, +// and on the corner ✉️ icon — a count of unanswered threads for admins, +// the same dot for visitors. 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 + if (state.viewerAdmin) { + let badge = icon.querySelector('.n') + if (state.adminUnread > 0) { + if (!badge) { + badge = document.createElement('span') + badge.className = 'n' + icon.append(badge) + } + badge.textContent = state.adminUnread + } else { + badge?.remove() + } + } else { + icon.classList.toggle('has-unread', state.unread) + } } function onThread(thread) { state.thread = thread const lastAdmin = [...thread.messages].reverse().find((m) => m.admin) - state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !state.open - if (state.open) markSeen() + 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 + if (viewing) markSeen() else refreshBadges() } -export function open(br) { +export function open(br, { admin = false } = {}) { bridge = br + if (admin) state.viewerAdmin = true if (!app) { token = ensureToken() - app = createApp(ContactPanel, { + app = createApp(ContactOverlay, { state, token, onSend: ({ md, name, email }) => sendMessage(md, name, email), @@ -126,8 +163,12 @@ export function open(br) { app.mount(el) // Registration doubles as the resync: the server pushes the thread // state back, and pagerite.js re-registers on every socket reconnect. + // Registered on first open in either mode — the visitor thread is + // also the admin's own (the trigger buttons open it for them). bridge.register(token, onThread, onPow) } + state.mode = admin ? 'admin' : 'visitor' state.open = true - markSeen() + if (!admin) markSeen() + else refreshBadges() } diff --git a/frontend/src/messages-main.js b/frontend/src/messages-main.js deleted file mode 100644 index 009358f..0000000 --- a/frontend/src/messages-main.js +++ /dev/null @@ -1,30 +0,0 @@ -// Messages page entry: mounts MessagesView (the admin contact inbox) inside -// the normal page layout. In production the backend inlines this module into -// the /_m page (and pagerite.js re-creates the script element after -// fetch-navigations there); in dev pagerite.js imports it from the Vite dev -// server on demand. Either way it auto-mounts on #messages-app when it -// evaluates, and unmounts when pagerite.js announces a swap away from /_m. -import { createApp } from 'vue' -import MessagesView from './MessagesView.vue' - -let app = null - -export function mount(container) { - if (app) return - app = createApp(MessagesView) - app.mount(container) -} - -export function unmount() { - app?.unmount() - app = null -} - -// pagerite.js calls this before swapping away from /_m; each evaluation -// (the inlined production module evaluates fresh on every visit) replaces -// the handle. -window.__pageriteMessagesUnmount = unmount - -// Auto-mount when the page holding #messages-app is present. -const container = document.getElementById('messages-app') -if (container) mount(container) diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index bd9bfc7..be711b5 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -182,6 +182,38 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; 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; + 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" : "contact us"; + b.textContent = "✉️"; + if (admin) { + if (adminUnreadSeed > 0) { + const badge = document.createElement("span"); + badge.className = "n"; + badge.textContent = adminUnreadSeed; + b.append(badge); + } + } 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 // 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, @@ -213,25 +245,21 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; // Editing is open for admins and, as a dev/no-proxy fallback, when no // Paskia SSO is detected at all. const canEdit = isAdmin || !ssoAvailable; - // The analytics and messages pages are tool dashboards: editing pens - // and the side panel do not apply there. Login/logout links are - // still useful. - const onToolPage = currentPath === "/_a" || currentPath === "/_m"; + // The analytics page is a read-only dashboard: editing pens and the + // side panel do not apply there. Login/logout links are still useful. + const onToolPage = currentPath === "/_a"; if (document.getElementById("page-banner")) { const pens = pensContainer(); + // The contact icon is there for everyone. + pens.append(makeContactIcon(canEdit)); if (canEdit && !onToolPage) { - // The tool viewers are normal pages at /_a and /_m. + // Analytics viewer is a normal page at /_a. const a = document.createElement("a"); a.className = "edit-link analytics-link icon-btn"; a.href = "/_a"; a.title = "analytics"; a.textContent = "📊"; - const m = document.createElement("a"); - m.className = "edit-link messages-link icon-btn"; - m.href = "/_m"; - m.title = "messages"; - m.textContent = "✉️"; - pens.append(a, m); + pens.append(a); pens.append(makePen("site")); } if (ssoAvailable) pens.append(makeAuthButton(isAdmin)); @@ -269,8 +297,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; isAdmin = false; try { - await fetchJson("/_api/settings"); + const settings = await fetchJson("/_api/settings"); isAdmin = true; + // Seeds the contact icon's admin badge (docs/messages.md). + adminUnreadSeed = settings.contact_unread || 0; } catch { // Anonymous, no pagerite:admin, or no auth proxy / dev. } @@ -278,8 +308,8 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; if (isAdmin) { // Warm the cache with the editor bundle: the hashed asset is // immutable, so preloading costs nothing and the pens then open - // instantly. The tool pages have no editor. - if (currentPath !== "/_a" && currentPath !== "/_m" && !import.meta.env.DEV) { + // instantly. The analytics page has no editor. + if (currentPath !== "/_a" && !import.meta.env.DEV) { const preload = document.createElement("link"); preload.rel = "modulepreload"; preload.href = src; @@ -592,8 +622,8 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; // activity reconnects and the server sees a new session. // Excluded: back/forward (popstate never reports), everything while the // editor is open (body.editing — admin noise, not visits), and - // navigations TO the tool pages (/_a, /_m — admin machinery). Navigations - // AWAY from a tool page must report: load() already fetched the target page + // navigations TO the analytics page (/_a — admin machinery). Navigations + // AWAY from /_a must report: load() already fetched the target page // without the preload header, and without the message that GET would flush // to the crawler list. // Admins (when SSO is actually in use — with no auth proxy "admin" is @@ -797,18 +827,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; } // --- Admin tool pages mount/unmount ------------------------------------ - // /_a (analytics) and /_m (contact inbox) are normal pages whose bodies - // are rendered by the server but whose content is a Vue app. In dev the + // /_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 the Vite dev server on demand; in // production it is inlined into the page as a script element, which a // fetch-navigation swap does not execute — re-create the element so the // fresh module auto-mounts on its container. Each module exposes its - // unmount as a window global (see analytics-main.js / messages-main.js). + // unmount as a window global (see analytics-main.js). const TOOL_PAGES = { "/_a": { container: "analytics-app", script: "pagerite-js-analytics", asset: "pagerite:analytics-src", unmount: "__pageriteAnalyticsUnmount" }, - "/_m": { container: "messages-app", script: "pagerite-js-messages", - asset: "pagerite:messages-src", unmount: "__pageriteMessagesUnmount" }, }; function isToolPage(path) { @@ -905,11 +933,13 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; ping({ to: currentPath, lang: tag }); }); - // --- Contact panel ------------------------------------------------------ - // {contact: Label} directives in articles render .contact-trigger buttons. - // The panel bundle (Vue composer + thread view) loads on first click and - // rides the activity socket for realtime thread state — no extra - // connection (docs/messages.md). Registration re-sends on every socket + // --- 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 = ""; @@ -935,7 +965,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; }, }; - async function openContactPanel() { + async function openContactPanel(opts) { try { contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]); for (const css of (assets["pagerite:contact-css"] || "").split(",")) { @@ -947,7 +977,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; document.head.append(link); } } - contactMod.open(contactBridge); + contactMod.open(contactBridge, opts); } catch (e) { console.error("contact panel load failed:", e); } @@ -959,18 +989,19 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; ev.preventDefault(); // In the editor preview the button is inert. if (document.body.classList.contains("editing")) return; - openContactPanel(); + // Always the visitor side, even for admins (testing both sides). + openContactPanel({ admin: false }); }); // --- Fetch navigation ------------------------------------------------ async function load(url, push = true, back = false) { // Navigating with the editor open keeps it open: the shell retargets to - // the new page once the swap lands (below). The exception is the tool - // pages (/_a analytics, /_m inbox), where the panel does not apply — - // close it there, with navigating: true so the close does not - // re-swap/re-title the page it was previewing (this navigation is - // already swapping). The cache is bypassed while editing because the - // editor may have invalidated the prefetched copies of other pages. + // the new page once the swap lands (below). The exception is /_a + // (analytics), where the panel does not apply — close it there, with + // navigating: true so the close does not re-swap/re-title the page it + // was previewing (this navigation is already swapping). The cache is + // bypassed while editing because the editor may have invalidated the + // prefetched copies of other pages. const editing = document.body.classList.contains("editing"); if (editing && isToolPage(new URL(url, location.href).pathname)) { await editorModule?.then((m) => m.closeEditor({ navigating: true })); @@ -1021,9 +1052,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; } // Stylesheets live in with stable ids — links in dev, inline //