contact messages: live language switching, language-neutral visitor UI

The asset config now carries every site language's label/subject variant
(pagerite:contact-langs, JSON); the client re-picks the dialog title and
mailto subject from <html lang> as the language selector swaps pages in
place (the config block itself is not swapped).

The visitor-facing dialog no longer has hardcoded English: empty state
and intro fields are glyphs (✉️, 👤/📧), the composer placeholder and
toolbar are symbols, upload errors flag the file with ⚠️ (details to
the console), and closing a conversation is a two-tap-armed ⟲ instead
of a text confirm(). The admin inbox stays English.
This commit is contained in:
LeoVasanko committed 2026-09-29 03:29:21 +00:00
1 parent 766fb503d9
commit cb046fe867
6 files changed
+121 -37

No files matched your search

+10 -2
View File
@@ -82,12 +82,20 @@ same store (`i18n.set_string_translation`; empty or the original's text
drops the override). The localized strings reach the client through the drops the override). The localized strings reach the client through the
page's asset config (`pagerite:contact-label` / `-subject`, absent page's asset config (`pagerite:contact-label` / `-subject`, absent
entirely when disabled — pagerite.js reads that as "visitors get no entirely when disabled — pagerite.js reads that as "visitors get no
icon"); `contact-main.js` re-reads them on every open and on page swaps, icon") plus `pagerite:contact-langs`, a JSON map of every site
so a language switch or a settings save is reflected immediately. The language's variant: the language selector swaps pages in place without
touching the asset config, so the client re-picks the strings live from
`<html lang>`. The
email address itself is never in the config: it is revealed over the email address itself is never in the config: it is revealed over the
messaging socket only after the proof of work is solved (and only when messaging socket only after the proof of work is solved (and only when
configured). configured).
The visitor-facing UI carries no hardcoded language: the dialog title
and mailto subject are the localized site strings, and everything else
is glyphs (✉️ empty state, 👤/📧 intro fields, ✏️ composer placeholder,
toolbar symbols, the two-tap-armed ⟲ close-conversation button — no
text confirm). The admin inbox UI stays English.
## Visitor side ## Visitor side
The token is `crypto.randomUUID()` in localStorage The token is `crypto.randomUUID()` in localStorage
+17 -12
View File
@@ -9,7 +9,9 @@ import { apiFetch } from 'paskia'
import { serializeRich } from './richtext.js' import { serializeRich } from './richtext.js'
const props = defineProps({ const props = defineProps({
placeholder: { type: String, default: 'Write a message…' }, // The visitor side passes nothing: language-neutral glyph (admin
// instances pass their own English placeholders).
placeholder: { type: String, default: '✏️' },
// Visitor session token for the public upload endpoint; empty = admin, // Visitor session token for the public upload endpoint; empty = admin,
// whose uploads go to the /_api file store instead. // whose uploads go to the /_api file store instead.
token: { type: String, default: '' }, token: { type: String, default: '' },
@@ -47,7 +49,7 @@ function cmd(name, value = null) {
} }
function link() { function link() {
const url = window.prompt('Link URL', 'https://') const url = window.prompt('URL', 'https://')
if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url) if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url)
} }
@@ -81,7 +83,10 @@ function onDrop(ev) {
async function uploadFiles(files) { async function uploadFiles(files) {
for (const file of files) { for (const file of files) {
if (file.size > 20 * 1024 * 1024) { if (file.size > 20 * 1024 * 1024) {
showError(`"${file.name}" is over 20 MB`) // Visitor-facing chrome is language-neutral: details go to the
// console, the panel just flags the file.
console.error(`contact upload: "${file.name}" is over 20 MB`)
showError(`⚠️ ${file.name}`)
continue continue
} }
uploading.value++ uploading.value++
@@ -103,7 +108,7 @@ async function uploadFiles(files) {
insertUpload(await r.json()) insertUpload(await r.json())
} catch (e) { } catch (e) {
console.error('contact upload failed:', e) console.error('contact upload failed:', e)
showError(`could not upload "${file.name}": ${e.message}`) showError(`⚠️ ${file.name}`)
} finally { } finally {
uploading.value-- uploading.value--
} }
@@ -148,14 +153,14 @@ defineExpose({ focus: () => box.value?.focus() })
@keydown="onKeydown" @keydown="onKeydown"
></div> ></div>
<div class="composer-bar"> <div class="composer-bar">
<button type="button" title="bold" @mousedown.prevent @click="cmd('bold')"><b>B</b></button> <button type="button" @mousedown.prevent @click="cmd('bold')"><b>B</b></button>
<button type="button" title="italic" @mousedown.prevent @click="cmd('italic')"><i>I</i></button> <button type="button" @mousedown.prevent @click="cmd('italic')"><i>I</i></button>
<button type="button" title="link" @mousedown.prevent @click="link">🔗</button> <button type="button" @mousedown.prevent @click="link">🔗</button>
<button type="button" title="list" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button> <button type="button" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button>
<button type="button" title="image" @mousedown.prevent @click="imageInput.click()">🖼</button> <button type="button" @mousedown.prevent @click="imageInput.click()">🖼</button>
<button type="button" title="attachment" @mousedown.prevent @click="fileInput.click()">📎</button> <button type="button" @mousedown.prevent @click="fileInput.click()">📎</button>
<span v-if="uploading" class="uploading">uploading…</span> <span v-if="uploading" class="uploading">⏳</span>
<button type="button" class="send" title="send (Ctrl+Enter)" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button> <button type="button" class="send" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button>
</div> </div>
<p v-if="error" class="composer-error">{{ error }}</p> <p v-if="error" class="composer-error">{{ error }}</p>
<input <input
+25 -5
View File
@@ -5,7 +5,7 @@
// {contact:} trigger buttons open, for admins too), 'admin' the contact // {contact:} trigger buttons open, for admins too), 'admin' the contact
// inbox (MessagesView — what the corner ✉️ icon opens for admins). // inbox (MessagesView — what the corner ✉️ icon opens for admins).
// contact-main holds the backdrop while state.open and owns the store. // contact-main holds the backdrop while state.open and owns the store.
import { computed, onMounted, onUnmounted } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import ContactPanel from './ContactPanel.vue' import ContactPanel from './ContactPanel.vue'
import MessagesView from './MessagesView.vue' import MessagesView from './MessagesView.vue'
@@ -32,6 +32,23 @@ function onKey(ev) {
} }
onMounted(() => addEventListener('keydown', onKey)) onMounted(() => addEventListener('keydown', onKey))
onUnmounted(() => removeEventListener('keydown', onKey)) onUnmounted(() => removeEventListener('keydown', onKey))
// The close-conversation ⟲ button is two-tap armed (the visitor UI has
// no hardcoded language, so no text confirm): the first tap arms it for
// a few seconds, the second fires.
const armingClose = ref(false)
let armTimer = null
function onCloseThread() {
if (!armingClose.value) {
armingClose.value = true
clearTimeout(armTimer)
armTimer = setTimeout(() => { armingClose.value = false }, 3000)
return
}
clearTimeout(armTimer)
armingClose.value = false
emit('close-thread')
}
</script> </script>
<template> <template>
@@ -49,10 +66,10 @@ onUnmounted(() => removeEventListener('keydown', onKey))
v-if="!admin && state.thread?.messages?.length" v-if="!admin && state.thread?.messages?.length"
type="button" type="button"
class="close-thread" class="close-thread"
title="close this conversation — your next message starts a fresh thread" :class="{ armed: armingClose }"
@click="emit('close-thread')" @click="onCloseThread"
>⟲</button> >{{ armingClose ? '❗' : '⟲' }}</button>
<button type="button" class="close" title="close" @click="emit('close')">✕</button> <button type="button" class="close" @click="emit('close')">✕</button>
</header> </header>
<ContactPanel <ContactPanel
v-if="!admin" v-if="!admin"
@@ -137,6 +154,9 @@ onUnmounted(() => removeEventListener('keydown', onKey))
.contact-panel .close-thread:hover { .contact-panel .close-thread:hover {
color: var(--text, CanvasText); color: var(--text, CanvasText);
} }
.contact-panel .close-thread.armed {
color: var(--accent);
}
/* Mobile: full screen (the admin layout's own sidebar stacking kicks in /* Mobile: full screen (the admin layout's own sidebar stacking kicks in
at the same width). */ at the same width). */
+21 -10
View File
@@ -61,9 +61,7 @@ function onSend(md) {
<template> <template>
<div ref="scroll" class="contact-messages"> <div ref="scroll" class="contact-messages">
<p v-if="!state.thread || !state.thread.messages.length" class="contact-empty"> <p v-if="!state.thread || !state.thread.messages.length" class="contact-empty">✉️</p>
Send us a message — the reply appears here.
</p>
<div <div
v-for="m in state.thread?.messages || []" v-for="m in state.thread?.messages || []"
:key="m.id" :key="m.id"
@@ -74,15 +72,15 @@ function onSend(md) {
message source (pagerite/messages.py, html=False): markdown-it message source (pagerite/messages.py, html=False): markdown-it
output only, never user markup. --> output only, never user markup. -->
<header> <header>
<span class="who">{{ m.admin || state.thread?.name || 'you' }}</span> <span class="who">{{ m.admin || state.thread?.name || '👤' }}</span>
<time>{{ fmtTime(m.t) }}</time> <time>{{ fmtTime(m.t) }}</time>
</header> </header>
<div class="contact-msg-body" v-html="m.html"></div> <div class="contact-msg-body" v-html="m.html"></div>
</div> </div>
</div> </div>
<div v-if="showIntro" class="contact-intro"> <div v-if="showIntro" class="contact-intro">
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100"> <label><span aria-hidden="true">👤</span><input v-model="intro.name" type="text" maxlength="100"></label>
<input v-model="intro.email" type="email" placeholder="Your email (optional)" maxlength="200"> <label><span aria-hidden="true">📧</span><input v-model="intro.email" type="email" maxlength="200"></label>
</div> </div>
<Composer <Composer
:token="token" :token="token"
@@ -104,6 +102,8 @@ function onSend(md) {
color: var(--muted); color: var(--muted);
text-align: center; text-align: center;
margin-top: 2rem; margin-top: 2rem;
font-size: 2rem;
opacity: 0.5;
} }
.contact-msg { .contact-msg {
@@ -151,19 +151,30 @@ function onSend(md) {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem; gap: 0.4rem;
} }
.contact-intro input { .contact-intro label {
flex: 1; flex: 1;
min-width: 8rem; min-width: 8rem;
padding: 0.3rem 0.5rem; display: flex;
align-items: center;
gap: 0.35rem;
padding-left: 0.5rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.3rem; border-radius: 0.3rem;
background: var(--bg, Canvas); }
.contact-intro input {
flex: 1;
min-width: 0;
padding: 0.3rem 0.5rem 0.3rem 0;
border: none;
background: none;
color: var(--text, CanvasText); color: var(--text, CanvasText);
font: inherit; font: inherit;
font-size: 0.9rem; font-size: 0.9rem;
} }
.contact-intro input:focus { .contact-intro label:focus-within {
border-color: var(--accent); border-color: var(--accent);
}
.contact-intro input:focus {
outline: none; outline: none;
} }
</style> </style>
+34 -7
View File
@@ -52,7 +52,23 @@ const state = reactive({
}) })
// The localized contact strings ride the page's asset config (the // The localized contact strings ride the page's asset config (the
// production JSON block, or pagerite:* meta tags in dev). // production JSON block, or pagerite:* meta tags in dev). Every site
// language's variant is included (pagerite:contact-langs, JSON): the
// language selector swaps the page in place without touching the asset
// config, so the strings follow <html lang> live instead.
let baseLabel = ''
let baseSubject = ''
let contactLangs = {}
function applyLang() {
const tag = (document.documentElement.lang || '').split('-')[0]
const tr = contactLangs[tag]
state.label = tr?.label || baseLabel || 'Contact us'
state.subject = tr?.subject || baseSubject || state.label
// state.email is NOT reset here: it arrives over the socket after the
// PoW is solved and stays valid for the session.
}
function readContactStrings() { function readContactStrings() {
let assets = {} let assets = {}
const el = document.getElementById('pagerite-assets') const el = document.getElementById('pagerite-assets')
@@ -65,12 +81,23 @@ function readContactStrings() {
assets[m.name] = m.content assets[m.name] = m.content
} }
} }
state.label = assets['pagerite:contact-label'] || 'Contact us' baseLabel = assets['pagerite:contact-label'] || ''
state.subject = assets['pagerite:contact-subject'] || state.label baseSubject = assets['pagerite:contact-subject'] || ''
// state.email is NOT reset here: it arrives over the socket after the try {
// PoW is solved and stays valid for the session. contactLangs = JSON.parse(assets['pagerite:contact-langs'] || '{}')
} catch {
contactLangs = {}
}
applyLang()
} }
// Language switches update <html lang> (fetch navigation, the editor's
// lang override): re-pick the strings.
new MutationObserver(applyLang).observe(document.documentElement, {
attributes: true,
attributeFilter: ['lang'],
})
// Page swaps (fetch navigation, editor re-renders after settings saves) // Page swaps (fetch navigation, editor re-renders after settings saves)
// carry fresh per-language config: keep the dialog strings in sync // carry fresh per-language config: keep the dialog strings in sync
// without reopening. // without reopening.
@@ -219,9 +246,9 @@ function onThread(thread) {
// The visitor ended the conversation: archive it server-side (the admin // The visitor ended the conversation: archive it server-side (the admin
// inbox keeps it), wipe the local session and rotate the token, so the // inbox keeps it), wipe the local session and rotate the token, so the
// next message starts an all-new thread. The panel stays open on the // next message starts an all-new thread. The panel stays open on the
// fresh empty state. // fresh empty state. The ⟲ button is two-tap armed in the overlay — no
// text confirm dialog (the visitor UI carries no hardcoded language).
function closeConversation() { function closeConversation() {
if (!confirm('Close this conversation? A fresh thread starts with your next message.')) return
bridge.send({ token: state.token, close: true }) bridge.send({ token: state.token, close: true })
for (const key of [TOKEN_KEY, SEEN_KEY, LASTADMIN_KEY, SESSION_KEY]) { for (const key of [TOKEN_KEY, SEEN_KEY, LASTADMIN_KEY, SESSION_KEY]) {
localStorage.removeItem(key) localStorage.removeItem(key)
+14 -1
View File
@@ -440,6 +440,8 @@ def _layout(
if contact is not None: if contact is not None:
config["pagerite:contact-label"] = contact["label"] config["pagerite:contact-label"] = contact["label"]
config["pagerite:contact-subject"] = contact["subject"] config["pagerite:contact-subject"] = contact["subject"]
# Every site language's label/subject (JSON): live switching.
config["pagerite:contact-langs"] = contact["langs"]
if vite_url: if vite_url:
for key, value in config.items(): for key, value in config.items():
doc.meta(name=key, content=value) doc.meta(name=key, content=value)
@@ -1010,13 +1012,24 @@ def _contact_config(data: Data, lang: str) -> dict[str, str]:
(pagerite:contact-*): the {contact:} buttons' default label doubling as (pagerite:contact-*): the {contact:} buttons' default label doubling as
the visitor dialog's title, and the mailto subject (the label when no the visitor dialog's title, and the mailto subject (the label when no
separate subject is configured). Translations come from the separate subject is configured). Translations come from the
chunk-keyed trans store (i18n.site_string). The email address itself chunk-keyed trans store (i18n.site_string). ``langs`` carries every
site language's variant as JSON, so the client switches strings live
with the page language (<html lang>) — language switches swap the page
in place without touching the asset config. The email address itself
is NOT here — it is revealed over the messaging socket only after the is NOT here — it is revealed over the messaging socket only after the
proof of work is solved, so scrapers cannot lift it from pages.""" proof of work is solved, so scrapers cannot lift it from pages."""
label = i18n.site_string(data, lang, data.contact_label) label = i18n.site_string(data, lang, data.contact_label)
langs = {}
for tag in sorted({i18n.primary_lang(data.menu, ""), *data.translate_langs}):
tr = i18n.site_string(data, tag, data.contact_label)
langs[tag] = {
"label": tr,
"subject": i18n.site_string(data, tag, data.contact_subject) or tr,
}
return { return {
"label": label, "label": label,
"subject": i18n.site_string(data, lang, data.contact_subject) or label, "subject": i18n.site_string(data, lang, data.contact_subject) or label,
"langs": json.dumps(langs, ensure_ascii=False),
} }