Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14fcbc4f3 | ||
|
|
cb046fe867 |
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
|
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
@@ -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
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -151,7 +151,12 @@
|
|||||||
subordinate). border-radius is the fallback for browsers without
|
subordinate). border-radius is the fallback for browsers without
|
||||||
shape(). Asymmetric inline padding keeps the text clear of the
|
shape(). Asymmetric inline padding keeps the text clear of the
|
||||||
slants (wider on the right: the wide bottom-right sweep cuts deeper
|
slants (wider on the right: the wide bottom-right sweep cuts deeper
|
||||||
into the label's end side). Editor chrome (.icon-btn pens, .copy)
|
into the label's end side). The negative right margin tucks whatever
|
||||||
|
follows — another button or plain text — under the right slant's
|
||||||
|
empty top triangle: adjacent buttons' slant bands overlap by ~0.3em
|
||||||
|
horizontally while the painted edges keep ~0.3em apart (the slants
|
||||||
|
are parallel, so the clearance is constant top to bottom).
|
||||||
|
Editor chrome (.icon-btn pens, .copy)
|
||||||
keeps its own styling. The {contact:} envelope's line-height is
|
keeps its own styling. The {contact:} envelope's line-height is
|
||||||
reduced in the base stylesheet so it doesn't outgrow this height. */
|
reduced in the base stylesheet so it doesn't outgrow this height. */
|
||||||
.contact-trigger,
|
.contact-trigger,
|
||||||
@@ -161,6 +166,7 @@ article button:not(.icon-btn):not(.copy) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 1.7em;
|
height: 1.7em;
|
||||||
padding: 0 1.3em 0 1em;
|
padding: 0 1.3em 0 1em;
|
||||||
|
margin-right: -0.8em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
/* Same typography as the contact trigger (raw <button>s otherwise get
|
/* Same typography as the contact trigger (raw <button>s otherwise get
|
||||||
the small UA font): nav family/weight, article size and leading. */
|
the small UA font): nav family/weight, article size and leading. */
|
||||||
|
|||||||
+14
-1
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user