diff --git a/docs/messages.md b/docs/messages.md
index 3608bc2..877260c 100644
--- a/docs/messages.md
+++ b/docs/messages.md
@@ -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
+``. 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
diff --git a/frontend/src/Composer.vue b/frontend/src/Composer.vue
index f052d0c..2bebc20 100644
--- a/frontend/src/Composer.vue
+++ b/frontend/src/Composer.vue
@@ -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"
>
-
-
-
-
-
-
- uploadingβ¦
-
+
+
+
+
+
+
+ β³
+
{{ error }}
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')
+}
@@ -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')"
- >β²
-
+ :class="{ armed: armingClose }"
+ @click="onCloseThread"
+ >{{ armingClose ? 'β' : 'β²' }}
+
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). */
diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue
index 76380e0..e21ebd9 100644
--- a/frontend/src/ContactPanel.vue
+++ b/frontend/src/ContactPanel.vue
@@ -61,9 +61,7 @@ function onSend(md) {
diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js
index 508eb86..ceee5d2 100644
--- a/frontend/src/contact-main.js
+++ b/frontend/src/contact-main.js
@@ -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 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,12 +81,23 @@ 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 (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
// without reopening.
@@ -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)
diff --git a/pagerite/views.py b/pagerite/views.py
index a4f3b79..c5cfe7c 100644
--- a/pagerite/views.py
+++ b/pagerite/views.py
@@ -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 () β 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),
}