contact messages: admin inbox on /_a

The analytics viewer gains a messages tab over the same socket: frames
with a type field are inbox payloads (thread list on connect, live
thread upserts), the type-less snapshot stays analytics. MessagesView
shows the thread list with unread dots and the selected thread with the
shared composer for replies; opening a thread marks it read server-side.
The header badge counts unread threads.
This commit is contained in:
LeoVasanko committed 2026-09-26 01:14:52 +00:00
1 parent eea89032a2
commit bbb11bf17e
2 files changed
+327 -6

No files matched your search

+77 -6
View File
@@ -2,7 +2,9 @@
// Analytics viewer rendered as a normal page inside #main. Receives live
// analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and
// renders totals, smoothed visit/views curves, a transition map, and recent
// visit/crawler tables. Read-only.
// visit/crawler tables. The same socket serves the contact inbox (the
// "messages" tab, MessagesView): visitor threads arrive with the snapshot
// and replies go back down it (docs/messages.md).
// See docs/analytics.md for the data format.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { fetchJson } from 'paskia'
@@ -31,6 +33,7 @@ import VisitorCharts from './VisitorCharts.vue'
import { VIEW_W } from './analytics/chart.js'
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import ConnNote from './ConnNote.vue'
import MessagesView from './MessagesView.vue'
// Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width.
@@ -70,6 +73,28 @@ const hashRange = location.hash.slice(1)
const range = ref(RANGES[hashRange] ? hashRange : 'week')
let rangePinned = Boolean(RANGES[hashRange])
// The contact inbox rides the same socket (docs/messages.md): frames with
// a `type` field are messaging payloads, the type-less snapshot is
// analytics. The header switches between the two views.
const tab = ref('analytics')
const threads = ref([])
const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread ? 1 : 0), 0))
function lastT(t) {
return t.messages.length ? t.messages[t.messages.length - 1].t : t.created
}
function upsertThread(thread) {
const rest = threads.value.filter((t) => t.id !== thread.id)
threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a)))
}
function wsSend(obj) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
}
function onReply({ id, text }) { wsSend({ type: 'reply', id, text }) }
function onRead(id) { wsSend({ type: 'read', id }) }
function connect() {
if (ws || !mounted) return
conn.value = 'connecting'
@@ -84,7 +109,22 @@ function connect() {
}
ws.onmessage = (event) => {
try {
data.value = JSON.parse(event.data)
const msg = JSON.parse(event.data)
// Contact-messaging frames carry a type field (docs/messages.md);
// the type-less snapshot is analytics data.
if (msg?.type === 'threads') {
threads.value = msg.threads
return
}
if (msg?.type === 'thread') {
upsertThread(msg)
return
}
if (msg?.type === 'contact-error') {
console.warn('contact:', msg.detail)
return
}
data.value = msg
if (!rangePinned) {
rangePinned = true
const starts = (data.value?.visits || [])
@@ -300,7 +340,15 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
<div class="analytics-panel">
<header>
<h1>Analytics</h1>
<nav class="ranges">
<nav class="tabs">
<button type="button" :class="{ active: tab === 'analytics' }" @click="tab = 'analytics'">
analytics
</button>
<button type="button" :class="{ active: tab === 'messages' }" @click="tab = 'messages'">
messages<span v-if="unreadTotal" class="badge">{{ unreadTotal }}</span>
</button>
</nav>
<nav v-show="tab === 'analytics'" class="ranges">
<button v-for="(r, key) in RANGES" :key="key" type="button"
:class="{ active: range === key }" @click="range = key">
{{ r.label }}
@@ -309,6 +357,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
<a href="/" class="close" title="home">✕</a>
</header>
<ConnNote :text="connNote" />
<MessagesView v-if="tab === 'messages'" :threads="threads" @reply="onReply" @read="onRead" />
<div v-show="tab === 'analytics'">
<p v-if="error" class="error">⚠️ {{ error }}</p>
<p v-else-if="!data" class="loading">loading…</p>
<template v-else>
@@ -447,6 +497,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
</div>
</section>
</template>
</div>
</div>
</div>
</template>
@@ -484,7 +535,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
margin-left: auto;
}
.ranges button {
.ranges button,
.tabs button {
padding: 0.2rem 0.7rem;
font: inherit;
font-size: 0.9rem;
@@ -495,13 +547,32 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
cursor: pointer;
}
.ranges button:hover { color: var(--text); }
.ranges button:hover,
.tabs button:hover { color: var(--text); }
.ranges button.active {
.ranges button.active,
.tabs button.active {
color: var(--text);
border-color: var(--accent);
}
.tabs {
display: flex;
gap: 0.25rem;
}
.tabs .badge {
display: inline-block;
min-width: 1.1em;
margin-left: 0.3em;
padding: 0 0.25em;
border-radius: 1em;
background: var(--accent);
color: var(--bg, Canvas);
font-size: 0.8em;
text-align: center;
}
.close {
padding: 0 0.3rem;
background: none;
+250
View File
@@ -0,0 +1,250 @@
<script setup>
// The admin contact inbox on /_a: thread list plus the selected thread
// with a reply composer. Threads and replies ride the analytics socket
// (AnalyticsView owns it and passes them down); message HTML is the
// server's strict-markdown rendering — injected verbatim, never user
// markup (pagerite/messages.py).
import { computed, nextTick, ref, watch } from 'vue'
import Composer from './Composer.vue'
const props = defineProps({
threads: { type: Array, default: () => [] },
})
const emit = defineEmits(['reply', 'read'])
const selected = ref('')
const scroll = ref(null)
const thread = computed(() => props.threads.find((t) => t.id === selected.value) || null)
// Auto-open the first thread once the list arrives.
watch(
() => props.threads.length,
(n) => {
if (n && !selected.value) selected.value = props.threads[0].id
},
{ immediate: true },
)
// Opening a thread marks it read on the server (the updated payload
// comes back around and clears the dot).
watch(thread, (t) => {
if (!t) return
if (t.unread) emit('read', t.id)
scrollDown()
})
watch(() => thread.value?.messages?.length, scrollDown)
async function scrollDown() {
await nextTick()
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
}
function lastMsg(t) {
return t.messages[t.messages.length - 1] || null
}
// List-row preview: the last message's visible text (the html is
// server-rendered strict markdown, safe to parse here).
function preview(t) {
const m = lastMsg(t)
if (!m) return ''
const div = document.createElement('div')
div.innerHTML = m.html
const who = m.admin ? `${m.admin}: ` : ''
return (who + (div.textContent || '')).slice(0, 80)
}
function fmtTime(t) {
return new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
function send(md) {
if (thread.value) emit('reply', { id: thread.value.id, text: md })
}
</script>
<template>
<div class="messages-view">
<aside class="thread-list">
<p v-if="!threads.length" class="empty">no messages yet</p>
<button
v-for="t in threads"
:key="t.id"
type="button"
class="thread-row"
:class="{ active: t.id === selected }"
@click="selected = t.id"
>
<span class="dot" :class="{ unread: t.unread }"></span>
<span class="peek">
<strong>{{ t.id.slice(0, 8) }}</strong>
{{ preview(t) }}
</span>
<time>{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
</button>
</aside>
<section v-if="thread" class="thread">
<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). -->
<div class="contact-msg-body" v-html="m.html"></div>
<footer>
<span v-if="m.admin" class="who">{{ m.admin }}</span>
<a v-if="m.page" class="page" :href="m.page" title="sent from this page">{{ m.page }}</a>
<time>{{ fmtTime(m.t) }}</time>
</footer>
</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-view {
display: flex;
gap: 1rem;
margin-top: 1rem;
min-height: 60vh;
}
.thread-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 16rem;
flex-shrink: 0;
}
.thread-row {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: none;
color: var(--text, CanvasText);
font: inherit;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.thread-row.active {
border-color: var(--accent);
}
.thread-row .dot {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: transparent;
}
.thread-row .dot.unread {
background: var(--accent);
}
.thread-row .peek {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--muted);
}
.thread-row .peek strong {
color: var(--text, CanvasText);
font-weight: 600;
}
.thread-row time {
flex-shrink: 0;
color: var(--muted);
font-size: 0.75rem;
}
.thread {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.6rem;
min-width: 0;
}
.thread-messages {
flex: 1;
overflow-y: auto;
max-height: 65vh;
padding-right: 0.2rem;
}
.empty {
color: var(--muted);
text-align: center;
margin-top: 2rem;
}
.contact-msg {
max-width: 85%;
margin: 0.45rem 0;
padding: 0.35rem 0.6rem;
border-radius: 0.6rem;
background: color-mix(var(--muted) 12%, transparent);
}
.contact-msg.mine {
margin-left: auto;
background: color-mix(var(--accent) 16%, transparent);
}
.contact-msg-body {
overflow-wrap: break-word;
}
.contact-msg-body :deep(p) {
margin: 0.25em 0;
}
.contact-msg-body :deep(img) {
max-width: 100%;
border-radius: 0.3rem;
}
.contact-msg-body :deep(a.attachment) {
display: inline-block;
padding: 0.05em 0.5em;
border: 1px solid var(--line);
border-radius: 1em;
background: var(--bg, Canvas);
text-decoration: none;
white-space: nowrap;
}
.contact-msg footer {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
align-items: baseline;
color: var(--muted);
font-size: 0.75rem;
}
.contact-msg footer .who {
font-weight: 600;
}
.contact-msg footer .page {
color: var(--muted);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.contact-msg footer .page:hover {
color: var(--accent);
}
@media (max-width: 48rem) {
.messages-view {
flex-direction: column;
}
.thread-list {
width: 100%;
}
}
</style>