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:
1 parent
766fb503d9
commit
cb046fe867
6 files changed
+121
-37
No files matched your search
+10
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user