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:
1 parent
9a2435f03a
commit
afa6b967e6
16 files changed
+451
-427
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user