From 8d577dc56ebb57b85204fdc4cec0dfd13fceb763 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 28 Sep 2026 15:43:01 +0000 Subject: [PATCH] contact messages: label is the enable switch; single-line settings row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/messages.md | 42 +++++++------ frontend/src/SiteEditor.vue | 112 ++++++++++++++++------------------- frontend/src/contact-main.js | 5 ++ pagerite/api.py | 11 +--- pagerite/data.py | 17 +++--- pagerite/messages.py | 13 +++- pagerite/translate.py | 14 ++--- pagerite/views.py | 19 +++--- 8 files changed, 119 insertions(+), 114 deletions(-) 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(() => {
-
+
- -
-
- - - + + +
@@ -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),