Per-node primary language; home-country flags; grouped lang grid
- Node.language sets an article's primary language, inherited down the hierarchy, configured per row in the structure tab; English is now a regular language in the lang tab, still the root fallback - Flags map a base tag to the language's home country (en → GB, pt → PT); explicit variant tags resolve their own region - Lang tab flag grid: geographic group rows, shading-only selection
This commit is contained in:
@@ -8,7 +8,7 @@ import BannerEditor from './BannerEditor.vue'
|
||||
import SiteEditor from './SiteEditor.vue'
|
||||
import StructureEditor from './StructureEditor.vue'
|
||||
import LocalizationEditor from './LocalizationEditor.vue'
|
||||
import { editorLang } from './editorLang'
|
||||
import { editorLang, pagePrimary } from './editorLang'
|
||||
import { loadPlain, setLangOverride } from './swapdoc'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -24,11 +24,14 @@ const activeMode = ref(props.initialMode)
|
||||
// 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.
|
||||
const primaryLang = ref('en')
|
||||
// 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
|
||||
setLangOverride(editorLang.value || primaryLang.value)
|
||||
// '' pagePrimary = not yet learned: pin 'en', the server's final fallback
|
||||
// (i18n.ORIGINAL_LANGUAGE).
|
||||
setLangOverride(editorLang.value || pagePrimary.value || 'en')
|
||||
loadPlain(currentPath.value)
|
||||
}
|
||||
function unpinPreviewLang() {
|
||||
@@ -38,6 +41,9 @@ function unpinPreviewLang() {
|
||||
loadPlain(currentPath.value)
|
||||
}
|
||||
watch(editorLang, () => { if (pinned) pinPreviewLang() })
|
||||
// The page's primary may be (re)learned while pinned on it (doc accept,
|
||||
// tree refresh, a language change on the row) — re-pin with the new code.
|
||||
watch(pagePrimary, () => { if (pinned && !editorLang.value) pinPreviewLang() })
|
||||
|
||||
// Tab order: site-wide settings first (site, structure, localization), then
|
||||
// — after a visual break — the per-page editors (article, banner).
|
||||
@@ -85,12 +91,14 @@ onMounted(() => {
|
||||
addEventListener('pagerite:switch-editor', onSwitchEvent)
|
||||
addEventListener('pagerite:editor-shown', pinPreviewLang)
|
||||
addEventListener('pagerite:editor-hidden', unpinPreviewLang)
|
||||
// The shell mounts visible (openEditor), so pin immediately. The primary
|
||||
// language's code (for pinning it explicitly) comes from the settings.
|
||||
// The shell mounts visible (openEditor), so pin 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()
|
||||
fetch('/_api/settings').then((r) => r.json()).then((s) => {
|
||||
primaryLang.value = s.primary_lang || 'en'
|
||||
}).catch(() => { /* keep the default */ })
|
||||
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
|
||||
}).catch(() => { /* keep the fallback */ })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { computed, ref } from 'vue'
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, required: true }, // [{tag, code, name, flag, primary}]
|
||||
title: { type: String, default: '' }, // toggle-button tooltip override
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
@@ -28,9 +29,9 @@ function select(tag) {
|
||||
type="button"
|
||||
class="lang-current"
|
||||
:class="{ open }"
|
||||
:title="current
|
||||
:title="title || (current
|
||||
? `language: ${current.name}${current.primary ? ' (primary)' : ''}`
|
||||
: ''"
|
||||
: '')"
|
||||
@click="open = !open"
|
||||
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
|
||||
<span v-if="open" class="lang-pop" @mouseleave="open = false">
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup>
|
||||
// Lang tab: the site-wide translation target languages (translate_langs)
|
||||
// and the translator service WebSocket URL(s) (translate_keys). The primary
|
||||
// language is configured per site hierarchy, not here. Flag clicks toggle
|
||||
// and save immediately; the settings round-trip re-reads the payload, so
|
||||
// this tab only ever changes translate_langs. The settings write's
|
||||
// invalidation hook kicks the translation dispatcher. The refresh button
|
||||
// drops all machine translations (user patches are kept), making the
|
||||
// dispatcher re-translate everything.
|
||||
// and the translator service WebSocket URL(s) (translate_keys). ALL
|
||||
// languages are listed, English included — a page whose primary language
|
||||
// (Node.language, configured per row in the structure tab, inherited down
|
||||
// the hierarchy) differs can be translated INTO any other. Flag clicks
|
||||
// toggle and save immediately; the settings round-trip re-reads the
|
||||
// payload, so this tab only ever changes translate_langs. The settings
|
||||
// write's invalidation hook kicks the translation dispatcher. The refresh
|
||||
// button drops all machine translations (user patches are kept), making
|
||||
// the dispatcher re-translate everything.
|
||||
import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { TRANSLATABLE, flagFor, langName } from './langs'
|
||||
import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs'
|
||||
import { dropPageCache } from './swapdoc'
|
||||
|
||||
defineProps({ pagePath: { type: String, default: '' } })
|
||||
@@ -16,18 +18,22 @@ defineProps({ pagePath: { type: String, default: '' } })
|
||||
defineEmits(['close', 'pathChange'])
|
||||
|
||||
const saveError = ref('')
|
||||
const primaryLang = ref('en')
|
||||
const selected = ref(new Set())
|
||||
const keyUrls = ref([])
|
||||
|
||||
// The toggleable targets: every translatable language but the primary,
|
||||
// alphabetized by display name.
|
||||
const options = computed(() =>
|
||||
Object.keys(TRANSLATABLE)
|
||||
.filter((code) => code !== primaryLang.value)
|
||||
.map((code) => ({ code, name: langName(code), flag: flagFor(code) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
// The toggleable targets: every translatable language, laid out in
|
||||
// geographic/cultural groups (one row each) rather than alphabetized —
|
||||
// related languages sit together (a node's own primary is excluded per
|
||||
// article, server-side). Any code missing from LANG_GROUPS trails as an
|
||||
// extra row.
|
||||
const groups = computed(() => {
|
||||
const tile = (code) => ({ code, name: langName(code), flag: flagFor(code) })
|
||||
const rows = LANG_GROUPS.map((g) => g.filter((c) => c in TRANSLATABLE).map(tile))
|
||||
const covered = new Set(LANG_GROUPS.flat())
|
||||
const rest = Object.keys(TRANSLATABLE).filter((c) => !covered.has(c)).map(tile)
|
||||
if (rest.length) rows.push(rest)
|
||||
return rows.filter((r) => r.length)
|
||||
})
|
||||
|
||||
function updateWindowTitle() {
|
||||
document.title = 'lang 🖊️'
|
||||
@@ -45,7 +51,6 @@ onMounted(async () => {
|
||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||
try {
|
||||
const s = await (await fetch('/_api/settings')).json()
|
||||
primaryLang.value = s.primary_lang || 'en'
|
||||
selected.value = new Set(s.translate_langs || [])
|
||||
const wsBase = location.origin.replace(/^http/, 'ws')
|
||||
keyUrls.value = Object.entries(s.translate_keys || {})
|
||||
@@ -106,17 +111,19 @@ async function refresh() {
|
||||
<span class="field-label">languages</span>
|
||||
</div>
|
||||
<div class="flags">
|
||||
<button
|
||||
v-for="o in options"
|
||||
:key="o.code"
|
||||
type="button"
|
||||
class="flag-tile"
|
||||
:class="{ selected: selected.has(o.code) }"
|
||||
:title="`${o.name} (${o.code})`"
|
||||
@click="toggle(o.code)"
|
||||
>
|
||||
<span class="flag" v-html="o.flag" />
|
||||
</button>
|
||||
<div v-for="(row, ri) in groups" :key="ri" class="flag-row">
|
||||
<button
|
||||
v-for="o in row"
|
||||
:key="o.code"
|
||||
type="button"
|
||||
class="flag-tile"
|
||||
:class="{ selected: selected.has(o.code) }"
|
||||
:title="`${o.name} (${o.code})`"
|
||||
@click="toggle(o.code)"
|
||||
>
|
||||
<span class="flag" v-html="o.flag" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -179,13 +186,20 @@ async function refresh() {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Flag grid: deselected flags sit dimmed and grayed; a click brings one to
|
||||
full color with an accent ring (selected = a translation target). */
|
||||
/* Flag grid: one geographic group per row. Deselected flags sit dimmed and
|
||||
grayed; a click brings one to full color (selected = a translation
|
||||
target) — the shading alone carries the state, no outline. */
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.flag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.flag-tile {
|
||||
@@ -207,8 +221,6 @@ async function refresh() {
|
||||
.flag-tile.selected {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Same flag chips as the PageEditor language picker / analytics cells. */
|
||||
|
||||
@@ -33,7 +33,7 @@ import { indentWithTab } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { editorLang } from './editorLang'
|
||||
import { editorLang, pagePrimary } from './editorLang'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import ConnNote from './ConnNote.vue'
|
||||
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||
@@ -752,6 +752,7 @@ function onMessage(ev) {
|
||||
title.value = msg.title
|
||||
published.value = msg.published
|
||||
primaryLang.value = msg.primary_lang || 'en'
|
||||
pagePrimary.value = primaryLang.value // the page's own — the shell pins the preview by it
|
||||
pageLangs.value = msg.langs || []
|
||||
siteLangs.value = msg.translate_langs || []
|
||||
lang.value = msg.lang || ''
|
||||
|
||||
@@ -20,7 +20,7 @@ import StructureTree from './StructureTree.vue'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import { slugify } from './slugify'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { editorLang } from './editorLang'
|
||||
import { editorLang, pagePrimary } from './editorLang'
|
||||
import { dropPageCache, loadPlain } from './swapdoc'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -59,6 +59,25 @@ const currentLang = computed(
|
||||
// titles in it (and EditorShell swaps the page preview into it).
|
||||
watch(lang, () => refreshPages())
|
||||
|
||||
// Per-row primary language (Node.language, '' = inherit): the row's
|
||||
// dropdown lists "inherit" first (naming what it resolves to), then every
|
||||
// site language. Setting it on a section covers its whole subtree.
|
||||
const rowLangChoices = computed(() =>
|
||||
[primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
|
||||
.map((code) => ({ tag: code, code, name: langName(code), flag: flagFor(code), primary: false })),
|
||||
)
|
||||
function rowLangOptions(el) {
|
||||
const resolved = el.primary || primaryLang.value
|
||||
return [
|
||||
{ tag: '', code: '_inherit', name: `inherit (${langName(resolved)})`, flag: flagFor(resolved), primary: false },
|
||||
...rowLangChoices.value,
|
||||
]
|
||||
}
|
||||
|
||||
async function setLanguage(node, tag) {
|
||||
await postStructure({ path: node.path, language: tag })
|
||||
}
|
||||
|
||||
function normPath(p) {
|
||||
return p.trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
@@ -188,10 +207,22 @@ async function commitPending() {
|
||||
}
|
||||
|
||||
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
|
||||
function findNode(nodes, p) {
|
||||
for (const n of nodes) {
|
||||
if (n.path === p) return n
|
||||
const found = findNode(n.children, p)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function refreshPages() {
|
||||
try {
|
||||
const q = lang.value ? `?lang=${lang.value}` : ''
|
||||
tree.value = await (await fetch(`/_api/pages${q}`)).json()
|
||||
// The tree carries each node's resolved primary language: publish the
|
||||
// current page's (the shell pins the preview by it on '' selection).
|
||||
pagePrimary.value = findNode(tree.value, path.value)?.primary || 'en'
|
||||
} catch { /* list stays stale; not fatal */ }
|
||||
}
|
||||
|
||||
@@ -305,6 +336,8 @@ provide('structureHandlers', {
|
||||
commitPending,
|
||||
discardPending,
|
||||
newPage,
|
||||
langOptions: rowLangOptions,
|
||||
setLanguage,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
||||
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
|
||||
// {slug, path, title, translated, order, published, has_content, children}.
|
||||
// {slug, path, title, translated, order, published, has_content, language,
|
||||
// primary, children}. The row's flag (LangSelect) sets the node's primary
|
||||
// language (language; '' = inherit — dimmed, showing the resolved flag);
|
||||
// the setting covers the whole subtree.
|
||||
// With a `lang` prop (StructureEditor's language strip) the titles shown
|
||||
// are that language's; `translated` marks rows with an actual translation
|
||||
// (untranslated rows show the original title, dimmed).
|
||||
@@ -27,6 +30,7 @@
|
||||
import { inject } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { slugify } from './slugify'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
|
||||
defineOptions({ name: 'StructureTree' })
|
||||
const props = defineProps({
|
||||
@@ -150,6 +154,17 @@ function onEnd() {
|
||||
@change="handlers.commitSlug(element, $event)"
|
||||
/>
|
||||
<span class="acts">
|
||||
<span
|
||||
class="row-lang"
|
||||
:class="{ inherited: !element.language }"
|
||||
><LangSelect
|
||||
:model-value="element.language"
|
||||
:options="handlers.langOptions(element)"
|
||||
:title="element.language
|
||||
? `primary language: set on this page (subtree inherits)`
|
||||
: `primary language: inherited — set it here (subtree inherits)`"
|
||||
@update:model-value="handlers.setLanguage(element, $event)"
|
||||
/></span>
|
||||
<span v-if="!element.published" class="draft">draft</span>
|
||||
<button
|
||||
v-if="element.has_content || !element.children.length"
|
||||
@@ -234,7 +249,7 @@ body.tree-dragging .treelist {
|
||||
level, not across levels). */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem 5rem;
|
||||
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem auto;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
/* Vertical spacing widens the drop zones: the exposed top strip is the
|
||||
@@ -303,6 +318,20 @@ body.tree-dragging .treelist {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
/* Row language selector (LangSelect): the effective primary language's
|
||||
flag; dimmed while the setting is inherited rather than set on the row. */
|
||||
.row-lang {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.row-lang.inherited :deep(.lang-current) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.row-lang.inherited:hover :deep(.lang-current) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.draft {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -5,3 +5,11 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const editorLang = ref('')
|
||||
|
||||
// The CURRENT PAGE's primary language ('' = not yet learned): the shell's
|
||||
// settings fetch fills it with the site default; the page/structure tabs
|
||||
// then refine it per page (doc accept / tree rows — strictly better
|
||||
// sources, so they overwrite freely while the settings fetch only fills
|
||||
// the unknown). EditorShell pins the preview by it when the selection is
|
||||
// '' (the primary).
|
||||
export const pagePrimary = ref('')
|
||||
|
||||
+28
-8
@@ -4,15 +4,30 @@
|
||||
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
||||
|
||||
// The Seed-X reference translator's languages (scripts/translator.py) — the
|
||||
// translation-target ceiling — each mapped to the country whose flag stands
|
||||
// for it (CLDR likely-subtag regions: the country with the most speakers).
|
||||
// translation-target ceiling — each mapped to the language's home country
|
||||
// flag (England for English, Portugal for Portuguese — not the most
|
||||
// populous variant). Internal tags are the bare 2-letter base subtags; the
|
||||
// translator decides the variant. A variant tag (en-US, pt-BR) is still a
|
||||
// valid explicit selection for a future translator that distinguishes them
|
||||
// — flagFor shows its own region then.
|
||||
export const TRANSLATABLE = {
|
||||
ar: 'EG', cs: 'CZ', da: 'DK', de: 'DE', el: 'GR', en: 'US', es: 'ES',
|
||||
ar: 'EG', cs: 'CZ', da: 'DK', de: 'DE', el: 'GR', en: 'GB', es: 'ES',
|
||||
fa: 'IR', fi: 'FI', fr: 'FR', hu: 'HU', id: 'ID', it: 'IT', ja: 'JP',
|
||||
ko: 'KR', ms: 'MY', nl: 'NL', no: 'NO', pl: 'PL', pt: 'BR', ro: 'RO',
|
||||
ko: 'KR', ms: 'MY', nl: 'NL', no: 'NO', pl: 'PL', pt: 'PT', ro: 'RO',
|
||||
ru: 'RU', sv: 'SE', th: 'TH', tr: 'TR', uk: 'UA', vi: 'VN', zh: 'CN',
|
||||
}
|
||||
|
||||
// The languages in geographic/cultural groups (the lang tab's flag grid
|
||||
// lays them out one group per row, in this order): English with the
|
||||
// Nordics, then Western/Central and Eastern Europe, Southern Europe with
|
||||
// the Middle East, and Asia.
|
||||
export const LANG_GROUPS = [
|
||||
['en', 'nl', 'da', 'no', 'sv', 'fi', 'ru'],
|
||||
['fr', 'de', 'pl', 'cs', 'hu', 'ro', 'uk'],
|
||||
['es', 'pt', 'it', 'el', 'tr', 'ar', 'fa'],
|
||||
['zh', 'ja', 'ko', 'vi', 'th', 'id', 'ms'],
|
||||
]
|
||||
|
||||
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
|
||||
|
||||
// English display name for a language tag ("fi" -> "Finnish").
|
||||
@@ -24,11 +39,16 @@ export function langName(tag) {
|
||||
}
|
||||
}
|
||||
|
||||
// Flag SVG string for a language tag: the explicit mapping first, then the
|
||||
// tag's most likely region (for languages outside the translatable list).
|
||||
// Flag SVG string for a language tag: an explicit region variant (en-US)
|
||||
// gets its own region's flag; a bare base tag maps to the language's home
|
||||
// country (en → GB, pt → PT); languages outside the list fall back to the
|
||||
// tag's most likely region.
|
||||
export function flagFor(tag) {
|
||||
const country = TRANSLATABLE[tag]
|
||||
if (country) return flagSvgs[country] || ''
|
||||
tag = tag || ''
|
||||
if (!tag.includes('-')) {
|
||||
const country = TRANSLATABLE[tag.split('-')[0].toLowerCase()]
|
||||
if (country) return flagSvgs[country] || ''
|
||||
}
|
||||
try {
|
||||
return flagSvgs[new Intl.Locale(tag).maximize().region] || ''
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user