contact messages: visitor panel and composer

The composer is a constrained contenteditable (plain-text paste,
toolbar formatting, upload chips for images/attachments) serialized to
a Markdown subset by richtext.js — the wire format is text and the
server re-renders it, so no user markup ever reaches a page. Uploads go
to /_contact/files with the session token (admins share the component
via /_api/files). ContactPanel shows the thread pushed live over the
activity socket: pagerite.js lazy-loads the bundle on trigger clicks,
routes socket pushes to it, re-registers the session token on every
reconnect (the resync), and dots trigger buttons on unread replies.
This commit is contained in:
LeoVasanko committed 2026-09-26 01:11:14 +00:00
1 parent 776f408743
commit eea89032a2
6 files changed
+669

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
<script setup>
// The message composer shared by the visitor contact panel and the admin
// inbox: a constrained contenteditable (plain-text paste, toolbar-driven
// formatting, upload chips) serialized to the Markdown subset on send
// (./richtext.js). The server re-renders that with its strict parser —
// what shows in threads is never this DOM.
import { onMounted, ref } from 'vue'
import { apiFetch } from 'paskia'
import { serializeRich } from './richtext.js'
const props = defineProps({
placeholder: { type: String, default: 'Write a message…' },
// Visitor session token for the public upload endpoint; empty = admin,
// whose uploads go to the /_api file store instead.
token: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
})
const emit = defineEmits(['send'])
const box = ref(null)
const imageInput = ref(null)
const fileInput = ref(null)
const uploading = ref(0)
onMounted(() => {
if (props.autofocus) box.value?.focus()
})
function cmd(name, value = null) {
box.value?.focus()
document.execCommand(name, false, value)
}
function link() {
const url = window.prompt('Link URL', 'https://')
if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url)
}
const escHtml = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
// Paste is plain text only — the grammar (bold, links, chips…) comes from
// the toolbar and uploads, never from foreign HTML.
function onPaste(ev) {
ev.preventDefault()
if (ev.clipboardData?.files?.length) {
uploadFiles(ev.clipboardData.files)
return
}
const text = ev.clipboardData?.getData('text/plain') || ''
if (!text) return
const html = text
.split(/\n{2,}/)
.map((p) => `<div>${p.split(/\r\n|\r|\n/).map(escHtml).join('<br>')}</div>`)
.join('')
document.execCommand('insertHTML', false, html)
}
function onDrop(ev) {
if (ev.dataTransfer?.files?.length) {
ev.preventDefault()
uploadFiles(ev.dataTransfer.files)
}
}
async function uploadFiles(files) {
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
console.warn('contact upload skipped (over 20 MB):', file.name)
continue
}
uploading.value++
try {
const path = `/_${props.token ? 'contact' : 'api'}/files/${encodeURIComponent(file.name)}`
const init = { method: 'PUT', body: file }
if (props.token) init.headers = { 'x-contact-token': props.token }
const r = await (props.token ? fetch(path, init) : apiFetch(path, init))
if (!r.ok) throw new Error(`upload failed with status ${r.status}`)
insertUpload(await r.json())
} catch (e) {
console.error('contact upload failed:', e)
} finally {
uploading.value--
}
}
}
// An uploaded file becomes an inline image or an attachment chip; both
// serialize back to /_f/ Markdown references on send.
function insertUpload({ path, image, name }) {
const html = image
? `<img class="contact-img" src="${escHtml(path)}" data-path="${escHtml(path)}" alt="${escHtml(name)}">`
: `<span class="contact-file" contenteditable="false" data-path="${escHtml(path)}" data-name="${escHtml(name)}">📎 ${escHtml(name)}</span>&nbsp;`
box.value?.focus()
document.execCommand('insertHTML', false, html)
}
function send() {
const md = serializeRich(box.value)
if (!md || uploading.value) return
emit('send', md)
box.value.innerHTML = ''
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') send()
}
defineExpose({ focus: () => box.value?.focus() })
</script>
<template>
<div class="composer">
<div
ref="box"
class="composer-box"
contenteditable="true"
:data-placeholder="placeholder"
role="textbox"
aria-multiline="true"
@paste="onPaste"
@drop="onDrop"
@keydown="onKeydown"
></div>
<div class="composer-bar">
<button type="button" title="bold" @click="cmd('bold')"><b>B</b></button>
<button type="button" title="italic" @click="cmd('italic')"><i>I</i></button>
<button type="button" title="link" @click="link">🔗</button>
<button type="button" title="list" @click="cmd('insertUnorderedList')">≡</button>
<button type="button" title="image" @click="imageInput.click()">🖼</button>
<button type="button" title="attachment" @click="fileInput.click()">📎</button>
<span v-if="uploading" class="uploading">uploading…</span>
<button type="button" class="send" title="send (Ctrl+Enter)" @click="send">➤</button>
</div>
<input
ref="imageInput"
type="file"
accept="image/*"
multiple
hidden
@change="uploadFiles($event.target.files); $event.target.value = ''"
>
<input
ref="fileInput"
type="file"
multiple
hidden
@change="uploadFiles($event.target.files); $event.target.value = ''"
>
</div>
</template>
<style scoped>
.composer {
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg, Canvas);
}
.composer:focus-within {
border-color: var(--accent);
}
.composer-box {
min-height: 3.2rem;
max-height: 30vh;
overflow-y: auto;
padding: 0.5rem 0.7rem;
outline: none;
font: inherit;
overflow-wrap: break-word;
}
.composer-box:empty::before {
content: attr(data-placeholder);
color: var(--muted);
pointer-events: none;
}
.composer-box :deep(img.contact-img) {
display: block;
max-width: 100%;
max-height: 10rem;
margin: 0.2rem 0;
border-radius: 0.3rem;
}
.composer-box :deep(.contact-file) {
display: inline-block;
padding: 0.05em 0.5em;
border: 1px solid var(--line);
border-radius: 1em;
background: color-mix(var(--muted) 10%, transparent);
font-size: 0.9em;
white-space: nowrap;
}
.composer-bar {
display: flex;
align-items: center;
gap: 0.15rem;
padding: 0.2rem 0.3rem;
border-top: 1px solid var(--line);
}
.composer-bar button {
min-width: 1.7rem;
padding: 0.15rem 0.3rem;
border: none;
border-radius: 0.3rem;
background: none;
color: var(--text, CanvasText);
font: inherit;
cursor: pointer;
}
.composer-bar button:hover {
background: color-mix(var(--muted) 15%, transparent);
}
.composer-bar .uploading {
margin-left: 0.4rem;
color: var(--muted);
font-size: 0.85em;
}
.composer-bar .send {
margin-left: auto;
color: var(--accent);
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script setup>
// 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 { nextTick, ref, watch } from 'vue'
import Composer from './Composer.vue'
const props = defineProps({
state: { type: Object, required: true },
token: { type: String, required: true },
})
const emit = defineEmits(['send', 'close'])
const scroll = ref(null)
async function scrollDown() {
await nextTick()
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
}
watch(() => props.state.thread?.messages?.length, scrollDown)
watch(() => props.state.open, (open) => { if (open) scrollDown() })
function fmtTime(t) {
return new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
</script>
<template>
<div v-if="state.open" class="contact-backdrop" @click.self="emit('close')">
<section class="contact-panel" role="dialog" aria-label="Contact us">
<header>
<h2>Contact us</h2>
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
</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. -->
<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 ${m.page}`">⌂</a>
<time>{{ fmtTime(m.t) }}</time>
</footer>
</div>
</div>
<Composer :token="token" autofocus @send="(md) => emit('send', md)" />
</section>
</div>
</template>
<style scoped>
.contact-backdrop {
position: fixed;
inset: 0;
z-index: 30;
background: rgb(0 0 0 / 0.25);
}
.contact-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
width: min(26rem, 94vw);
padding: 0.8rem;
gap: 0.6rem;
background: var(--bg, Canvas);
color: var(--text, CanvasText);
border-left: 1px solid var(--line);
box-shadow: -0.5rem 0 2rem rgb(0 0 0 / 0.2);
}
.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;
overflow-y: auto;
padding-right: 0.2rem;
}
.contact-empty {
color: var(--muted);
text-align: center;
margin-top: 2rem;
}
.contact-msg {
max-width: 88%;
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);
text-decoration: none;
}
.contact-msg footer .page:hover {
color: var(--accent);
}
</style>
+31
View File
@@ -878,6 +878,37 @@ article {
opacity: 1;
}
/* {contact: Label} directive: the button opening the visitor contact
panel (docs/messages.md). A standalone accent button in the article
flow; .has-unread (set by contact-main) marks a new admin reply. */
.contact-trigger {
position: relative;
padding: 0.35em 1.1em;
font: inherit;
color: var(--accent);
background: none;
border: 1px solid var(--accent);
border-radius: 1.2em;
cursor: pointer;
}
.contact-trigger:hover {
color: var(--bg);
background: var(--accent);
}
.contact-trigger.has-unread::after {
content: "";
position: absolute;
top: -0.25em;
right: -0.25em;
width: 0.6em;
height: 0.6em;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 0.15em var(--bg);
}
.edit-link {
position: absolute;
top: 0.2rem;
+74
View File
@@ -0,0 +1,74 @@
// 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.
import { createApp, reactive } from 'vue'
import ContactPanel from './ContactPanel.vue'
const TOKEN_KEY = 'pagerite-contact-token'
const SEEN_KEY = 'pagerite-contact-seen'
let app = null
const state = reactive({ open: false, thread: null, unread: false })
function ensureToken() {
let token = localStorage.getItem(TOKEN_KEY)
if (!token) {
token = crypto.randomUUID()
localStorage.setItem(TOKEN_KEY, token)
}
return token
}
function seenAt() {
return Number(localStorage.getItem(SEEN_KEY) || 0)
}
function markSeen() {
localStorage.setItem(SEEN_KEY, String(Date.now()))
state.unread = false
refreshBadges()
}
// 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.
export function refreshBadges() {
for (const btn of document.querySelectorAll('.contact-trigger')) {
btn.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()
else refreshBadges()
}
export function open(bridge) {
if (!app) {
const token = ensureToken()
app = createApp(ContactPanel, {
state,
token,
onSend: (md) => bridge.send({ token, msg: md }),
onClose: () => {
state.open = false
refreshBadges()
},
})
const el = document.createElement('div')
el.id = 'contact-root'
document.body.append(el)
app.mount(el)
// Registration doubles as the resync: the server pushes the thread
// state back, and pagerite.js re-registers on every socket reconnect.
bridge.register(token, onThread)
}
state.open = true
markSeen()
}
+73
View File
@@ -456,6 +456,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// Editor-driven swaps don't go through load(): re-evaluate the
// language selector from the fresh copy too.
mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html"));
contactMod?.refreshBadges();
});
// Editors mutate site-wide state (theme, structure, headings, banners),
@@ -629,6 +630,20 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
ws.onopen = () => {
wsPolicy.opened();
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
// Re-registering resyncs the contact thread after a reconnect.
if (contactToken) ws.send(JSON.stringify({ token: contactToken }));
};
ws.onmessage = (ev) => {
// Contact thread pushes ride this socket (docs/messages.md).
if (!contactListener) return;
let msg = null;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
if (msg?.type === "thread") contactListener(msg);
else if (msg?.type === "contact-error") console.warn("contact:", msg.detail);
};
ws.onclose = () => {
ws = null;
@@ -869,6 +884,61 @@ 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
// (re)connect, and the server pushes the thread back as the resync.
let contactMod = null;
let contactToken = "";
let contactListener = null;
const contactBridge = {
register(token, onThread) {
contactToken = token;
contactListener = onThread;
contactBridge.send({ token });
},
send(msg) {
msg.fr = currentPath;
activityWs();
if (ws?.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(msg));
return;
} catch { /* fall through to queueing */ }
}
wsQueue.push(msg);
},
};
async function openContactPanel() {
try {
contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]);
for (const css of (assets["pagerite:contact-css"] || "").split(",")) {
if (css && !document.querySelector(`link[href="${css}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = css;
link.dataset.pagerite = "contact-css";
document.head.append(link);
}
}
contactMod.open(contactBridge);
} catch (e) {
console.error("contact panel load failed:", e);
}
}
addEventListener("click", (ev) => {
const btn = ev.target.closest?.(".contact-trigger");
if (!btn) return;
ev.preventDefault();
// In the editor preview the button is inert.
if (document.body.classList.contains("editing")) return;
openContactPanel();
});
// --- Fetch navigation ------------------------------------------------
async function load(url, push = true, back = false) {
// Navigating with the editor open keeps it open: the shell retargets to
@@ -978,6 +1048,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
applyEffects();
mountAnalytics(doc);
mountLangselect(doc);
// The swap rebuilt any contact-trigger buttons: re-apply the
// "new reply" dot to the fresh elements.
contactMod?.refreshBadges();
};
// Rotating cube page transition (styles injected as #pagerite-transition
// from the selected design's transition.css, e.g. themes/cube/);
+101
View File
@@ -0,0 +1,101 @@
// Serialize the constrained contenteditable composer DOM to the contact
// message Markdown subset (emphasis, links, lists, code, /_f/ images and
// file chips). The wire format is text: the server re-renders it with a
// strict, html-less MarkdownIt (pagerite/messages.py), so no user markup
// of any kind ever reaches a page. This serializer is for fidelity, not
// safety — anything it does not know is simply unwrapped.
const ESCAPE_RE = /([\\`*_[\]])/g
export function escapeMd(text) {
return text.replace(ESCAPE_RE, '\\$1')
}
// One message's Markdown from the composer root's child nodes: each
// top-level node becomes a block, joined by blank lines.
export function serializeRich(root) {
const blocks = []
for (const node of root.childNodes) block(node, blocks)
return blocks
.map((b) => b.trim())
.filter((b) => b)
.join('\n\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function block(node, out) {
if (node.nodeType === Node.TEXT_NODE) {
out.push(escapeMd(node.textContent))
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
switch (node.tagName) {
case 'UL':
out.push([...node.children].map((li) => `- ${inline(li).trim()}`).join('\n'))
break
case 'OL':
out.push([...node.children].map((li, i) => `${i + 1}. ${inline(li).trim()}`).join('\n'))
break
case 'BLOCKQUOTE':
out.push(inline(node).split('\n').map((l) => `> ${l}`).join('\n'))
break
case 'PRE':
out.push('```\n' + node.textContent + '\n```')
break
case 'IMG':
// Uploaded image (data-path set by the composer on insert).
if (node.dataset.path) out.push(`![${escapeMd(node.alt || 'image')}](${node.dataset.path})`)
break
case 'BR':
break
default:
// DIV, P and anything else: a paragraph of inline content.
out.push(inline(node))
}
}
function inline(node) {
let out = ''
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
out += escapeMd(child.textContent)
continue
}
if (child.nodeType !== Node.ELEMENT_NODE) continue
switch (child.tagName) {
case 'B':
case 'STRONG':
out += `**${inline(child)}**`
break
case 'I':
case 'EM':
out += `*${inline(child)}*`
break
case 'CODE':
out += '`' + child.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`'
break
case 'BR':
out += '\n'
break
case 'A': {
const text = inline(child)
const href = child.getAttribute('href') || ''
out += /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text
break
}
case 'IMG':
if (child.dataset.path) out += `![${escapeMd(child.alt || 'image')}](${child.dataset.path})`
break
case 'SPAN':
// Attachment chip (data-path + data-name); other spans unwrap.
out += child.dataset.path && child.dataset.name
? `[📎 ${escapeMd(child.dataset.name)}](${child.dataset.path})`
: inline(child)
break
default:
out += inline(child)
}
}
return out
}