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

+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);
})();