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') +}