contact messages: unified overlay, corner ✉️ icon for everyone

The admin inbox leaves the short-lived /_m page and joins the visitor panel as one overlay (ContactOverlay shell over paskia's shared blur backdrop, centered dialog, full-screen on mobile): the corner ✉️ icon opens the visitor thread for visitors and the wider sidebar inbox for admins, while {contact:} trigger buttons always open the visitor side so admins can test both. The icon highlights unread state without extra connections: visitors via localStorage seen/last-admin timestamps, admins via the contact_unread count added to the /_api/settings probe, both kept live by the bundle once loaded. MessagesView owns its admin socket only while the overlay is open.
This commit is contained in:
LeoVasanko committed 2026-09-28 01:37:37 +00:00
1 parent 9a2435f03a
commit afa6b967e6
16 files changed
+451 -427

No files matched your search

+2 -3
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`), 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 `<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,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.
+38 -24
View File
@@ -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
+4 -4
View File
@@ -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) {
+121
View File
@@ -0,0 +1,121 @@
<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 },
token: { type: String, required: true },
})
const emit = defineEmits(['send', 'close'])
const admin = computed(() => props.state.mode === 'admin')
// 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' : 'Contact us'"
>
<header>
<h2>{{ admin ? 'Messages' : 'Contact us' }}</h2>
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
</header>
<ContactPanel v-if="!admin" :state="state" :token="token" @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;
}
.contact-panel h2 {
margin: 0;
font-size: 1.1rem;
}
.contact-panel .close {
margin-left: auto;
padding: 0 0.3rem;
border: none;
background: none;
color: var(--muted);
font-size: 1.2rem;
cursor: pointer;
}
.contact-panel .close: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>
+27 -100
View File
@@ -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) {
</script>
<template>
<div v-if="state.open" class="contact-overlay" @click.self="emit('close')">
<section class="contact-panel" role="dialog" aria-label="Contact us">
<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>
<h2>Contact us</h2>
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
<span class="who">{{ m.admin || state.thread?.name || 'you' }}</span>
<time>{{ fmtTime(m.t) }}</time>
</header>
<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" autofocus @send="onSend" />
</section>
<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" autofocus @send="onSend" />
</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);
}
/* Mobile: full screen. */
@media (max-width: 36rem) {
.contact-panel {
width: 100%;
height: 100%;
border: none;
border-radius: 0;
box-shadow: none;
}
}
.contact-panel header {
display: flex;
align-items: center;
}
.contact-panel h2 {
margin: 0;
font-size: 1.1rem;
}
.contact-panel .close {
margin-left: auto;
padding: 0 0.3rem;
border: none;
background: none;
color: var(--muted);
font-size: 1.2rem;
cursor: pointer;
}
.contact-panel .close:hover {
color: var(--text, CanvasText);
}
.contact-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
+72 -114
View File
@@ -1,18 +1,25 @@
<script setup>
// 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).
// 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'
// 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)
@@ -37,6 +44,9 @@ const connNote = computed(() =>
: '',
)
const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread ? 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
}
@@ -87,8 +97,8 @@ function connect() {
// 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.
// proxy offering no paskia flow) closes the inbox; anything else gets the
// paced backoff.
async function onSocketClose() {
ws = null
if (!mounted) return
@@ -99,7 +109,7 @@ async function onSocketClose() {
if (outcome === 'auth') {
connectNow()
} else if (outcome === 'abort') {
goHome()
props.state.open = false
} else {
scheduleRetry(wait)
}
@@ -112,12 +122,6 @@ function scheduleRetry(wait) {
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
@@ -161,8 +165,8 @@ function onPageShow(ev) {
// 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.
// 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
@@ -178,7 +182,7 @@ function onAuthMessage(ev) {
ws.close()
ws = null
}
goHome()
props.state.open = false
}
}
@@ -257,109 +261,60 @@ function send(md) {
</script>
<template>
<!-- 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>
<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>
<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>
<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 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 class="contact-msg-body" v-html="m.html"></div>
</div>
</div>
</div>
<Composer placeholder="Reply…" @send="send" />
</section>
<section v-else class="thread">
<p class="empty">select a thread</p>
</section>
</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 {
flex: 1;
min-height: 0;
display: flex;
gap: 1rem;
margin: 1rem auto 0;
max-width: 41rem;
min-height: 60vh;
}
.thread-list {
@@ -368,6 +323,7 @@ function send(md) {
gap: 0.2rem;
width: 16rem;
flex-shrink: 0;
overflow-y: auto;
}
.thread-row {
display: flex;
@@ -419,11 +375,12 @@ function send(md) {
flex-direction: column;
gap: 0.6rem;
min-width: 0;
min-height: 0;
}
.thread-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
max-height: 65vh;
padding-right: 0.2rem;
}
.empty {
@@ -494,6 +451,7 @@ function send(md) {
}
.thread-list {
width: 100%;
max-height: 30%;
}
}
</style>
+41
View File
@@ -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
+59 -18
View File
@@ -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()
}
-30
View File
@@ -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)
+70 -39
View File
@@ -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 <head> with stable ids — links in dev, inline
// <style> elements in production — and must follow the swap: the
// 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
// 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
// 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.
@@ -1180,7 +1211,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
return;
}
// Machinery and auth endpoints are never fetch-navigated, except the
// admin tool pages (/_a analytics, /_m inbox).
// public analytics viewer page at /_a.
if ((url.pathname.startsWith("/_") && !isToolPage(url.pathname))
|| url.pathname.startsWith("/auth")) return;
ev.preventDefault();
+1 -2
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", "/_m", "/_ws", "/_translate", "/_contact"] }),
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate", "/_contact"] }),
vue(),
vueDevTools(),
],
@@ -50,7 +50,6 @@ 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
+4 -1
View File
@@ -23,7 +23,7 @@ from fastapi import (
from html5tagger import E
from pydantic import BaseModel
from pagerite import i18n, views
from pagerite import i18n, messages, views
from pagerite.chunks import store_chunks
from pagerite.data import (
Node,
@@ -293,6 +293,9 @@ async def get_settings() -> dict:
"transition": data.transition,
"transitions": views._transition_names(),
"translate_keys": data.translate_keys,
# Unanswered contact threads: the corner ✉️ icon's badge for
# admins (this probe runs on every page load; no polling).
"contact_unread": messages.unread_count(),
# The site default primary language: the front page's resolved
# setting (every page may override it, inherited down the tree).
"primary_lang": i18n.primary_lang(data.menu, ""),
+10 -22
View File
@@ -5,7 +5,8 @@ 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 /_m inbox page (below) over the
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
@@ -49,7 +50,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 _html_response, data, kanta
from pagerite.state import data, kanta
logger = logging.getLogger(__name__)
@@ -288,6 +289,13 @@ def visitor_disconnect(ws: WebSocket) -> None:
# --- 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)."""
return sum(1 for t in data.contact.values() if _unread(t))
def _key_from_hex(value) -> bytes | None:
try:
key = bytes.fromhex(str(value or ""))
@@ -361,26 +369,6 @@ 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,16 +153,6 @@ 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 -2
View File
@@ -528,8 +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 rendered at /_m
(docs/messages.md):
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;
-58
View File
@@ -419,7 +419,6 @@ 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],
}
@@ -1576,20 +1575,6 @@ 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
@@ -1683,46 +1668,3 @@ 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)),
),
)