diff --git a/AGENTS.md b/AGENTS.md index 2674238..46b056c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `frontend/src/` — Vue editor and public-page JS entries. - `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). + - `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`). - `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling. diff --git a/docs/localization.md b/docs/localization.md index 62c593b..e691342 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -53,11 +53,13 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`). article's own language), `?lang=xx` when serving a translation — however the language was arrived at (query or header). - `` entries follow the canonical - directly (before the social meta tags) and are the same set on every - page — the site-wide configured languages (`translate_langs`, which the - translator works to fill in): `x-default` first, pointing at the plain - autodetecting URL, then every language explicitly with `?lang=`, the - page's own primary language included. + directly (before the social meta tags) and list the languages the page + is **actually available in**: `x-default` first, pointing at the plain + autodetecting URL, then every available language — the original again by + its plain URL, translations by `?lang=`. The public language selector + keys off these: pagerite.js mounts the editors' flag dropdown in the + top-right corner when the head advertises x-default plus more than one + language, loading its bundle (Vue + the flag SVG set) on demand. - The override sticks for the session of clicks: a page requested with `?lang=` replicates the query onto the navigation links it renders (nav, sidebar, cards, brand — in-article links are content and stay as @@ -66,6 +68,11 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`). `history.replaceState` (pretty, shareable URLs), remembers the language, and adds it to every internal fetch that lacks one (preloads, fetch-navigations, history traversals); history entries stay query-less. +- The public selector's pick is the same override, pure JS state + (`pagerite:set-session-lang`): the session language changes and the page + swaps in place — no `?lang=` in the address bar, no reload. The choice is + linked with the editor panel's language dropdown both ways; closing the + panel keeps the chosen language instead of reverting. - A full page refresh or a shared link resets to automatic selection (header only). This gives a clean one-time override without cookies. diff --git a/frontend/package.json b/frontend/package.json index 50f02b2..c942881 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "codemirror": "^6.0.2", "country-flag-icons": "^1.6.20", "overlayscrollbars": "^2.16.0", + "pinia": "^4.0.3", "transliteration": "^2.6.1", "vue": "^3.5.26", "vuedraggable": "^4.1.0" diff --git a/frontend/src/EditorShell.vue b/frontend/src/EditorShell.vue index a53d152..471da31 100644 --- a/frontend/src/EditorShell.vue +++ b/frontend/src/EditorShell.vue @@ -21,11 +21,11 @@ const currentPath = ref(props.pagePath) const activeMode = ref(props.initialMode) // The shared language selection (./editorLang, v-modeled by the tabs' -// LangSelects) also drives the page preview: while the shell is open it -// overrides the normal language preferences (?lang= / Accept-Language), -// so the page renders in the language being edited; closing restores. -// The primary selection pins by the CURRENT PAGE's own primary language -// (pages may differ — Node.language is inherited down the tree). +// LangSelects) is linked to the whole-page language: while the shell is +// open it drives the page preview (overrides ?lang= / Accept-Language), +// and closing keeps the pick as the session language. The primary +// selection pins by the CURRENT PAGE's own primary language (pages may +// differ — Node.language is inherited down the tree). let pinned = false function pinPreviewLang() { pinned = true @@ -34,6 +34,15 @@ function pinPreviewLang() { setLangOverride(editorLang.value || pagePrimary.value || 'en') loadPlain(currentPath.value) } +// Opening the panel must not switch the page's language: adopt the +// session's chosen language (public selector / earlier pick) once, then +// pin. Runs only on (re)open — after that the selection is the user's. +function openShell() { + const session = window.__pageriteLang + if (!editorLang.value && session && session !== (pagePrimary.value || 'en')) + editorLang.value = session + pinPreviewLang() +} function unpinPreviewLang() { if (!pinned) return pinned = false @@ -89,13 +98,13 @@ function onSwitchEvent(ev) { onMounted(() => { document.body.dataset.editorMode = activeMode.value addEventListener('pagerite:switch-editor', onSwitchEvent) - addEventListener('pagerite:editor-shown', pinPreviewLang) + addEventListener('pagerite:editor-shown', openShell) addEventListener('pagerite:editor-hidden', unpinPreviewLang) - // The shell mounts visible (openEditor), so pin immediately. The site + // The shell mounts visible (openEditor), so open immediately. The site // default primary language comes from the settings — it only fills the // unknown; the page/structure tabs refine pagePrimary per page as they // learn it (their knowledge is strictly better). - pinPreviewLang() + openShell() fetch('/_api/settings').then((r) => r.json()).then((s) => { if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en' }).catch(() => { /* keep the fallback */ }) @@ -103,7 +112,7 @@ onMounted(() => { onUnmounted(() => { removeEventListener('pagerite:switch-editor', onSwitchEvent) - removeEventListener('pagerite:editor-shown', pinPreviewLang) + removeEventListener('pagerite:editor-shown', openShell) removeEventListener('pagerite:editor-hidden', unpinPreviewLang) }) diff --git a/frontend/src/LangSelect.vue b/frontend/src/LangSelect.vue index 6c0ba28..ac51db8 100644 --- a/frontend/src/LangSelect.vue +++ b/frontend/src/LangSelect.vue @@ -3,7 +3,8 @@ // flag button opening a clean dropdown, v-modeled on the shared editorLang // ('' = the primary language). The lang tab's flag grid is a different // control (toggles, not a select) and stays as it is. -import { computed, ref } from 'vue' +import { computed, nextTick, ref } from 'vue' +import { usePopup } from './dropdown' const props = defineProps({ modelValue: { type: String, default: '' }, @@ -13,8 +14,12 @@ const props = defineProps({ const emit = defineEmits(['update:modelValue']) const open = ref(false) +const root = ref(null) const toggleBtn = ref(null) +const pop = ref(null) const popStyle = ref({}) +// Closes on outside click / Escape (./dropdown), not on mouseleave. +usePopup(open, root) const current = computed( () => props.options.find((o) => o.tag === props.modelValue) ?? props.options[0], ) @@ -26,6 +31,17 @@ function toggle() { // onto the page area instead of being clipped by it. const r = toggleBtn.value.getBoundingClientRect() popStyle.value = { top: `${r.bottom + 2}px`, left: `${r.left}px` } + // A toggle mounted near the right window edge (the public page + // selector sits top-right) opens the popup flush against that edge. + nextTick(() => { + const p = pop.value?.getBoundingClientRect() + if (p && p.right > innerWidth - 4) { + popStyle.value = { + ...popStyle.value, + left: `${Math.max(4, innerWidth - 4 - p.width)}px`, + } + } + }) } } @@ -36,7 +52,7 @@ function select(tag) {