contact messages: standalone /_m admin inbox, site-name reply label

The inbox moves out of the analytics tab to its own page at /_m (own pens-row icon, own entry and socket lifecycle; the admin-gated /_api/ws/analytics socket keeps serving both, with typed messaging frames ignored by AnalyticsView). Admin replies are labeled with the currently configured site name in the wire payload (the SSO name stays stored for audit), and the thread view is capped to roughly the visitor panel's width.
This commit is contained in:
LeoVasanko committed 2026-09-27 18:59:22 +00:00
1 parent f4d1f0ac58
commit 4ad4e3bdff
11 files changed
+535 -209

No files matched your search

+2 -2
View File
@@ -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`), 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 `/_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).
- `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.
@@ -31,10 +31,10 @@ 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.
- `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.
- `MessagesView.vue` — the admin contact inbox (messages tab of `AnalyticsView.vue` on `/_a`): thread list, replies and read marking over `/_api/ws/analytics`.
- `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state.
- `pagerite.js` — public page entry.
- `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`).
+19 -16
View File
@@ -2,24 +2,24 @@
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 `/_a`.
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.
- `pagerite/messages.py` — the feature's core: the strict message renderer,
thread store ops, wire payloads, both socket protocols and the
`/_contact/files` upload route.
thread store ops, wire payloads, both socket protocols, the `/_m` inbox
page route 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`) and the
on-demand bundle's asset config.
- `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.
- `frontend/src/Composer.vue` + `richtext.js` — the shared composer and its
DOM→Markdown serializer.
- `frontend/src/MessagesView.vue` — the admin inbox (the messages tab of
`AnalyticsView.vue` on `/_a`).
- `frontend/src/messages-main.js` + `MessagesView.vue` — the admin inbox
page at `/_m` (own pens-row icon, own socket lifecycle).
## Trigger
@@ -78,16 +78,18 @@ solves and retries once. Challenges are a per-connection local in the
## Admin side
Admins watch the inbox as the **messages tab on `/_a`**, 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 the client tells them apart.
On connect the server sends `{type: "threads", threads: [...]}` (100 most
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}`.
Replies are authored with the SSO `remote-user` name 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.
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 format and the XSS model
@@ -151,7 +153,8 @@ 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) and `page`.
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`).
+12 -74
View File
@@ -2,9 +2,10 @@
// Analytics viewer rendered as a normal page inside #main. Receives live
// 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 serves the contact inbox (the
// "messages" tab, MessagesView): visitor threads arrive with the snapshot
// and replies go back down it (docs/messages.md).
// 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.
// See docs/analytics.md for the data format.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { fetchJson } from 'paskia'
@@ -33,7 +34,6 @@ import VisitorCharts from './VisitorCharts.vue'
import { VIEW_W } from './analytics/chart.js'
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import ConnNote from './ConnNote.vue'
import MessagesView from './MessagesView.vue'
// Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width.
@@ -73,28 +73,6 @@ const hashRange = location.hash.slice(1)
const range = ref(RANGES[hashRange] ? hashRange : 'week')
let rangePinned = Boolean(RANGES[hashRange])
// The contact inbox rides the same socket (docs/messages.md): frames with
// a `type` field are messaging payloads, the type-less snapshot is
// analytics. The header switches between the two views.
const tab = ref('analytics')
const threads = ref([])
const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread ? 1 : 0), 0))
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)
threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a)))
}
function wsSend(obj) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
}
function onReply({ id, text }) { wsSend({ type: 'reply', id, text }) }
function onRead(id) { wsSend({ type: 'read', id }) }
function connect() {
if (ws || !mounted) return
conn.value = 'connecting'
@@ -110,20 +88,9 @@ function connect() {
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
// Contact-messaging frames carry a type field (docs/messages.md);
// the type-less snapshot is analytics data.
if (msg?.type === 'threads') {
threads.value = msg.threads
return
}
if (msg?.type === 'thread') {
upsertThread(msg)
return
}
if (msg?.type === 'contact-error') {
console.warn('contact:', msg.detail)
return
}
// Contact-messaging frames (docs/messages.md) carry a type field and
// are for the /_m inbox; the type-less snapshot is analytics data.
if (msg?.type) return
data.value = msg
if (!rangePinned) {
rangePinned = true
@@ -340,15 +307,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
<div class="analytics-panel">
<header>
<h1>Analytics</h1>
<nav class="tabs">
<button type="button" :class="{ active: tab === 'analytics' }" @click="tab = 'analytics'">
analytics
</button>
<button type="button" :class="{ active: tab === 'messages' }" @click="tab = 'messages'">
messages<span v-if="unreadTotal" class="badge">{{ unreadTotal }}</span>
</button>
</nav>
<nav v-show="tab === 'analytics'" class="ranges">
<nav class="ranges">
<button v-for="(r, key) in RANGES" :key="key" type="button"
:class="{ active: range === key }" @click="range = key">
{{ r.label }}
@@ -357,8 +316,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
<a href="/" class="close" title="home">✕</a>
</header>
<ConnNote :text="connNote" />
<MessagesView v-if="tab === 'messages'" :threads="threads" @reply="onReply" @read="onRead" />
<div v-show="tab === 'analytics'">
<div>
<p v-if="error" class="error">⚠️ {{ error }}</p>
<p v-else-if="!data" class="loading">loading…</p>
<template v-else>
@@ -535,8 +493,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
margin-left: auto;
}
.ranges button,
.tabs button {
.ranges button {
padding: 0.2rem 0.7rem;
font: inherit;
font-size: 0.9rem;
@@ -547,32 +504,13 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
cursor: pointer;
}
.ranges button:hover,
.tabs button:hover { color: var(--text); }
.ranges button:hover { color: var(--text); }
.ranges button.active,
.tabs button.active {
.ranges button.active {
color: var(--text);
border-color: var(--accent);
}
.tabs {
display: flex;
gap: 0.25rem;
}
.tabs .badge {
display: inline-block;
min-width: 1.1em;
margin-left: 0.3em;
padding: 0 0.25em;
border-radius: 1em;
background: var(--accent);
color: var(--bg, Canvas);
font-size: 0.8em;
text-align: center;
}
.close {
padding: 0 0.3rem;
background: none;
+297 -56
View File
@@ -1,28 +1,219 @@
<script setup>
// The admin contact inbox on /_a: thread list plus the selected thread
// with a reply composer. Threads and replies ride the analytics socket
// (AnalyticsView owns it and passes them down); message HTML is the
// The admin contact inbox, rendered as a normal page at /_m inside
// #messages-app. Owns its connection to the admin-gated /_api/ws/analytics
// socket (the same endpoint the analytics viewer uses): 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, ref, watch } from 'vue'
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'
const props = defineProps({
threads: { type: Array, default: () => [] },
})
const emit = defineEmits(['reply', 'read'])
const threads = ref([])
const selected = ref('')
const scroll = ref(null)
const thread = computed(() => props.threads.find((t) => t.id === selected.value) || 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
// 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…'
: '',
)
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)
threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a)))
}
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
} else if (msg?.type === 'thread') {
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) leaves for the front page; 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') {
goHome()
} else {
scheduleRetry(wait)
}
}
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 /_m 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 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 leaves for the front page — the inbox 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
}
goHome()
}
}
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(
() => props.threads.length,
() => threads.value.length,
(n) => {
if (n && !selected.value) selected.value = props.threads[0].id
if (n && !selected.value) selected.value = threads.value[0].id
},
{ immediate: true },
)
@@ -31,7 +222,7 @@ watch(
// comes back around and clears the dot).
watch(thread, (t) => {
if (!t) return
if (t.unread) emit('read', t.id)
if (t.unread) wsSend({ type: 'read', id: t.id })
scrollDown()
})
@@ -61,63 +252,113 @@ function preview(t) {
const fmtTime = (t) => formatWhen(t)
function send(md) {
if (thread.value) emit('reply', { id: thread.value.id, text: md })
if (thread.value) wsSend({ type: 'reply', id: thread.value.id, text: md })
}
</script>
<template>
<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 }"
@click="selected = t.id"
>
<span class="dot" :class="{ unread: t.unread }"></span>
<span class="peek">
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
{{ 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>
<!-- Untranslated admin view: always LTR, like the editor panel. -->
<div class="messages-page" lang="en" dir="ltr">
<div class="messages-panel">
<header class="view-head">
<h1>Messages</h1>
<a href="/" class="close" title="home">✕</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>
<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 }"
@click="selected = t.id"
>
<span class="dot" :class="{ unread: t.unread }"></span>
<span class="peek">
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
{{ 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 class="contact-msg-body" v-html="m.html"></div>
</div>
<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="Reply…" @send="send" />
</section>
<section v-else class="thread">
<p class="empty">select a thread</p>
</section>
</div>
<Composer placeholder="Reply…" @send="send" />
</section>
<section v-else class="thread">
<p class="empty">select a thread</p>
</section>
</div>
</div>
</template>
<style scoped>
.messages-page {
min-height: 100vh;
background: var(--bg, Canvas);
color: var(--text, CanvasText);
}
.messages-panel {
margin: 0;
width: 100%;
/* Same 1.25rem side spacing as main's article padding. */
padding: 1.5rem 1.25rem 4rem;
}
.view-head {
display: flex;
align-items: center;
gap: 1rem;
/* Roughly the article column: the thread renders much like the
visitor's narrow panel. */
max-width: 41rem;
margin-inline: auto;
}
.view-head h1 {
margin: 0;
font-size: 1.4rem;
}
.close {
margin-left: auto;
padding: 0 0.3rem;
background: none;
border: none;
color: var(--muted);
font-size: 1.2rem;
cursor: pointer;
}
.close:hover { color: var(--text); }
.messages-view {
display: flex;
gap: 1rem;
margin-top: 1rem;
margin: 1rem auto 0;
max-width: 41rem;
min-height: 60vh;
}
+30
View File
@@ -0,0 +1,30 @@
// 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)
+72 -52
View File
@@ -213,25 +213,31 @@ 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 page is a read-only dashboard: editing pens and the side
// panel do not apply there. Login/logout links are still useful.
const onAnalytics = currentPath === "/_a";
// 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";
if (document.getElementById("page-banner")) {
const pens = pensContainer();
if (canEdit && !onAnalytics) {
// Analytics viewer is now a normal page at /_a.
if (canEdit && !onToolPage) {
// The tool viewers are normal pages at /_a and /_m.
const a = document.createElement("a");
a.className = "edit-link analytics-link icon-btn";
a.href = "/_a";
a.title = "analytics";
a.textContent = "📊";
pens.append(a);
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(makePen("site"));
}
if (ssoAvailable) pens.append(makeAuthButton(isAdmin));
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
// bundle has been loaded once).
@@ -272,8 +278,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 analytics page has no editor.
if (currentPath !== "/_a" && !import.meta.env.DEV) {
// instantly. The tool pages have no editor.
if (currentPath !== "/_a" && currentPath !== "/_m" && !import.meta.env.DEV) {
const preload = document.createElement("link");
preload.rel = "modulepreload";
preload.href = src;
@@ -586,8 +592,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 analytics page (/_a — admin machinery). Navigations
// AWAY from /_a must report: load() already fetched the target page
// navigations TO the tool pages (/_a, /_m — admin machinery). Navigations
// AWAY from a tool page 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
@@ -656,7 +662,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
function report(msg) {
if (document.body.classList.contains("editing")) return;
if (msg.to === "/_a") return;
if (isToolPage(msg.to)) return;
if (ssoAvailable && isAdmin) msg.hide = true;
activityWs();
if (ws?.readyState === WebSocket.OPEN) {
@@ -669,9 +675,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
}
function ping({ to, fr = currentPath, read = 0, lang } = {}) {
// Reading-time updates from the analytics page itself are not tracked
// (/_a is admin machinery; the server would reject the path anyway).
if (!to && currentPath === "/_a") return;
// Reading-time updates from the tool pages themselves are not tracked
// (admin machinery; the server would reject the path anyway).
if (!to && isToolPage(currentPath)) return;
const msg = {};
if (fr) msg.fr = fr;
if (to) msg.to = to;
@@ -790,27 +796,41 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
ping({ to: currentPath, fr: "" });
}
// --- Analytics page mount/unmount --------------------------------------
// The analytics page 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 /_a
// page as script#pagerite-js-analytics, which a fetch-navigation swap does
// not execute — re-create the element so the fresh module auto-mounts on
// #analytics-app (see analytics-main.js). The module exposes its unmount
// as window.__pageriteAnalyticsUnmount.
function teardownAnalytics() {
// Remove even the server-rendered script element so a later return to
// /_a re-mounts from a fresh copy (the module has torn itself down).
document.getElementById("pagerite-js-analytics")?.remove();
window.__pageriteAnalyticsUnmount?.();
window.__pageriteAnalyticsUnmount = null;
// --- 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
// 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).
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) {
return Object.hasOwn(TOOL_PAGES, path || "");
}
async function mountAnalytics(doc) {
if (!doc.getElementById("analytics-app")) return;
// Already mounted: on a full /_a load the inline script has run.
if (document.getElementById("pagerite-js-analytics")) return;
const inline = doc.getElementById("pagerite-js-analytics");
function teardownTools() {
// Remove even the server-rendered script element so a later return
// re-mounts from a fresh copy (the module has torn itself down).
for (const p of Object.values(TOOL_PAGES)) {
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) {
const s = document.createElement("script");
for (const a of inline.attributes) s.setAttribute(a.name, a.value);
@@ -822,11 +842,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// Dev: the cached module auto-mounts only on its first evaluation,
// so call mount() explicitly for repeat visits (it no-ops when the
// app is already up).
const mod = await import(/* @vite-ignore */ assets["pagerite:analytics-src"]);
const container = document.getElementById("analytics-app");
const mod = await import(/* @vite-ignore */ assets[page.asset]);
const container = document.getElementById(page.container);
if (container) mod.mount(container);
} catch (e) {
console.error("analytics mount failed:", e);
console.error("tool page mount failed:", e);
}
}
@@ -945,17 +965,17 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// --- 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 /_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.
// 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.
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 }));
}
teardownAnalytics();
teardownTools();
let doc;
let finalUrl = url;
const cached = !editing && pageCache.get(cacheKey(url));
@@ -1001,9 +1021,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
}
// Stylesheets live in <head> with stable ids — links in dev, inline
// <style> elements in production — and must follow the swap: the
// analytics sheet exists on /_a only, and theme/banner/custom CSS
// may have changed since this page was loaded. Diff by id, keeping
// the fresh document's order; unchanged sheets keep their elements
// analytics sheet exists on /_a (and the messages sheet on /_m)
// only, and theme/banner/custom CSS may have changed since this page
// was loaded. Diff by id, keeping the fresh document's order; unchanged sheets keep their elements
// so their @keyframes are never torn down. Editor-injected sheets
// (data-pagerite, no id) and Vite's dev styles (no id) are left
// alone. Mirrors the head sync in swapdoc.js.
@@ -1049,7 +1069,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
runScripts(document.getElementById("page-banner"));
runScripts(document.getElementById("main"));
applyEffects();
mountAnalytics(doc);
mountTool(doc);
mountLangselect(doc);
// The swap rebuilt any contact-trigger buttons: re-apply the
// "new reply" dot to the fresh elements.
@@ -1160,8 +1180,8 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
return;
}
// Machinery and auth endpoints are never fetch-navigated, except the
// public analytics viewer page at /_a.
if ((url.pathname.startsWith("/_") && url.pathname !== "/_a")
// admin tool pages (/_a analytics, /_m inbox).
if ((url.pathname.startsWith("/_") && !isToolPage(url.pathname))
|| url.pathname.startsWith("/auth")) return;
ev.preventDefault();
// Capture the source now: load() updates currentPath before pinging.
@@ -1281,6 +1301,6 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
setupAuth();
applyEffects();
mountAnalytics(document);
mountTool(document);
mountLangselect(document);
})();
+2 -1
View File
@@ -16,7 +16,7 @@ const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
// https://vite.dev/config/
export default defineConfig({
plugins: [
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate", "/_contact"] }),
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_m", "/_ws", "/_translate", "/_contact"] }),
vue(),
vueDevTools(),
],
@@ -50,6 +50,7 @@ export default defineConfig({
main: fileURLToPath(new URL('./src/main.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)),
messages: fileURLToPath(new URL('./src/messages-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
+31 -7
View File
@@ -5,10 +5,11 @@ 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 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.
Admins watch and reply from the /_m inbox page (below) 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 with every
@@ -48,7 +49,7 @@ 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
from pagerite.state import _html_response, data, kanta
logger = logging.getLogger(__name__)
@@ -146,7 +147,10 @@ def _unread(thread: ContactThread) -> bool:
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."""
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(),
@@ -158,7 +162,7 @@ def _payload(key: bytes, thread: ContactThread) -> dict:
{
"id": mid,
"t": m.t.isoformat(),
"admin": m.admin,
"admin": brand if m.admin else "",
"html": m.html,
"page": m.page,
}
@@ -357,6 +361,26 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None:
await _push(_admin_ws, _payload(key, thread))
# --- Admin inbox page -------------------------------------------------------
@router.get("/_m", response_model=None)
async def messages_page(request: Request) -> Response:
"""Render the admin contact inbox as a normal site page at /_m.
Like /_a, the page shell itself is public, but the data stream
(/_api/ws/analytics, which carries the admin messaging protocol) stays
admin-gated like the rest of /_api.
"""
return _html_response(
request,
"messages",
"",
headers={"cache-control": "no-cache"},
etag=True,
)
# --- Uploads -----------------------------------------------------------------
#: Contact-panel uploads: images (raster and SVG) go through the usual
+10
View File
@@ -153,6 +153,16 @@ def _render_html(
data.brand_html,
transition=data.transition,
)
if kind == "messages":
return views.render_messages(
data.menu,
data.brand,
data.custom_css,
data.theme,
data.favicon,
data.brand_html,
transition=data.transition,
)
return views.render_analytics(
data.menu,
data.brand,
+2 -1
View File
@@ -528,7 +528,8 @@ async def analytics_websocket(ws: WebSocket) -> None:
Admin-only via the /_api forward-auth gate, like every management
endpoint. Powers the analytics viewer rendered at /_a.
The same socket serves the admin contact inbox (docs/messages.md):
The same socket serves the admin contact inbox rendered at /_m
(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;
+58
View File
@@ -419,6 +419,7 @@ def _layout(
config = {
"pagerite:editor-src": editor_scripts[-1],
"pagerite:analytics-src": _analytics_assets()[0][0],
"pagerite:messages-src": _messages_assets()[0][0],
"pagerite:langselect-src": langselect_scripts[-1],
"pagerite:contact-src": contact_scripts[-1],
}
@@ -1575,6 +1576,20 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
return _asset_cache["analytics"]
def _messages_assets() -> tuple[list[str], list[str]]:
"""Script and stylesheet URLs for the admin contact-inbox page entry."""
vite_url = env.vite_url
if vite_url:
return [f"{vite_url}/src/messages-main.js"], []
if "messages" not in _asset_cache:
manifest = _manifest()
entry = manifest["src/messages-main.js"]
scripts = [f"/{entry['file']}"]
stylesheets = [f"/{css}" for css in entry.get("css", [])]
_asset_cache["messages"] = scripts, stylesheets
return _asset_cache["messages"]
def _langselect_assets() -> tuple[list[str], list[str]]:
"""Script and stylesheet URLs for the on-demand public language selector."""
vite_url = env.vite_url
@@ -1668,3 +1683,46 @@ def render_analytics(
Main=HTML(str(doc)),
),
)
def render_messages(
menu: dict[str, Node],
brand: str = SITE_NAME,
custom_css: str = "",
theme: str = "",
favicon: str = "",
brand_html: str = "",
transition: str = "cube",
) -> str:
"""Render the admin contact inbox as a normal page at /_m.
Same shell treatment as render_analytics (entry inlined in prod, loaded
from the Vite dev server in dev), minus the .wide breakout: the thread
view keeps roughly the article column's width so it renders much like
the visitor's panel.
"""
page_scripts, page_stylesheets = _page_assets()
messages_scripts, messages_stylesheets = _messages_assets()
scripts = page_scripts + messages_scripts
stylesheets = page_stylesheets + messages_stylesheets
doc = E.article
with doc:
doc.div(id="messages-app")
return str(
_layout(
scripts,
stylesheets,
custom_css,
theme,
banner_design(menu, "_m", theme),
transition,
favicon,
)(
Title=f"Messages – {brand}" if brand else "Messages",
Brand=_brand_link(brand, brand_html),
Nav=nav_html(menu, "_m"),
Sidebar=sidebar_html(menu, "_m"),
Banner=banner_html(menu, "_m", theme),
Main=HTML(str(doc)),
),
)