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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user