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:
1 parent
eea89032a2
commit
bbb11bf17e
2 files changed
+327
-6
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user