diff --git a/docs/messages.md b/docs/messages.md
index dd50919..df06c1d 100644
--- a/docs/messages.md
+++ b/docs/messages.md
@@ -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
``. 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
diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue
index f3c38de..b9ec35c 100644
--- a/frontend/src/SiteEditor.vue
+++ b/frontend/src/SiteEditor.vue
@@ -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(() => {
-
+
-
-
-
-
-
-
+
+ saveSettings())"
+ />
+
@@ -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 {
diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js
index 8d43525..7c8b5d2 100644
--- a/frontend/src/contact-main.js
+++ b/frontend/src/contact-main.js
@@ -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).
diff --git a/pagerite/api.py b/pagerite/api.py
index 8f4bc61..713ca94 100644
--- a/pagerite/api.py
+++ b/pagerite/api.py
@@ -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:
diff --git a/pagerite/data.py b/pagerite/data.py
index 53e5c8b..71a2c4a 100644
--- a/pagerite/data.py
+++ b/pagerite/data.py
@@ -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.
diff --git a/pagerite/messages.py b/pagerite/messages.py
index 1ec3188..1595be4 100644
--- a/pagerite/messages.py
+++ b/pagerite/messages.py
@@ -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,
diff --git a/pagerite/translate.py b/pagerite/translate.py
index ab3648c..1e25c1e 100644
--- a/pagerite/translate.py
+++ b/pagerite/translate.py
@@ -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 (
diff --git a/pagerite/views.py b/pagerite/views.py
index b70fff7..1ef278f 100644
--- a/pagerite/views.py
+++ b/pagerite/views.py
@@ -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),