diff --git a/AGENTS.md b/AGENTS.md index ddb5460..c992f4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,17 +10,18 @@ Please instead ask the user to see from dev tools what you need, e.g. to look up Pagerite is a CMS. See `docs` for the full design and implementation details. Key files for code changes: - `pagerite/` — Python backend package (hatchling build target). - - `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/files routers, then `frontend.route(app, "/")`, then the pages catch-all last). + - `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/messages/files routers, then `frontend.route(app, "/")`, then the pages catch-all last). - `state.py` — shared core, no routes: env-derived site constants, `data`/`kanta`, `analytics_store`, the fastapi-vue `frontend`, the render cache (`_html_response`, `_invalidate_pages`), the translator `dispatcher`, slug helpers, `@kanta.bootstrap` hooks. - - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`), file routes (`/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, favicon settings). + - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings). - `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`. - - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). + - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames and holds the per-connection easypow challenge (a validated proof grants the per-session message/upload quota held in messages.py, reveals the site contact email, and records the visit's contact trail marker), the analytics socket the admin inbox. + - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols (PoW challenges live in `tracking.activity_ws` as a per-connection local; the granted quota here as a per-session RAM budget shared with the upload route; browser solver served at `/_contact/easypow.js`), the admin unread count for `/_api/settings`, and the public `/_contact/files` upload route (PoW quota + pending-upload quota, unreferenced files deleted when the session's last socket closes). - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via ``, and llms.txt links point at the `.md` URLs. - `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `` and the sitemap, recorded in analytics. - `data.py` — msgspec Structs for the kanta database. - `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md). - - `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh). - - `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route. + - `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh), plus the site-string translations (`site_string`/`set_string_translation`) for the contact label/subject (docs/messages.md). + - `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route. Pending title/nav work includes the site contact strings, so the contact chrome localizes automatically. - `segments.py` — the translation round trip: fragments split into pure-prose wire segments (via markdown.make_md's verbatim parser; link- and formatting-carrying blocks stay whole, link/formatted texts inline, Markdown stripped) and translations spliced back by source offset, link/formatting markdown re-inserted at weight-mapped positions (docs/localization.md). - `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field. - `markdown.py` — markdown-it-py renderer. @@ -31,6 +32,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `main.js` — Vue editor app entry. - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). - `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown). + - `contact-main.js` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked, and at page load when a message session is live: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected while open — a background `watchAdmin` connection keeps the badge and notifications live at all times). A visitor's open conversation is flagged in localStorage and resumed on page load; closing it archives the thread server-side and rotates the token. Both sides notify new messages via the browser Notification API (`notify.js`), permission asked after the first message of a fresh thread. + - `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly. - `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state. - `pagerite.js` — public page entry. - `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`). @@ -45,7 +48,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed). ## Toolchain -- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). +- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `easypow`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). - Key libraries: - **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML). - To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header diff --git a/docs/analytics.md b/docs/analytics.md index 4e563e5..43a4b23 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -199,7 +199,11 @@ for misses. re-visited in a different language becomes a distinct trail step instead of merging into the existing item. The entry page's referer and `utm_*` tags come from the GET that loaded it (within 10 s before the - first message). + first message). A server-originated contact marker (the visitor opened + the contact panel and solved its proof of work, docs/messages.md — + recorded by the `/_ws` handler on the validated solution, never by the + client) flags the trail step it happened on, the first one per visit, + rendered with a ✉️ marker in the viewer. - **Crawler hits**: a document GET no activity message matched within `_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch documents never register as visits. JS-running crawlers (Googlebot, diff --git a/docs/frontend-runtime.md b/docs/frontend-runtime.md index 5c86b46..d0f56aa 100644 --- a/docs/frontend-runtime.md +++ b/docs/frontend-runtime.md @@ -14,7 +14,7 @@ It first probes `GET /auth/api/settings` to detect whether Paskia SSO is availab The editor/analytics components (everything except pagerite.js and main.js) make their `/_api` calls through paskia-js's `apiFetch`/`apiJson` instead of plain `fetch`: when a call gets a 401/403 carrying an `auth.iframe` hint (expired session), the login dialog opens in place and the request retries transparently after authentication. pagerite.js uses `apiJson` only for the task-checkbox toggle (ticking a box is an explicit edit attempt, so a login dialog is welcome there; any failure — including a cancelled login — reverts the checkbox) and `fetchJson` for its auth probes (plain fetch with JSON handling and errors on non-OK, never a dialog); page-cache and navigation fetches stay on plain `fetch`, so anonymous visitors never get a login popup uninvited. -Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as ` diff --git a/frontend/src/ContactOverlay.vue b/frontend/src/ContactOverlay.vue new file mode 100644 index 0000000..223c77f --- /dev/null +++ b/frontend/src/ContactOverlay.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue new file mode 100644 index 0000000..76380e0 --- /dev/null +++ b/frontend/src/ContactPanel.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue new file mode 100644 index 0000000..7ea90fa --- /dev/null +++ b/frontend/src/MessagesView.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 4d871e8..221aa60 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -12,6 +12,9 @@ import { html } from '@codemirror/lang-html' import { cmHighlight, cmTheme } from './cmtheme' import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { apiFetch, apiJson } from 'paskia' +import { editorLang } from './editorLang' +import LangSelect from './LangSelect.vue' +import { flagFor, langName, langSort } from './langs' const props = defineProps({ pagePath: { type: String, default: '' }, @@ -57,6 +60,103 @@ async function errorDetail(res) { // empty brand removes the header link and the title suffix entirely. const brand = ref('') const theme = ref('') + +// --- Messaging (contact) --------------------------------------------------- +// Contact messaging settings: a two-row grid (labels above, the flag +// selector and the boxes below). 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 and mail server are global: the +// email's reveal after the PoW solve is server-side, and the mail +// server (a mailbridge URL, docs/messages.md; empty = no email) is +// reserved for email notifications. +const contactEmail = ref('') +const contactLabel = ref('') +const contactSubject = ref('') +const contactMailserver = ref('') +const contactLabelOrig = ref('') +const contactSubjectOrig = ref('') +// Password managers offer credential autofill on these boxes no matter +// the autocomplete setting; readonly until the first interaction keeps +// them off (the vendors' ignore attributes on the inputs back it up). +const pmGuard = ref(true) +const pmUnlock = () => { pmGuard.value = false } +const lang = editorLang // '' = the original strings (the primary language) +const primaryLang = ref('en') +const translateLangs = ref([]) +const translating = computed(() => lang.value && lang.value !== primaryLang.value) + +// The flag selector's options, built like the article editor's: the +// primary language first (tag ''), then the configured targets in the +// geographic grouping. +const contactLangOptions = computed(() => { + const others = langSort(translateLangs.value.filter((l) => l && l !== primaryLang.value)) + return [primaryLang.value, ...others].map((code) => ({ + tag: code === primaryLang.value ? '' : code, + code, + name: langName(code), + flag: flagFor(code), + primary: code === primaryLang.value, + })) +}) + +async function onContactLangChange() { + if (!translating.value) { + contactLabel.value = contactLabelOrig.value + contactSubject.value = contactSubjectOrig.value + return + } + try { + const s = await apiJson(`/_api/settings?lang=${lang.value}`) + contactLabel.value = s.contact_label_tr || '' + contactSubject.value = s.contact_subject_tr || '' + } catch { /* keep empty */ } +} +watch(lang, onContactLangChange) + +function onContactInput() { + // Capture the language being edited: a debounced save must not land in + // whatever language happens to be selected when the timer fires. + const tag = lang.value + if (!tag || tag === primaryLang.value) { + contactLabelOrig.value = contactLabel.value + contactSubjectOrig.value = contactSubject.value + } + debounce('contact', () => saveContact(tag, contactLabel.value, contactSubject.value)) +} + +async function saveContact(tag = lang.value, label = '', subject = '') { + if (tag && tag !== primaryLang.value) { + // Translation override: only the label/subject apply (empty = drop). + const res = await apiFetch(`/_api/settings?lang=${tag}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + brand: brand.value, + theme: theme.value, + custom_css: customCss.value, + contact_label: label, + contact_subject: subject, + }), + }) + saveError.value = res.ok ? '' : '⚠️ changes could not be saved' + if (res.ok) rerender() + return + } + 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. const themeOptions = ref([{ value: '', label: '☀️ none' }]) @@ -88,6 +188,16 @@ async function loadSettings() { ] transition.value = s.transition || 'cube' transitionOptions.value = s.transitions || [] + contactEmail.value = s.contact_email || '' + contactMailserver.value = s.contact_mailserver || '' + contactLabelOrig.value = s.contact_label || '' + contactSubjectOrig.value = s.contact_subject || '' + if (!translating.value) { + contactLabel.value = contactLabelOrig.value + contactSubject.value = contactSubjectOrig.value + } + primaryLang.value = s.primary_lang || 'en' + translateLangs.value = s.translate_langs || [] fontOptions.value = [ ...BASE_FONT_OPTIONS, ...(s.fonts || []).map((f) => ({ @@ -255,6 +365,12 @@ async function saveSettings(opts = {}) { custom_css: customCss.value, brand_html: brandHtml.value, transition: transition.value, + contact_email: contactEmail.value, + contact_mailserver: contactMailserver.value, + // The originals: while a translation override is being edited the + // input refs hold the override, not the original. + contact_label: contactLabelOrig.value, + contact_subject: contactSubjectOrig.value, ...opts, }), }) @@ -264,6 +380,7 @@ async function saveSettings(opts = {}) { } else { saveError.value = '⚠️ changes could not be saved' } + return res.ok } async function onThemeChange() { @@ -528,6 +645,9 @@ onMounted(async () => { parent: brandEl.value, }) await loadSettings() + // The shared editorLang may already point at a translation (picked in + // another tab): load its label/subject overrides. + await onContactLangChange() parseFonts(customCss.value) setCssDocument(customCss.value) applyCustomCss(customCss.value) @@ -655,6 +775,88 @@ onUnmounted(() => { +
+
+
+ + +
+ + + + +
+
+
brand code (replaces the brand link) @@ -785,6 +987,48 @@ onUnmounted(() => { font-size: 1rem; } +/* The messaging block: a two-row grid — labels on the first row, the + article editor's flag selector and the boxes on the second, so the + flag centers with the boxes and every label sits directly above its + own field (contact button label = the enable switch, email subject, + our email; the hidden mail-server field joins the email group once the + backend lands). The flag column sizes + to the flag; when only one language is configured the selector renders + nothing and the column collapses. + The boxes are readonly until first interaction (+ vendor ignore + attributes): password managers offer credential autofill on them no + matter the autocomplete setting. */ +.msg-grid { + display: grid; + /* The hidden mail-server field (template comment) re-adds a column. */ + grid-template-columns: auto repeat(3, minmax(0, 1fr)); + align-items: center; + gap: 0.15rem 0.6rem; +} + +/* Field wrappers vanish into the grid: labels land on row 1 (the + flag's empty spacer keeps the fields in the 1fr columns whether or + not the flag renders), the flag and boxes on row 2. */ +.msg-grid .field, +.msg-grid .msg-flag { + display: contents; +} + +.msg-grid .field-label { + grid-row: 1; + min-width: 0; +} + +.msg-grid .lang-select, +.msg-grid .text-input { + grid-row: 2; +} + +.msg-grid .text-input { + flex: none; + font-size: 0.85rem; +} + .text-input { flex: 1; min-width: 4rem; diff --git a/frontend/src/TrailLink.vue b/frontend/src/TrailLink.vue index c892775..2ae4597 100644 --- a/frontend/src/TrailLink.vue +++ b/frontend/src/TrailLink.vue @@ -24,6 +24,9 @@ const title = computed(() => { if (hasError.value) { parts.push(`${props.step.status}`) } + if (props.step.contact) { + parts.push('contact panel opened, proof of work solved') + } return parts.filter(Boolean).join(' — ') }) @@ -39,6 +42,7 @@ const title = computed(() => { {{ formatCount(count) }}× {{ step.slug }} + ✉️ @@ -51,6 +55,12 @@ const title = computed(() => { vertical-align: -0.1em; } +/* The contact-panel marker (PoW solved on this step). */ +.contact-mark { + margin-left: 0.25em; + font-size: 0.85em; +} + /* Same flag chip as the visitor cells (VisitorCell.vue). */ .flag { display: inline-flex; diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 09020e2..61ee60c 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -656,6 +656,8 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit if (item.read) step.readSeconds = item.read if (item.status) step.status = item.status if (item.lang) step.lang = item.lang + // Contact panel opened here and its PoW solved (✉️ marker). + if (item.contact) step.contact = true } return step }) @@ -667,6 +669,7 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang) if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds if (step.status) prev.status = step.status + if (step.contact) prev.contact = true } else { step.langs = step.lang ? [step.lang] : [] trail.push(step) diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index ab095bc..6416d79 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -72,6 +72,10 @@ --font-heading: var(--font-source-serif); /* The brand follows the heading font unless overridden separately. */ --font-brand: var(--font-heading); + /* Navbar typography (family and weight only — size/letter-case stay + per-theme #nav rules); the {contact:} trigger button matches it. */ + --font-nav: var(--font-body); + --nav-weight: 400; --font-code: var(--font-fira-code); /* Target x-height ratio for code text: set to the body font's ratio (here Source Sans 3's 0.478) so font-size-adjust can scale the code @@ -258,6 +262,8 @@ body { /* Nav overlaid at the bottom of the banner */ #nav { + font-family: var(--font-nav); + font-weight: var(--nav-weight); font-size: 1.3em; padding: 0.35rem 1.25rem; } @@ -407,6 +413,22 @@ body.editing #sidebar { text-decoration: none; } +/* The corner ✉️ (docs/messages.md) is in the pens row for everyone, so + needs its own positioning context. Unread (a new reply for visitors, + unanswered threads for admins) lifts it out of the icon-btn dimming + and animates it (see contact-nudge by the trigger button). + ✉️'s envelope glyph runs smaller than its emoji neighbors (📊, 🔐, the + flag) at the same font size; bump it to match their height (scoped to + .editor-pens to out-specify the row's `font: inherit` reset). */ +.editor-pens .contact-icon { + position: relative; + font-size: 1.38em; +} + +.contact-icon.has-unread { + opacity: 1; +} + #sidebar { grid-column: 1; /* Pinned to the page's start edge (not the article's) and kept in view @@ -878,6 +900,76 @@ article { opacity: 1; } +/* {contact: Label} directive: the button opening the visitor contact + panel (docs/messages.md). A standalone accent button in the article + flow; .has-unread (set by contact-main) marks a new admin reply. + Filled with the accent and hovered like the accent-filled navbar tab: + text in --ink (nitro; --bg elsewhere), hover switches to the bright + --tab-hover with the same glow (text-shadow, which fades dimmer, so + the color stays bright). */ +.contact-trigger { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.35em; + margin: 0.3em 0.1em; + padding: 0.35em 1.1em; + /* Same font and weight as the navbar, size/leading as the article text. */ + font-size: inherit; + line-height: inherit; + font-family: var(--font-nav); + font-weight: var(--nav-weight); + color: var(--ink, var(--bg)); + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 1.2em; + cursor: pointer; +} + +.contact-trigger .icon { + /* Match the corner ✉️'s optical size against the button text. The + reduced line-height keeps the larger glyph from inflating the + button: 1.38 × 1.2 ≈ the label text's own 1.65 line box. */ + font-size: 1.38em; + line-height: 1.2; +} + +.contact-trigger:hover { + color: var(--tab-hover, var(--bg)); + text-shadow: 0 0 0.1em; +} + +/* Unread (a new admin reply, or unanswered threads on the corner icon — + the .has-unread class, set by contact-main): a scale-up with a shake + every few seconds to draw the eye, stopping when read (the class goes + away). No static badge; reduced-motion users just get the lift above. */ +@media (prefers-reduced-motion: no-preference) { + .contact-trigger.has-unread, + .contact-icon.has-unread { + animation: contact-nudge 4.5s ease-in-out infinite; + } + + @keyframes contact-nudge { + 0%, 16%, 100% { transform: none; } + 2% { transform: scale(1.15) rotate(-8deg); } + 5% { transform: scale(1.15) rotate(7deg); } + 8% { transform: scale(1.15) rotate(-5deg); } + 11% { transform: scale(1.15) rotate(4deg); } + 14% { transform: scale(1.12); } + } +} + +/* Any button in the article flow (the {contact:} trigger and raw +