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
page's asset config (`pagerite:contact-label` / `-subject`, absent
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,
so a language switch or a settings save is reflected immediately. The
icon") plus `pagerite:contact-langs`, a JSON map of every site
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
messaging socket only after the proof of work is solved (and only when
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
The token is `crypto.randomUUID()` in localStorage
+17 -12
View File
@@ -9,7 +9,9 @@ import { apiFetch } from 'paskia'
import { serializeRich } from './richtext.js'
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,
// whose uploads go to the /_api file store instead.
token: { type: String, default: '' },
@@ -47,7 +49,7 @@ function cmd(name, value = null) {
}
function link() {
const url = window.prompt('Link URL', 'https://')
const url = window.prompt('URL', 'https://')
if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url)
}
@@ -81,7 +83,10 @@ function onDrop(ev) {
async function uploadFiles(files) {
for (const file of files) {
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
}
uploading.value++
@@ -103,7 +108,7 @@ async function uploadFiles(files) {
insertUpload(await r.json())
} catch (e) {
console.error('contact upload failed:', e)
showError(`could not upload "${file.name}": ${e.message}`)
showError(`⚠️ ${file.name}`)
} finally {
uploading.value--
}
@@ -148,14 +153,14 @@ defineExpose({ focus: () => box.value?.focus() })
@keydown="onKeydown"
></div>
<div class="composer-bar">
<button type="button" title="bold" @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" title="link" @mousedown.prevent @click="link">🔗</button>
<button type="button" title="list" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button>
<button type="button" title="image" @mousedown.prevent @click="imageInput.click()">🖼</button>
<button type="button" title="attachment" @mousedown.prevent @click="fileInput.click()">📎</button>
<span v-if="uploading" class="uploading">uploading…</span>
<button type="button" class="send" title="send (Ctrl+Enter)" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button>
<button type="button" @mousedown.prevent @click="cmd('bold')"><b>B</b></button>
<button type="button" @mousedown.prevent @click="cmd('italic')"><i>I</i></button>
<button type="button" @mousedown.prevent @click="link">🔗</button>
<button type="button" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button>
<button type="button" @mousedown.prevent @click="imageInput.click()">🖼</button>
<button type="button" @mousedown.prevent @click="fileInput.click()">📎</button>
<span v-if="uploading" class="uploading">⏳</span>
<button type="button" class="send" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button>
</div>
<p v-if="error" class="composer-error">{{ error }}</p>
<input
+25 -5
View File
@@ -5,7 +5,7 @@
// {contact:} trigger buttons open, for admins too), 'admin' the contact
// inbox (MessagesView — what the corner ✉️ icon opens for admins).
// 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 MessagesView from './MessagesView.vue'
@@ -32,6 +32,23 @@ function onKey(ev) {
}
onMounted(() => addEventListener('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>
<template>
@@ -49,10 +66,10 @@ onUnmounted(() => removeEventListener('keydown', onKey))
v-if="!admin && state.thread?.messages?.length"
type="button"
class="close-thread"
title="close this conversation — your next message starts a fresh thread"
@click="emit('close-thread')"
>⟲</button>
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
:class="{ armed: armingClose }"
@click="onCloseThread"
>{{ armingClose ? '❗' : '⟲' }}</button>
<button type="button" class="close" @click="emit('close')">✕</button>
</header>
<ContactPanel
v-if="!admin"
@@ -137,6 +154,9 @@ onUnmounted(() => removeEventListener('keydown', onKey))
.contact-panel .close-thread:hover {
color: var(--text, CanvasText);
}
.contact-panel .close-thread.armed {
color: var(--accent);
}
/* Mobile: full screen (the admin layout's own sidebar stacking kicks in
at the same width). */
+21 -10
View File
@@ -61,9 +61,7 @@ function onSend(md) {
<template>
<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>
<p v-if="!state.thread || !state.thread.messages.length" class="contact-empty">✉️</p>
<div
v-for="m in state.thread?.messages || []"
:key="m.id"
@@ -74,15 +72,15 @@ function onSend(md) {
message source (pagerite/messages.py, html=False): markdown-it
output only, never user markup. -->
<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>
</header>
<div class="contact-msg-body" v-html="m.html"></div>
</div>
</div>
<div v-if="showIntro" class="contact-intro">
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100">
<input v-model="intro.email" type="email" placeholder="Your email (optional)" maxlength="200">
<label><span aria-hidden="true">👤</span><input v-model="intro.name" type="text" maxlength="100"></label>
<label><span aria-hidden="true">📧</span><input v-model="intro.email" type="email" maxlength="200"></label>
</div>
<Composer
:token="token"
@@ -104,6 +102,8 @@ function onSend(md) {
color: var(--muted);
text-align: center;
margin-top: 2rem;
font-size: 2rem;
opacity: 0.5;
}
.contact-msg {
@@ -151,19 +151,30 @@ function onSend(md) {
flex-wrap: wrap;
gap: 0.4rem;
}
.contact-intro input {
.contact-intro label {
flex: 1;
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-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);
font: inherit;
font-size: 0.9rem;
}
.contact-intro input:focus {
.contact-intro label:focus-within {
border-color: var(--accent);
}
.contact-intro input:focus {
outline: none;
}
</style>
+34 -7
View File
@@ -52,7 +52,23 @@ const state = reactive({
})
// 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() {
let assets = {}
const el = document.getElementById('pagerite-assets')
@@ -65,11 +81,22 @@ function readContactStrings() {
assets[m.name] = m.content
}
}
state.label = assets['pagerite:contact-label'] || 'Contact us'
state.subject = assets['pagerite:contact-subject'] || state.label
// state.email is NOT reset here: it arrives over the socket after the
// PoW is solved and stays valid for the session.
baseLabel = assets['pagerite:contact-label'] || ''
baseSubject = assets['pagerite:contact-subject'] || ''
try {
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)
// carry fresh per-language config: keep the dialog strings in sync
@@ -219,9 +246,9 @@ function onThread(thread) {
// The visitor ended the conversation: archive it server-side (the admin
// 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
// 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() {
if (!confirm('Close this conversation? A fresh thread starts with your next message.')) return
bridge.send({ token: state.token, close: true })
for (const key of [TOKEN_KEY, SEEN_KEY, LASTADMIN_KEY, SESSION_KEY]) {
localStorage.removeItem(key)
+14 -1
View File
@@ -440,6 +440,8 @@ def _layout(
if contact is not None:
config["pagerite:contact-label"] = contact["label"]
config["pagerite:contact-subject"] = contact["subject"]
# Every site language's label/subject (JSON): live switching.
config["pagerite:contact-langs"] = contact["langs"]
if vite_url:
for key, value in config.items():
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
the visitor dialog's title, and the mailto subject (the label when no
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
proof of work is solved, so scrapers cannot lift it from pages."""
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 {
"label": label,
"subject": i18n.site_string(data, lang, data.contact_subject) or label,
"langs": json.dumps(langs, ensure_ascii=False),
}