Public language selector, linked with the editor's language selection. Shared popup menu operation with edit toolbar with consistent closing logic.
This commit is contained in:
@@ -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.
|
||||
|
||||
+12
-5
@@ -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).
|
||||
- `<link rel="alternate" hreflang="…">` 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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="options.length > 1" class="lang-select">
|
||||
<span v-if="options.length > 1" ref="root" class="lang-select">
|
||||
<button
|
||||
ref="toggleBtn"
|
||||
type="button"
|
||||
@@ -47,7 +63,7 @@ function select(tag) {
|
||||
: '')"
|
||||
@click="toggle"
|
||||
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
|
||||
<span v-if="open" class="lang-pop" :style="popStyle" @mouseleave="open = false">
|
||||
<span v-if="open" ref="pop" class="lang-pop" :style="popStyle">
|
||||
<button
|
||||
v-for="o in options"
|
||||
:key="o.code"
|
||||
@@ -66,20 +82,23 @@ function select(tag) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The closed state is just the small flag — no button chrome until hovered. */
|
||||
/* The closed state is just the small flag — no button chrome at all, on
|
||||
hover either (it sits among borderless emoji-icon buttons); like them it
|
||||
rests dimmed and brightens on hover. */
|
||||
.lang-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.lang-current:hover,
|
||||
.lang-current.open {
|
||||
border-color: var(--line);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The dropdown matches the page's existing popups (.picker-pop look).
|
||||
@@ -127,11 +146,13 @@ function select(tag) {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Flags render like in the analytics visitor cells. */
|
||||
/* em-sized so the chip matches the surrounding text/icon size in each
|
||||
context; the hairline border delineates white-flagged countries (not
|
||||
button chrome). */
|
||||
.flag {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
width: 1.5em;
|
||||
height: 1em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
// The public page's language selector: the editors' flag dropdown
|
||||
// (LangSelect) as the first item of the banner's corner container, fed
|
||||
// from the shared store (pagerite.js sets the page's hreflang alternates
|
||||
// and served language per navigation). It binds the same store.lang the
|
||||
// editor's dropdown binds, so both always show the same selection. A pick
|
||||
// also dispatches pagerite:set-session-lang — pagerite.js swaps the page
|
||||
// in place when the editor is closed (open, the editor reacts to the
|
||||
// store and re-renders it).
|
||||
import { computed } from 'vue'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { useStore } from './store'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
// The "(primary)" marker is admin-panel information; the public selector
|
||||
// lists plain languages.
|
||||
const options = computed(() =>
|
||||
store.langAlternates.map((a) => ({
|
||||
tag: a.tag,
|
||||
code: a.tag,
|
||||
name: langName(a.tag),
|
||||
flag: flagFor(a.tag),
|
||||
primary: false,
|
||||
})),
|
||||
)
|
||||
const primaryTag = computed(() => store.langAlternates.find((a) => a.primary)?.tag ?? '')
|
||||
// The explicit pick, else the served language (header-autodetected pages
|
||||
// may have neither), else the primary.
|
||||
const model = computed(() => store.lang || store.servedLang || primaryTag.value)
|
||||
|
||||
function go(tag) {
|
||||
store.lang = tag === primaryTag.value ? '' : tag
|
||||
dispatchEvent(new CustomEvent('pagerite:set-session-lang', { detail: { lang: tag } }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LangSelect :model-value="model" :options="options" @update:model-value="go" />
|
||||
</template>
|
||||
@@ -26,6 +26,7 @@
|
||||
// renders the version being edited, whichever language the page itself
|
||||
// was loaded in.
|
||||
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { usePopup } from './dropdown'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { Compartment, EditorState } from '@codemirror/state'
|
||||
import { keymap } from '@codemirror/view'
|
||||
@@ -621,8 +622,15 @@ const TABLE_MAX_ROWS = 6
|
||||
// Class pickers: popup listing the block class toggles (placement ↔︎,
|
||||
// text size AA), closed after applying. The block's current class of the
|
||||
// group is marked; choosing "normal" (or the current class) removes it.
|
||||
// All popups share the close behavior of ./dropdown (outside click /
|
||||
// Escape; never mouseleave).
|
||||
const classPicker = ref(null) // 'place' | 'size' | null
|
||||
const activeClasses = ref(new Set())
|
||||
const placeRoot = ref(null)
|
||||
const sizeRoot = ref(null)
|
||||
const tableRoot = ref(null)
|
||||
usePopup(classPicker, computed(() => (classPicker.value === 'place' ? placeRoot : sizeRoot).value))
|
||||
usePopup(tablePicker, tableRoot)
|
||||
|
||||
function openClassPicker(which) {
|
||||
classPicker.value = classPicker.value === which ? null : which
|
||||
@@ -1136,15 +1144,31 @@ onUnmounted(() => {
|
||||
<div class="format-bar">
|
||||
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code></></code></button>
|
||||
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗︎</button>
|
||||
<span class="picker" ref="tableRoot">
|
||||
<button
|
||||
type="button"
|
||||
title="table"
|
||||
:class="{ active: tablePicker }"
|
||||
@click="tablePicker = !tablePicker"
|
||||
>⊞</button>
|
||||
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
|
||||
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
|
||||
<button
|
||||
v-for="n in TABLE_MAX_COLS * TABLE_MAX_ROWS"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
:class="{ on: tableSize.cols >= (n - 1) % TABLE_MAX_COLS + 1 && tableSize.rows >= Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||||
@mouseenter="tableSize = { cols: (n - 1) % TABLE_MAX_COLS + 1, rows: Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||||
@click="insertTable(tableSize.cols, tableSize.rows)"
|
||||
/>
|
||||
</div>
|
||||
<div class="tp-size">{{ tableSize.cols || '–' }} × {{ tableSize.rows || '–' }}</div>
|
||||
</div>
|
||||
</span>
|
||||
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼︎</button>
|
||||
<button type="button" title="aside box (::: aside) — wraps the selection or the cursor's line; clicked inside one, removes it" @click="insertAside">◧</button>
|
||||
<span class="picker">
|
||||
<span class="picker" ref="placeRoot">
|
||||
<button
|
||||
type="button"
|
||||
title="block placement class"
|
||||
@@ -1166,7 +1190,7 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
|
||||
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
|
||||
<span class="picker">
|
||||
<span class="picker" ref="sizeRoot">
|
||||
<button
|
||||
type="button"
|
||||
title="text size class"
|
||||
@@ -1368,7 +1392,7 @@ onUnmounted(() => {
|
||||
.table-picker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 6.5rem;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Shared popup open-state behavior: while `open` (a ref, truthy = open)
|
||||
// is set, a pointerdown outside `root` (a template ref covering both the
|
||||
// toggle button and the popup) or Escape resets it to null. One logic for
|
||||
// every dropdown (LangSelect, the page editor's class/table pickers), so
|
||||
// they can't drift apart.
|
||||
import { onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
export function usePopup(open, root) {
|
||||
let off = null
|
||||
const stop = watch(open, (v) => {
|
||||
off?.()
|
||||
off = null
|
||||
if (!v) return
|
||||
const down = (ev) => { if (!root.value?.contains(ev.target)) open.value = null }
|
||||
const key = (ev) => { if (ev.key === 'Escape') open.value = null }
|
||||
addEventListener('pointerdown', down, true)
|
||||
addEventListener('keydown', key)
|
||||
off = () => {
|
||||
removeEventListener('pointerdown', down, true)
|
||||
removeEventListener('keydown', key)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { off?.(); stop() })
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
// The editor shell's shared language selection ('' = the primary language):
|
||||
// one state, v-modeled by the LangSelect of every tab that has one (page,
|
||||
// structure). While the panel is open it also drives the page preview —
|
||||
// EditorShell applies it as the fetch-time language override (swapdoc).
|
||||
import { ref } from 'vue'
|
||||
// backed by the app-wide store (./store), so the editor tabs' LangSelects
|
||||
// and the public corner selector bind the same value. Linked to the
|
||||
// whole-page language: while the panel is open it drives the page preview
|
||||
// (EditorShell applies it as the fetch-time language override, swapdoc).
|
||||
import { computed, ref } from 'vue'
|
||||
import { pinia, useStore } from './store'
|
||||
|
||||
export const editorLang = ref('')
|
||||
export const editorLang = computed({
|
||||
get: () => useStore(pinia).lang,
|
||||
set: (v) => { useStore(pinia).lang = v },
|
||||
})
|
||||
|
||||
// The CURRENT PAGE's primary language ('' = not yet learned): the shell's
|
||||
// settings fetch fills it with the site default; the page/structure tabs
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Public language-selector entry: imported on demand by pagerite.js on
|
||||
// pages advertising more than one language in their hreflang alternates.
|
||||
// Vue, Pinia and the flag SVG set live in this chunk only — untranslated
|
||||
// pages never pay for them. The selector's state lives in the shared
|
||||
// store (./store), not the DOM: the corner container is rebuilt freely
|
||||
// and ensureMounted re-mounts from the store.
|
||||
import { createApp } from 'vue'
|
||||
import LangSelector from './LangSelector.vue'
|
||||
import { pinia, useStore } from './store'
|
||||
|
||||
let app = null
|
||||
|
||||
function store() {
|
||||
return useStore(pinia)
|
||||
}
|
||||
|
||||
// The current page's languages (called on every navigation).
|
||||
export function setLanguages(alternates, current) {
|
||||
Object.assign(store(), {
|
||||
langAlternates: alternates,
|
||||
servedLang: current,
|
||||
langSelectorActive: true,
|
||||
})
|
||||
}
|
||||
|
||||
// The current page is single-language: the selector goes away.
|
||||
export function hide() {
|
||||
store().langSelectorActive = false
|
||||
app?.unmount()
|
||||
app = null
|
||||
}
|
||||
|
||||
// Mount the selector as the container's first item; re-mount when its
|
||||
// element went away with a container rebuild (a live app updates from the
|
||||
// store reactively).
|
||||
export function ensureMounted(host) {
|
||||
if (!store().langSelectorActive || !host) {
|
||||
app?.unmount()
|
||||
app = null
|
||||
return
|
||||
}
|
||||
if (app && host.contains(app._container)) return
|
||||
app?.unmount()
|
||||
const el = document.createElement('div')
|
||||
el.id = 'lang-selector'
|
||||
host.prepend(el)
|
||||
app = createApp(LangSelector)
|
||||
app.use(pinia)
|
||||
app.mount(el)
|
||||
}
|
||||
+89
-12
@@ -51,13 +51,18 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
url.searchParams.delete("lang");
|
||||
history.replaceState(history.state, "", url);
|
||||
}
|
||||
// The session language. While the editor panel is open, its language
|
||||
// selection overrides the normal preference (swapdoc.setLangOverride):
|
||||
// internal fetches and prefetches follow it until the panel closes and
|
||||
// the override clears (null restores the initial ?lang=, if any).
|
||||
// The session language: the user's explicit pick (initial ?lang=, public
|
||||
// selector, editor dropdown) is kept in chosenLang; while the editor is
|
||||
// open its selection overrides it (swapdoc.setLangOverride), and closing
|
||||
// falls back to chosenLang. JS state only — pretty URLs, no reloads.
|
||||
// window.__pageriteLang is the pin for swapdoc.loadPlain's fetches.
|
||||
let chosenLang = langParam;
|
||||
let sessionLang = langParam;
|
||||
window.__pageriteLang = sessionLang;
|
||||
addEventListener("pagerite:session-lang", (ev) => {
|
||||
sessionLang = ev.detail?.lang || langParam;
|
||||
if (ev.detail?.lang) chosenLang = ev.detail.lang;
|
||||
sessionLang = ev.detail?.lang || chosenLang;
|
||||
window.__pageriteLang = sessionLang;
|
||||
});
|
||||
// An internal URL as fetched: carries the session's ?lang= unless the
|
||||
// link already pins a language of its own. With no ?lang= on the initial
|
||||
@@ -167,6 +172,22 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
return a;
|
||||
}
|
||||
|
||||
// The banner top-right corner container: the language selector (first
|
||||
// item) plus the admin pens and auth links. renderAuthUi rebuilds it from
|
||||
// scratch; the selector's state lives in the shared store, not the DOM,
|
||||
// so the langselect bundle re-mounts it into the fresh container.
|
||||
function pensContainer() {
|
||||
let pens = document.querySelector(".editor-pens");
|
||||
if (!pens) {
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (!banner) return null;
|
||||
pens = document.createElement("div");
|
||||
pens.className = "editor-pens";
|
||||
banner.after(pens);
|
||||
}
|
||||
return pens;
|
||||
}
|
||||
|
||||
function removePens() {
|
||||
document.querySelectorAll(".editor-pens, #main article button.edit-link")
|
||||
.forEach((el) => el.remove());
|
||||
@@ -178,18 +199,15 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
// pens that may have been injected while the browser cache made us look
|
||||
// authenticated.
|
||||
removePens();
|
||||
if (!authReady) return;
|
||||
|
||||
if (authReady) {
|
||||
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
||||
// Paskia SSO is detected at all.
|
||||
const canEdit = isAdmin || !ssoAvailable;
|
||||
// The analytics page is a read-only dashboard: editing pens and the side
|
||||
// panel do not apply there. Login/logout links are still useful.
|
||||
const onAnalytics = currentPath === "/_a";
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (banner) {
|
||||
const pens = document.createElement("div");
|
||||
pens.className = "editor-pens";
|
||||
if (document.getElementById("page-banner")) {
|
||||
const pens = pensContainer();
|
||||
if (canEdit && !onAnalytics) {
|
||||
// Analytics viewer is now a normal page at /_a.
|
||||
const a = document.createElement("a");
|
||||
@@ -201,10 +219,14 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
pens.append(makePen("site"));
|
||||
}
|
||||
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
||||
banner.after(pens);
|
||||
if (!pens.firstElementChild) pens.remove();
|
||||
}
|
||||
if (canEdit && !onAnalytics) injectPagePen();
|
||||
}
|
||||
// Re-mount the selector into the fresh container (no-op until the
|
||||
// bundle has been loaded once).
|
||||
langselectMod?.ensureMounted(document.querySelector(".editor-pens"));
|
||||
}
|
||||
|
||||
async function setupAuth() {
|
||||
authReady = false;
|
||||
@@ -407,6 +429,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
// copy pinned to a language (?lang=) caches under its own key, where
|
||||
// navigation with the same session language finds it.
|
||||
pageCache.set(rawKey(ev.detail.url), ev.detail.html);
|
||||
// Editor-driven swaps don't go through load(): re-evaluate the
|
||||
// language selector from the fresh copy too.
|
||||
mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html"));
|
||||
});
|
||||
|
||||
// Editors mutate site-wide state (theme, structure, headings, banners),
|
||||
@@ -742,6 +767,56 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public language selector ------------------------------------------
|
||||
// Pages translated into more than one language advertise it via hreflang
|
||||
// alternates (x-default + one link per language). Those pages get the
|
||||
// editors' flag dropdown as the first item of the corner container; its
|
||||
// bundle (Vue + the flag SVG set) loads on demand. Re-evaluated from the
|
||||
// fresh document on every swap (the head's own alternates stay stale).
|
||||
let langselectMod = null;
|
||||
async function mountLangselect(doc) {
|
||||
const links = [...doc.head.querySelectorAll('link[rel="alternate"][hreflang]')];
|
||||
const dflt = links.find((l) => l.hreflang === "x-default");
|
||||
const langs = links.filter((l) => l.hreflang && l.hreflang !== "x-default");
|
||||
if (!dflt || langs.length <= 1) return langselectMod?.hide();
|
||||
try {
|
||||
langselectMod ??= await import(/* @vite-ignore */ assets["pagerite:langselect-src"]);
|
||||
for (const css of (assets["pagerite:langselect-css"] || "").split(",")) {
|
||||
if (css && !document.querySelector(`link[href="${css}"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = css;
|
||||
link.dataset.pagerite = "langselect-css";
|
||||
document.head.append(link);
|
||||
}
|
||||
}
|
||||
langselectMod.setLanguages(
|
||||
// The original's alternate is the plain URL — x-default's href —
|
||||
// which also marks it as the primary option.
|
||||
langs.map((l) => ({ tag: l.hreflang, href: l.href, primary: l.href === dflt.href })),
|
||||
doc.documentElement.lang,
|
||||
);
|
||||
langselectMod.ensureMounted(pensContainer());
|
||||
} catch (e) {
|
||||
console.error("language selector mount failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// The selector's pick (LangSelector dispatches this): make it the
|
||||
// session language and swap the page in place. With the editor open the
|
||||
// pick already landed in the shared store — the editor's watch re-renders
|
||||
// the page itself, so there is nothing to do here.
|
||||
addEventListener("pagerite:set-session-lang", async (ev) => {
|
||||
const tag = ev.detail?.lang;
|
||||
if (!tag || tag === sessionLang) return;
|
||||
if (document.body.classList.contains("editing")) return;
|
||||
chosenLang = sessionLang = tag;
|
||||
window.__pageriteLang = tag;
|
||||
const y = scrollY; // a language switch is not a navigation: keep scroll
|
||||
await load(currentPath, false);
|
||||
scrollTo(0, y);
|
||||
});
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
async function load(url, push = true, back = false) {
|
||||
// Navigating with the editor open closes it; unsaved edits are lost
|
||||
@@ -843,6 +918,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
mountAnalytics(doc);
|
||||
mountLangselect(doc);
|
||||
};
|
||||
// Rotating cube page transition (styles injected as #pagerite-transition
|
||||
// from the selected design's transition.css, e.g. themes/cube/);
|
||||
@@ -1076,4 +1152,5 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
setupAuth();
|
||||
applyEffects();
|
||||
mountAnalytics(document);
|
||||
mountLangselect(document);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// The app's shared Pinia store — cross-bundle UI state lives here. Every
|
||||
// entry chunk imports its own copy of this module, so the Pinia instance
|
||||
// is parked on window (Vue itself is a shared chunk, so reactivity works
|
||||
// across the copies). Pass `pinia` explicitly when calling useStore
|
||||
// outside a component (module code, no active instance).
|
||||
import { createPinia, defineStore } from 'pinia'
|
||||
|
||||
export const pinia = (window.__pageritePinia ??= createPinia())
|
||||
|
||||
export const useStore = defineStore('pagerite', {
|
||||
state: () => ({
|
||||
// The ONE language selection, v-modeled by both dropdowns (editor
|
||||
// tabs, public corner selector): '' = no explicit pick (the page's
|
||||
// primary / autodetect), else a concrete tag. A pick from either
|
||||
// dropdown is visible to everyone immediately.
|
||||
lang: '',
|
||||
// The language the current page was actually served in (set by
|
||||
// pagerite.js per navigation) — the selector's highlight fallback
|
||||
// when there is no explicit pick.
|
||||
servedLang: '',
|
||||
// The public selector's page data: hreflang alternates
|
||||
// ([{tag, href, primary}]) and whether to show at all.
|
||||
langAlternates: [],
|
||||
langSelectorActive: false,
|
||||
}),
|
||||
})
|
||||
+12
-9
@@ -12,12 +12,13 @@ export function dropPageCache() {
|
||||
}
|
||||
|
||||
// The editor's language override (set by EditorShell): while the panel is
|
||||
// open, its language selection wins over the normal preferences (?lang= /
|
||||
// Accept-Language) — every in-place re-render asks for that language
|
||||
// explicitly, and pagerite.js applies it to its own fetches and prefetches
|
||||
// (pagerite:session-lang). The primary selection pins by its code:
|
||||
// ?lang=<primary> selects the original explicitly (i18n.select_language).
|
||||
let overrideLang = null // the ?lang= value in force, null = normal prefs
|
||||
// open, its language selection wins over the normal preferences — every
|
||||
// in-place re-render asks for that language explicitly, and pagerite.js
|
||||
// applies it to its own fetches and prefetches (pagerite:session-lang).
|
||||
// The primary selection pins by its code: ?lang=<primary> selects the
|
||||
// original explicitly (i18n.select_language). Panel closed, the session's
|
||||
// chosen language (window.__pageriteLang) takes over — the pick stays.
|
||||
let overrideLang = null // the ?lang= value in force, null = the session's
|
||||
|
||||
export function setLangOverride(queryLang) {
|
||||
overrideLang = queryLang || null
|
||||
@@ -129,14 +130,16 @@ function swapRegions(doc) {
|
||||
// Fetch /p, swap its regions into the live page and replaceState to it.
|
||||
// Returns the final URL (after redirects), or null when the fetch did not
|
||||
// yield a page. Category and missing URLs render a placeholder 404 page —
|
||||
// fine to swap in (new pages are created by editing them). While the
|
||||
// editor's language override is set the fetch pins that language.
|
||||
// fine to swap in (new pages are created by editing them). The fetch pins
|
||||
// the editor's language override, or — panel closed — the session's chosen
|
||||
// language (window.__pageriteLang).
|
||||
export async function loadPlain(p) {
|
||||
let doc
|
||||
let finalUrl = `/${p}`
|
||||
let html
|
||||
try {
|
||||
const res = await fetch(overrideLang ? `${finalUrl}?lang=${overrideLang}` : finalUrl)
|
||||
const pin = overrideLang || window.__pageriteLang
|
||||
const res = await fetch(pin ? `${finalUrl}?lang=${pin}` : finalUrl)
|
||||
const type = res.headers.get('content-type') || ''
|
||||
if (!type.includes('text/html')) return null
|
||||
if (res.redirected) finalUrl = res.url
|
||||
|
||||
@@ -38,7 +38,7 @@ export default defineConfig({
|
||||
chunkSizeWarningLimit: 1200,
|
||||
// Mirror the URL space in the build output: hashed files land under
|
||||
// frontend-build/_assets/ and the Frontend serves the build directory
|
||||
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
|
||||
// at the site root.
|
||||
manifest: true,
|
||||
assetsDir: '_assets',
|
||||
rollupOptions: {
|
||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
||||
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
||||
analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)),
|
||||
langselect: fileURLToPath(new URL('./src/langselect-main.js', import.meta.url)),
|
||||
// Only the base CSS is built; theme/banner-design stylesheets live
|
||||
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
||||
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
||||
|
||||
+35
-6
@@ -385,10 +385,14 @@ def _layout(
|
||||
# carries the on-demand URLs in one JSON script instead.
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
editor_scripts, editor_css = _editor_assets()
|
||||
langselect_scripts, langselect_css = _langselect_assets()
|
||||
config = {
|
||||
"pagerite:editor-src": editor_scripts[-1],
|
||||
"pagerite:analytics-src": _analytics_assets()[0][0],
|
||||
"pagerite:langselect-src": langselect_scripts[-1],
|
||||
}
|
||||
if langselect_css:
|
||||
config["pagerite:langselect-css"] = ",".join(langselect_css)
|
||||
if editor_css:
|
||||
config["pagerite:editor-css"] = editor_css
|
||||
if vite_url:
|
||||
@@ -1050,10 +1054,10 @@ def render_page(
|
||||
# the actually served language — the plain URL for the original (for
|
||||
# SEO the non-query URL means the article's language), ?lang= for a
|
||||
# translation — regardless of how the language was arrived at (query
|
||||
# or header). The alternates are site-wide, the same set on every
|
||||
# page: the configured translate_langs (the translator works to fill
|
||||
# them all in), x-default first (the plain, autodetecting URL), then
|
||||
# every language explicitly, the page's own primary included.
|
||||
# or header). The alternates list the languages the page is actually
|
||||
# available in: x-default first (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.
|
||||
canonical = ""
|
||||
alternates = []
|
||||
if base_url:
|
||||
@@ -1061,8 +1065,8 @@ def render_page(
|
||||
canonical = url if lang == original else f"{url}?lang={lang}"
|
||||
if data.translate_langs:
|
||||
alternates = [("x-default", url)] + [
|
||||
(tag, f"{url}?lang={tag}")
|
||||
for tag in sorted({original, *data.translate_langs})
|
||||
(tag, url if tag == original else f"{url}?lang={tag}")
|
||||
for tag in sorted({original, *node.langs})
|
||||
]
|
||||
return str(
|
||||
_layout(
|
||||
@@ -1228,6 +1232,31 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
|
||||
return _asset_cache["analytics"]
|
||||
|
||||
|
||||
def _langselect_assets() -> tuple[list[str], list[str]]:
|
||||
"""Script and stylesheet URLs for the on-demand public language selector."""
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
if vite_url:
|
||||
return [f"{vite_url}/src/langselect-main.js"], []
|
||||
if "langselect" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
# import() loads no CSS automatically: collect the stylesheets of
|
||||
# the entry and its imported chunks (LangSelect's ride on the
|
||||
# shared langs chunk).
|
||||
scripts, stylesheets, seen = [], [], set()
|
||||
queue = ["src/langselect-main.js"]
|
||||
for key in queue: # grows with imported chunks
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entry = manifest[key]
|
||||
if entry.get("isEntry"):
|
||||
scripts.append(f"/{entry['file']}")
|
||||
stylesheets += [f"/{css}" for css in entry.get("css", [])]
|
||||
queue += entry.get("imports", [])
|
||||
_asset_cache["langselect"] = scripts, stylesheets
|
||||
return _asset_cache["langselect"]
|
||||
|
||||
|
||||
def render_analytics(
|
||||
menu: dict[str, Node],
|
||||
brand: str = SITE_NAME,
|
||||
|
||||
Reference in New Issue
Block a user