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

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