contact messages: label is the enable switch; single-line settings row
Data.contact_enabled is gone: an empty contact label disables messaging (no buttons, no visitor icon, sends rejected). The settings row is one line — flag selector, label, email, subject as small boxes with tooltip instructions — and saves re-render the page in place so button/dialog strings update without a reload; the open dialog also re-reads its strings on page swaps. The built-in Contact us default survives only as the inert preview placeholder.
This commit is contained in:
1 parent
59fe426e5e
commit
8d577dc56e
8 files changed
+119
-114
No files matched your search
+23
-19
@@ -12,8 +12,8 @@ existing WebSockets; there is no REST polling anywhere.
|
||||
thread store ops, wire payloads, both socket protocols, the admin unread
|
||||
count and the `/_contact/files` upload route.
|
||||
- `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`)
|
||||
and the site settings (`contact_enabled`, `contact_label`,
|
||||
`contact_email`, `contact_subject`).
|
||||
and the site settings (`contact_label` — which doubles as the on/off
|
||||
switch — `contact_email`, `contact_subject`).
|
||||
- `pagerite/tracking.py` — the sockets the protocol rides (see below).
|
||||
- `pagerite/api.py` — `/_api/settings` carries `contact_unread` (the admin
|
||||
icon badge seed — the probe runs on every page load, no polling) and the
|
||||
@@ -36,40 +36,44 @@ A lone `{contact: Label}` line in article Markdown (the block-directive
|
||||
machinery of `markdown.py`) renders
|
||||
`<button class="contact-trigger">Label</button>`. An empty label falls
|
||||
back to the configured site label (`Data.contact_label`, localized —
|
||||
below), ultimately the built-in "Contact us". `pagerite.js`
|
||||
below; "Contact us" appears only as the inert preview's placeholder when
|
||||
nothing is configured). `pagerite.js`
|
||||
click-delegates these buttons and lazy-loads
|
||||
the contact bundle; the editor preview renders the same button inert, and
|
||||
description/card mining resolves the tag to nothing. A per-button label
|
||||
lives in the chunk, so translated pages localize it like any other text.
|
||||
Feeds render the tag as its literal source (no directive context). When
|
||||
messaging is disabled the tag renders nothing at all.
|
||||
messaging is disabled (no site label configured) the tag renders nothing
|
||||
at all.
|
||||
|
||||
## Site settings and localization
|
||||
|
||||
The site editor's messaging block holds the feature's configuration
|
||||
(`/_api/settings`): `contact_enabled` (off = no trigger buttons, no
|
||||
visitor corner icon, sends rejected; admins keep the inbox for existing
|
||||
threads), `contact_email` (a mailto link in the visitor dialog, its
|
||||
subject prefilled) and the `contact_label`/`contact_subject` strings
|
||||
(the label doubles as the dialog's title and the buttons' default; the
|
||||
subject defaults to the label).
|
||||
The site editor's messaging row holds the feature's configuration
|
||||
(`/_api/settings`): `contact_label` — the {contact:} buttons' label and
|
||||
the visitor dialog's title, doubling as the on/off switch (empty = no
|
||||
trigger buttons, no visitor corner icon, sends rejected; admins keep the
|
||||
inbox for existing threads) — `contact_email` (a mailto link in the
|
||||
visitor dialog, its subject prefilled) and `contact_subject` (defaults
|
||||
to the label). Saves re-render the page in place, so button and dialog
|
||||
strings update without a reload.
|
||||
|
||||
The two strings localize like titles: translations live in the
|
||||
chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at
|
||||
render time — the *effective* label, so the built-in default localizes
|
||||
too) and the translator service picks them up automatically
|
||||
render time) and the translator service picks them up automatically
|
||||
(`pending_items` emits them as title-kind fragments; nav-mode jobs carry
|
||||
them as extra top-level list items). Manual overrides are edited in the
|
||||
same messaging block: its language selector switches the label/subject
|
||||
fields to a translation, saved via `PUT /_api/settings?lang=` into the
|
||||
same messaging row: the flag selector (the article editor's LangSelect)
|
||||
switches the label/subject fields to a translation, saved via
|
||||
`PUT /_api/settings?lang=` into the
|
||||
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, so a language
|
||||
switch is reflected immediately. The email address itself is never in
|
||||
the config: it is revealed over the messaging socket only after the
|
||||
proof of work is solved.
|
||||
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
|
||||
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).
|
||||
|
||||
## Visitor side
|
||||
|
||||
|
||||
+52
-60
@@ -62,15 +62,14 @@ const brand = ref('')
|
||||
const theme = ref('')
|
||||
|
||||
// --- Messaging (contact) ---------------------------------------------------
|
||||
// Site-wide contact settings: the enabled toggle (combined with the
|
||||
// section header) and mailto address are global; the label (the
|
||||
// {contact:} buttons' default and the visitor dialog's title) and the
|
||||
// email subject are per-language strings — the LangSelect (the article
|
||||
// editor's flag dropdown, on the shared shell-wide editorLang) switches
|
||||
// the two text fields between the original and a translation override
|
||||
// (stored in the chunk-keyed trans store; auto translators fill the same
|
||||
// store, so this is the manual-override UI).
|
||||
const contactEnabled = ref(true)
|
||||
// One-line contact settings. The label (the {contact:} buttons' label and
|
||||
// the visitor dialog's title) doubles as the on/off switch: empty =
|
||||
// disabled. The label and email subject are per-language strings — the
|
||||
// LangSelect (the article editor's flag dropdown, on the shared shell-wide
|
||||
// editorLang) switches the two fields between the original and a
|
||||
// translation override (stored in the chunk-keyed trans store; auto
|
||||
// translators fill the same store, so this is the manual-override UI).
|
||||
// The email is global; its reveal after the PoW solve is server-side.
|
||||
const contactEmail = ref('')
|
||||
const contactLabel = ref('')
|
||||
const contactSubject = ref('')
|
||||
@@ -135,10 +134,18 @@ async function saveContact(tag = lang.value, label = '', subject = '') {
|
||||
}),
|
||||
})
|
||||
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
|
||||
if (res.ok) dropPageCache()
|
||||
if (res.ok) rerender()
|
||||
return
|
||||
}
|
||||
saveSettings()
|
||||
if (await saveSettings()) rerender()
|
||||
}
|
||||
|
||||
// The rendered {contact:} buttons are server HTML: after a save the page
|
||||
// re-renders (like a theme change), updating buttons and dialog strings
|
||||
// without a reload.
|
||||
async function rerender() {
|
||||
dropPageCache()
|
||||
loadPlain(path.value)
|
||||
}
|
||||
// Theme options come from the backend (theme folders on disk, see GET
|
||||
// /_api/settings), so added themes need no frontend changes.
|
||||
@@ -171,7 +178,6 @@ async function loadSettings() {
|
||||
]
|
||||
transition.value = s.transition || 'cube'
|
||||
transitionOptions.value = s.transitions || []
|
||||
contactEnabled.value = s.contact_enabled !== false
|
||||
contactEmail.value = s.contact_email || ''
|
||||
contactLabelOrig.value = s.contact_label || ''
|
||||
contactSubjectOrig.value = s.contact_subject || ''
|
||||
@@ -348,7 +354,6 @@ async function saveSettings(opts = {}) {
|
||||
custom_css: customCss.value,
|
||||
brand_html: brandHtml.value,
|
||||
transition: transition.value,
|
||||
contact_enabled: contactEnabled.value,
|
||||
contact_email: contactEmail.value,
|
||||
// The originals: while a translation override is being edited the
|
||||
// input refs hold the override, not the original.
|
||||
@@ -363,6 +368,7 @@ async function saveSettings(opts = {}) {
|
||||
} else {
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
return res.ok
|
||||
}
|
||||
|
||||
async function onThemeChange() {
|
||||
@@ -758,51 +764,34 @@ onUnmounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<div class="block-head msg-head">
|
||||
<LangSelect
|
||||
v-model="lang"
|
||||
:options="contactLangOptions"
|
||||
title="Language of the label/subject strings (auto translators fill these too; this edits the manual override)"
|
||||
/>
|
||||
<label
|
||||
class="msg-toggle"
|
||||
title="Visitor messaging ({contact:} buttons, corner icon)"
|
||||
>
|
||||
<input v-model="contactEnabled" type="checkbox" @change="saveSettings()" />
|
||||
<span class="field-label">messaging</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">email</span>
|
||||
<input
|
||||
v-model="contactEmail"
|
||||
class="text-input"
|
||||
title="Contact email — a mailto link in the visitor dialog (empty = no link)"
|
||||
:disabled="!!translating"
|
||||
@input="debounce('contact-email', () => saveSettings())"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">label</span>
|
||||
<input
|
||||
v-model="contactLabel"
|
||||
class="text-input"
|
||||
title="Default {contact:} button label and visitor dialog title (empty = built-in default)"
|
||||
:placeholder="translating ? contactLabelOrig : 'Contact us'"
|
||||
@input="onContactInput"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">subject</span>
|
||||
<input
|
||||
v-model="contactSubject"
|
||||
class="text-input"
|
||||
title="Subject for the mailto link (empty = the label)"
|
||||
:placeholder="translating ? contactSubjectOrig : ''"
|
||||
@input="onContactInput"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
v-model="contactLabel"
|
||||
class="text-input"
|
||||
:placeholder="translating ? contactLabelOrig : 'Contact us'"
|
||||
title="Messaging: the {contact:} button label and visitor dialog title — empty disables messaging"
|
||||
@input="onContactInput"
|
||||
/>
|
||||
<input
|
||||
v-model="contactEmail"
|
||||
class="text-input"
|
||||
placeholder="email"
|
||||
title="Contact email — a mailto link in the visitor dialog, revealed only after the proof of work is solved (empty = no link)"
|
||||
:disabled="!!translating"
|
||||
@input="debounce('contact-email', () => saveSettings())"
|
||||
/>
|
||||
<input
|
||||
v-model="contactSubject"
|
||||
class="text-input"
|
||||
:placeholder="translating ? contactSubjectOrig : 'subject'"
|
||||
title="Subject for the mailto link (empty = the label)"
|
||||
@input="onContactInput"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -936,13 +925,16 @@ onUnmounted(() => {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* The messaging block's single-line header: the article editor's flag
|
||||
selector, then the enabled toggle fused with the section label. */
|
||||
.msg-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
/* The messaging block: one line — the article editor's flag selector,
|
||||
then small text boxes (label = the enable switch, email, subject). */
|
||||
.msg-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.msg-head .text-input {
|
||||
flex: 1 1 7rem;
|
||||
min-width: 5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
|
||||
@@ -54,6 +54,11 @@ function readContactStrings() {
|
||||
// PoW is solved and stays valid for the session.
|
||||
}
|
||||
|
||||
// Page swaps (fetch navigation, editor re-renders after settings saves)
|
||||
// carry fresh per-language config: keep the dialog strings in sync
|
||||
// without reopening.
|
||||
addEventListener('pagerite:page-fetched', readContactStrings)
|
||||
|
||||
// The panel is main content, not a sidebar: while open it holds paskia's
|
||||
// shared blur backdrop (refcounted, z-index 1099; the overlay itself sits
|
||||
// at 1100, below the auth iframe's 9999).
|
||||
|
||||
+3
-8
@@ -302,7 +302,7 @@ async def get_settings(lang: str | None = None) -> dict:
|
||||
# Unanswered contact threads: the corner ✉️ icon's badge for
|
||||
# admins (this probe runs on every page load; no polling).
|
||||
"contact_unread": messages.unread_count(),
|
||||
"contact_enabled": data.contact_enabled,
|
||||
# Messaging is enabled by configuring a label (empty = off).
|
||||
"contact_label": data.contact_label,
|
||||
"contact_email": data.contact_email,
|
||||
"contact_subject": data.contact_subject,
|
||||
@@ -314,7 +314,7 @@ async def get_settings(lang: str | None = None) -> dict:
|
||||
tag = i18n.base_tag(lang or "")
|
||||
if tag and tag != out["primary_lang"]:
|
||||
for field in ("contact_label", "contact_subject"):
|
||||
original = getattr(data, field) or ("Contact us" if field == "contact_label" else "")
|
||||
original = getattr(data, field)
|
||||
out[f"{field}_tr"] = (
|
||||
data.trans.get(chunk_key(original), {}).get(tag, "") if original else ""
|
||||
)
|
||||
@@ -331,7 +331,6 @@ class SettingsIn(BaseModel):
|
||||
transition: str = "cube"
|
||||
translate_langs: list[str] | None = None # None keeps the current set
|
||||
translate_keys: dict[str, str] | None = None # None keeps the current keys
|
||||
contact_enabled: bool | None = None # None keeps the current setting
|
||||
contact_label: str | None = None
|
||||
contact_email: str | None = None
|
||||
contact_subject: str | None = None
|
||||
@@ -357,9 +356,7 @@ async def put_settings(
|
||||
value = getattr(settings, field)
|
||||
if value is None:
|
||||
continue
|
||||
original = getattr(data, field) or (
|
||||
"Contact us" if field == "contact_label" else ""
|
||||
)
|
||||
original = getattr(data, field)
|
||||
if original:
|
||||
changed |= i18n.set_string_translation(
|
||||
data, tag, original, value.strip()
|
||||
@@ -373,8 +370,6 @@ async def put_settings(
|
||||
data.theme = settings.theme
|
||||
data.custom_css = settings.custom_css
|
||||
data.transition = settings.transition
|
||||
if settings.contact_enabled is not None:
|
||||
data.contact_enabled = settings.contact_enabled
|
||||
if settings.contact_label is not None:
|
||||
data.contact_label = settings.contact_label.strip()
|
||||
if settings.contact_email is not None:
|
||||
|
||||
+8
-9
@@ -210,17 +210,16 @@ class Data(msgspec.Struct):
|
||||
#: Visitor contact threads (docs/messages.md), keyed by the blake3
|
||||
#: hash of the visitor's session token (never the token itself).
|
||||
contact: dict[bytes, ContactThread] = {}
|
||||
#: Messaging on/off: when False, {contact:} buttons do not render,
|
||||
#: visitors get no corner ✉️ icon and sends are rejected (admins keep
|
||||
#: the inbox for the existing threads).
|
||||
contact_enabled: bool = True
|
||||
#: Site-wide default label for the {contact:} button and the visitor
|
||||
#: dialog title (a per-button {contact: Label} arg wins). Empty = the
|
||||
#: built-in "Contact us". Translated per language in Data.trans, keyed
|
||||
#: by chunk hash like titles (i18n.site_string).
|
||||
#: The {contact:} button label and visitor dialog title — and the
|
||||
#: feature's on/off switch: EMPTY disables messaging (no buttons, no
|
||||
#: visitor corner icon, sends rejected; admins keep the inbox). A
|
||||
#: per-button {contact: Label} arg overrides it. Translated per
|
||||
#: language in Data.trans, keyed by chunk hash like titles
|
||||
#: (i18n.site_string).
|
||||
contact_label: str = ""
|
||||
#: Contact email shown as a mailto link in the visitor dialog (with
|
||||
#: the subject prefilled). Empty = no link.
|
||||
#: the subject prefilled), revealed over the socket only after the
|
||||
#: proof of work is solved. Empty = no link.
|
||||
contact_email: str = ""
|
||||
#: Subject for the mailto link; empty = the (localized) label.
|
||||
#: Translated like contact_label.
|
||||
|
||||
+10
-3
@@ -234,6 +234,12 @@ def _append(
|
||||
# --- Visitor protocol (/_ws frames carrying "token") ------------------------
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""Messaging on/off: the site contact label doubles as the switch —
|
||||
empty disables the feature (docs/messages.md)."""
|
||||
return bool(data.contact_label.strip())
|
||||
|
||||
|
||||
def quota_left(token: str) -> int:
|
||||
"""Remaining PoW-granted actions for a session token (0 for malformed
|
||||
tokens — they fail validation later anyway)."""
|
||||
@@ -289,9 +295,10 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None:
|
||||
if len(text) > MAX_MESSAGE:
|
||||
await ws.send_json({"type": "contact-error", "detail": "message too long"})
|
||||
return
|
||||
if not data.contact_enabled:
|
||||
# Messaging off in the site settings: sends are rejected (the UI
|
||||
# is already hidden); existing threads and admin replies stay.
|
||||
if not enabled():
|
||||
# Messaging off (no contact label configured): sends are rejected
|
||||
# (the UI is already hidden); existing threads and admin replies
|
||||
# stay.
|
||||
await ws.send_json({"type": "contact-error", "detail": "messaging is disabled"})
|
||||
return
|
||||
# The first message must introduce the visitor (name; email optional,
|
||||
|
||||
@@ -311,11 +311,11 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
|
||||
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
||||
# Site-level messaging strings (Data.contact_label/contact_subject):
|
||||
# translated like titles and looked up at render time by chunk key
|
||||
# (i18n.site_string) — the EFFECTIVE label, so the built-in default
|
||||
# localizes too. Skipped in the site default language, like titles in
|
||||
# their article's primary language.
|
||||
if data.contact_enabled and lang != i18n.primary_lang(data.menu, ""):
|
||||
for text in (data.contact_label or "Contact us", data.contact_subject):
|
||||
# (i18n.site_string). Skipped when messaging is off (no label — the
|
||||
# label doubles as the enable switch) and in the site default
|
||||
# language, like titles in their article's primary language.
|
||||
if data.contact_label.strip() and lang != i18n.primary_lang(data.menu, ""):
|
||||
for text in (data.contact_label, data.contact_subject):
|
||||
if text and "\n" not in text:
|
||||
emit(chunk_key(text), text, "", "title")
|
||||
return items
|
||||
@@ -548,8 +548,8 @@ class Dispatcher:
|
||||
# The site-level messaging strings ride the same list (pending_items
|
||||
# covers them for scoped jobs): one round trip names the menu and
|
||||
# the contact chrome alike.
|
||||
if self.data.contact_enabled and lang != i18n.primary_lang(self.data.menu, ""):
|
||||
for text in (self.data.contact_label or "Contact us", self.data.contact_subject):
|
||||
if self.data.contact_label.strip() and lang != i18n.primary_lang(self.data.menu, ""):
|
||||
for text in (self.data.contact_label, self.data.contact_subject):
|
||||
if text and "\n" not in text:
|
||||
key = chunk_key(text)
|
||||
if (
|
||||
|
||||
+11
-8
@@ -920,7 +920,7 @@ def page_content(
|
||||
directives = {
|
||||
"contact": (
|
||||
(lambda args, _env: _contact_tag(args, _env, data, lang))
|
||||
if data.contact_enabled
|
||||
if data.contact_label.strip()
|
||||
else (lambda _args, _env: "")
|
||||
)
|
||||
}
|
||||
@@ -1013,7 +1013,7 @@ def _contact_config(data: Data, lang: str) -> dict[str, str]:
|
||||
chunk-keyed trans store (i18n.site_string). 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 or "Contact us")
|
||||
label = i18n.site_string(data, lang, data.contact_label)
|
||||
return {
|
||||
"label": label,
|
||||
"subject": i18n.site_string(data, lang, data.contact_subject) or label,
|
||||
@@ -1024,13 +1024,16 @@ def _contact_tag(args: str, _env: dict, data: Data | None = None, lang: str = ""
|
||||
"""Expand a ``{contact: Label}`` directive: the button that opens the
|
||||
visitor contact panel (docs/messages.md). The args are a per-button
|
||||
label overriding the configured site label (Data.contact_label,
|
||||
localized here). pagerite.js binds the click and lazy-loads the panel
|
||||
bundle; in the editor preview it stays inert."""
|
||||
localized here; "Contact us" is just the inert preview's placeholder
|
||||
when nothing is configured). pagerite.js binds the click and
|
||||
lazy-loads the panel bundle; in the editor preview it stays inert."""
|
||||
label = args.strip()
|
||||
if not label and data is not None:
|
||||
label = i18n.site_string(data, lang, data.contact_label)
|
||||
return str(
|
||||
E.button(
|
||||
E.span("✉️", class_="icon"),
|
||||
args.strip()
|
||||
or (_contact_config(data, lang)["label"] if data else "Contact us"),
|
||||
label or "Contact us",
|
||||
type="button",
|
||||
class_="contact-trigger",
|
||||
)
|
||||
@@ -1456,7 +1459,7 @@ def render_page(
|
||||
alternates,
|
||||
# The page's Markdown counterpart (pages.show_page's .md mode).
|
||||
f"/{path}.md",
|
||||
contact=_contact_config(data, lang) if data.contact_enabled else None,
|
||||
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
|
||||
)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand, brand_html, link_lang),
|
||||
@@ -1520,7 +1523,7 @@ def render_category(
|
||||
favicon,
|
||||
lang=lang,
|
||||
alternates=alternates,
|
||||
contact=_contact_config(data, lang) if data.contact_enabled else None,
|
||||
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
|
||||
)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand, brand_html, link_lang),
|
||||
|
||||
Reference in new issue
Block a user