diff --git a/frontend/src/Composer.vue b/frontend/src/Composer.vue new file mode 100644 index 0000000..7536426 --- /dev/null +++ b/frontend/src/Composer.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue new file mode 100644 index 0000000..4ea2625 --- /dev/null +++ b/frontend/src/ContactPanel.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index ab095bc..d0b5c45 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -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; diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js new file mode 100644 index 0000000..081e109 --- /dev/null +++ b/frontend/src/contact-main.js @@ -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() +} diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 770b117..c9b17cd 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -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/); diff --git a/frontend/src/richtext.js b/frontend/src/richtext.js new file mode 100644 index 0000000..044b497 --- /dev/null +++ b/frontend/src/richtext.js @@ -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 +}