From acc912ce28a069623ef10a5577a3e080bb8e8b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 06:28:42 +0000 Subject: [PATCH 01/43] Add per-dataset CV localization with language variants Users can now save localized versions of each CV (e.g. English + German) that share the same structure but have independent content. Language variants are linked via a language_group and share the same URL slug. Key changes: - Database: language + language_group columns on saved_datasets with composite unique constraints (slug,language) and (name,language) - Structural propagation: section order, visibility, and custom section layout sync automatically across language siblings on save - Public: language switcher at /v/{slug}/{lang}, /?lang=xx for root - Admin: functional language switcher in dataset banner, language dropdown in Save As modal, language badges in Open modal - Group-wide default/public toggles apply to all language variants - i18n keys added to all 8 locale files Bumps version to 1.28.0. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- CHANGELOG.md | 13 ++ package-lock.json | 4 +- package.json | 2 +- public-readonly/index.html | 76 ++++++- public/index.html | 15 ++ public/shared/admin.js | 254 +++++++++++++++++++-- public/shared/i18n/de.json | 12 + public/shared/i18n/en.json | 14 ++ public/shared/i18n/es.json | 13 ++ public/shared/i18n/fr.json | 12 + public/shared/i18n/it.json | 13 ++ public/shared/i18n/nl.json | 13 ++ public/shared/i18n/pt.json | 13 ++ public/shared/i18n/zh.json | 13 ++ public/shared/styles.css | 158 +++++++++++++ src/server.js | 456 ++++++++++++++++++++++++++++++------- tests/backend.test.js | 211 +++++++++++++++++ version.json | 2 +- 18 files changed, 1191 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5297f50c..fad773dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to CV Manager will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/). +## [1.28.0] - 2026-04-16 + +### Added +- Per-dataset CV localization: save language-specific variants of each CV (e.g. English + German) that share the same structure but have independent content. Language variants are linked via a `language_group` and share the same URL slug. +- Public language switcher: visitors can switch between available language variants at `/v/{slug}/{lang}`. A translate button appears on the public site when multiple languages are available (hidden during printing). +- Admin language switcher: when editing a dataset with language siblings, a language switcher in the active-dataset banner lets you quickly switch between variants. New languages are added via the Save As modal. +- Structural propagation: changes to section order, visibility, custom section layout, and item count automatically sync across all language siblings. Content (text, titles, descriptions) stays independent per language. +- Language-specific URL routing: `/v/{slug}/{lang}` for versioned datasets, `/?lang=xx` for the root URL. Single-language datasets continue to work at `/v/{slug}`. +- Group-wide default and public toggles: setting a dataset as default or toggling public visibility applies to all language variants in the group. +- New i18n keys for language management UI in all 8 locale files (en, de, fr, nl, es, it, pt, zh). +- Database migration adds `language` and `language_group` columns to `saved_datasets` with composite unique constraints on `(slug, language)` and `(name, language)`. +- Backend tests for language variant creation, sibling management, structural propagation, and group-wide operations. + ## [1.27.0] - 2026-04-15 ### Changed diff --git a/package-lock.json b/package-lock.json index 7abcdcc5..932f7f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.27.0", + "version": "1.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.27.0", + "version": "1.28.0", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index 3099b2a3..5f2de900 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.27.0", + "version": "1.28.0", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/public-readonly/index.html b/public-readonly/index.html index 3082755d..429001a8 100644 --- a/public-readonly/index.html +++ b/public-readonly/index.html @@ -19,7 +19,16 @@ - + + + + `; @@ -2764,12 +2915,39 @@ function saveAsOnListClick(e) { if (!btn) return; const action = btn.getAttribute('data-action'); const input = document.getElementById('saveAsNameInput'); + const langSelect = document.getElementById('saveAsLangSelect'); + const langGroupInput = document.getElementById('saveAsLangGroup'); if (!input) return; if (action === 'fill-name') { input.value = btn.getAttribute('data-name') || ''; + if (langSelect && btn.getAttribute('data-lang')) { + langSelect.value = btn.getAttribute('data-lang'); + } + if (langGroupInput) langGroupInput.value = ''; } else if (action === 'new-version') { const base = btn.getAttribute('data-base') || ''; input.value = suggestNextVersion(base, saveAsDatasetsCache); + if (langGroupInput) langGroupInput.value = ''; + } else if (action === 'add-language') { + const name = btn.getAttribute('data-name') || ''; + const group = btn.getAttribute('data-group') || ''; + input.value = name; + if (langGroupInput) langGroupInput.value = group; + // Set language to first available language not in this group + if (langSelect && group) { + const existingLangs = new Set(); + saveAsDatasetsCache.forEach(ds => { + if (ds.language_group === group) existingLangs.add(ds.language || 'en'); + }); + const available = (typeof I18n !== 'undefined' ? I18n.languages : []).map(l => l.code).filter(c => !existingLangs.has(c)); + if (available.length > 0) { + // Filter select to only show available languages + langSelect.innerHTML = available.map(code => { + const langObj = I18n.languages.find(l => l.code === code); + return ``; + }).join(''); + } + } } updateSaveAsSubmitState(); input.focus(); @@ -2778,18 +2956,26 @@ function saveAsOnListClick(e) { function updateSaveAsSubmitState() { const input = document.getElementById('saveAsNameInput'); const btn = document.getElementById('saveAsSubmitBtn'); + const langSelect = document.getElementById('saveAsLangSelect'); + const langGroupInput = document.getElementById('saveAsLangGroup'); if (!input || !btn) return; const name = (input.value || '').trim(); - const match = saveAsDatasetsCache.find(d => d.name === name); + const language = langSelect ? langSelect.value : 'en'; + const languageGroup = langGroupInput ? langGroupInput.value : ''; + const match = saveAsDatasetsCache.find(d => d.name === name && d.language === language); btn.classList.remove('btn-primary', 'btn-warning'); if (!name) { btn.disabled = true; btn.textContent = t('datasets.save_new_empty'); btn.classList.add('btn-primary'); - } else if (match) { + } else if (match && !languageGroup) { btn.disabled = false; btn.textContent = t('datasets.overwrite', { name }); btn.classList.add('btn-warning'); + } else if (languageGroup) { + btn.disabled = false; + btn.textContent = t('datasets.save_new_lang', { name, lang: language.toUpperCase() }); + btn.classList.add('btn-primary'); } else { btn.disabled = false; btn.textContent = t('datasets.save_new', { name }); @@ -2797,7 +2983,7 @@ function updateSaveAsSubmitState() { } // Highlight the matching row (if any) so users see which CV will be overwritten document.querySelectorAll('#saveAsExistingList [data-action="fill-name"]').forEach(el => { - if (name && el.getAttribute('data-name') === name) { + if (name && el.getAttribute('data-name') === name && el.getAttribute('data-lang') === language) { el.classList.add('save-as-row-selected'); } else { el.classList.remove('save-as-row-selected'); @@ -2810,17 +2996,27 @@ async function submitSaveAs() { if (!input) return; const name = (input.value || '').trim(); if (!name) return; - const match = saveAsDatasetsCache.find(d => d.name === name); + const langSelect = document.getElementById('saveAsLangSelect'); + const langGroupInput = document.getElementById('saveAsLangGroup'); + const language = langSelect ? langSelect.value : 'en'; + const languageGroup = langGroupInput ? langGroupInput.value : ''; + + const match = saveAsDatasetsCache.find(d => d.name === name && d.language === language); if (match && !confirm(t('confirm.overwrite_dataset', { name }))) return; try { - const result = await api('/api/datasets', { method: 'POST', body: { name } }); + const body = { name, language }; + if (languageGroup) body.language_group = languageGroup; + const result = await api('/api/datasets', { method: 'POST', body }); if (result.success) { activeDatasetId = result.id; activeDatasetName = name; activeDatasetIsDefault = !!result.is_default; + activeDatasetLanguage = result.language || language; + activeDatasetLanguageGroup = result.language_group || null; + await loadActiveDatasetSiblings(); showActiveDatasetBanner(activeDatasetId, activeDatasetName, activeDatasetIsDefault); closeSaveAsModal(); - toast(result.updated ? t('toast.dataset_updated') : t('toast.dataset_saved')); + toast(result.created && languageGroup ? t('toast.language_variant_created') : result.updated ? t('toast.dataset_updated') : t('toast.dataset_saved')); } else { toast(result.error || t('toast.failed_save'), 'error'); } @@ -2848,6 +3044,11 @@ async function submitSaveAs() { }); input.dataset.saveAsBound = '1'; } + const langSelect = document.getElementById('saveAsLangSelect'); + if (langSelect && !langSelect.dataset.saveAsBound) { + langSelect.addEventListener('change', updateSaveAsSubmitState); + langSelect.dataset.saveAsBound = '1'; + } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setup); @@ -2868,6 +3069,7 @@ function closeDatasetsModal() { // Render a single dataset row for the Open modal. // opts.isChild = true indents the row and attaches tree connectors to the version children. // opts.versionBadge = number renders a v{n} badge next to the name. +// opts.showLangBadge = true renders a language badge next to the name. function renderDatasetOpenRow(ds, opts = {}) { const isActive = ds.id === activeDatasetId; const isDefault = !!ds.is_default; @@ -2881,6 +3083,11 @@ function renderDatasetOpenRow(ds, opts = {}) { const versionBadgeHtml = opts.versionBadge ? `v${opts.versionBadge}` : ''; + const langBadgeHtml = opts.showLangBadge + ? `${(ds.language || 'en').toUpperCase()}` + : ''; + const dsLang = ds.language || 'en'; + const slugUrlSuffix = opts.showLangBadge ? `/${dsLang}` : ''; return `
@@ -2892,6 +3099,7 @@ function renderDatasetOpenRow(ds, opts = {}) {
+ ${langBadgeHtml} ${versionBadgeHtml} ${escapeHtml(ds.name)} ${isDefault ? 'Default' : ''} @@ -2899,8 +3107,8 @@ function renderDatasetOpenRow(ds, opts = {}) {
${escapeHtml(t('datasets.last_updated'))} ${formatDateTime(ds.updated_at)}
${showSlugUrl ? `
- /v/${escapeHtml(ds.slug)} - ${ds.is_public ? 'Public' : ''} @@ -2917,7 +3125,7 @@ function renderDatasetOpenRow(ds, opts = {}) { ${ds.is_public ? 'Shared' : 'Private'}
` : ''} ${ds.slug && !isDefault ? ` - ` : ''} @@ -2935,16 +3143,27 @@ async function loadDatasetsList() { return; } + // Detect language groups with multiple languages to show badges + const langGroupCounts = {}; + for (const ds of datasets) { + if (ds.language_group) { + if (!langGroupCounts[ds.language_group]) langGroupCounts[ds.language_group] = new Set(); + langGroupCounts[ds.language_group].add(ds.language || 'en'); + } + } + const multiLangGroups = new Set(Object.entries(langGroupCounts).filter(([, s]) => s.size > 1).map(([k]) => k)); + const groups = groupDatasetsByBase(datasets); container.innerHTML = groups.map(group => { + const showLang = group.items.some(ds => multiLangGroups.has(ds.language_group)); if (group.items.length === 1) { // Standalone dataset — render as today, no grouping chrome. - return renderDatasetOpenRow(group.items[0]); + return renderDatasetOpenRow(group.items[0], { showLangBadge: showLang }); } // Multi-version group — wrap in a container with a shared header. const countLabel = escapeHtml(t('datasets.versions_count', { count: group.items.length })); const children = group.items.map(ds => - renderDatasetOpenRow(ds, { isChild: true, versionBadge: ds._version }) + renderDatasetOpenRow(ds, { isChild: true, versionBadge: ds._version, showLangBadge: showLang }) ).join(''); return `
@@ -3058,6 +3277,9 @@ async function loadDataset(id, name) { activeDatasetId = result.id; activeDatasetName = result.name; activeDatasetIsDefault = !!result.is_default; + activeDatasetLanguage = result.language || 'en'; + activeDatasetLanguageGroup = result.language_group || null; + await loadActiveDatasetSiblings(); closeDatasetsModal(); await initAdmin(); toast(t('toast.dataset_loaded', { name: result.name })); diff --git a/public/shared/i18n/de.json b/public/shared/i18n/de.json index 54050375..2c8b0287 100644 --- a/public/shared/i18n/de.json +++ b/public/shared/i18n/de.json @@ -257,6 +257,18 @@ "datasets.save_new": "Als neuen speichern: \"{{name}}\"", "datasets.save_new_empty": "Als neuen speichern", "datasets.overwrite": "\"{{name}}\" überschreiben", + "datasets.language": "Sprache", + "datasets.add_language": "Sprache hinzufügen", + "datasets.add_language_hint": "Um eine neue Sprache hinzuzufügen, verwenden Sie Speichern unter", + "datasets.no_languages_available": "Alle Sprachen haben bereits Varianten", + "datasets.save_new_lang": "Speichern als \"{{name}}\" ({{lang}})", + "datasets.language_variant": "{{lang}}-Variante", + "datasets.lang_badge_tooltip": "Inhaltssprache: {{lang}}", + "datasets.languages_count": "{{count}} Sprachen", + "lang_switcher.label": "Sprache", + "lang_switcher.switch_to": "Wechseln zu {{lang}}", + "toast.language_variant_created": "Sprachvariante erstellt", + "confirm.switch_language": "Zu {{lang}} wechseln? Aktuelle Änderungen werden automatisch gespeichert.", "custom_section.edit_title": "Benutzerdefinierten Abschnitt bearbeiten", "custom_section.add_title": "Benutzerdefinierten Abschnitt hinzufügen", diff --git a/public/shared/i18n/en.json b/public/shared/i18n/en.json index a5795916..c0b62776 100644 --- a/public/shared/i18n/en.json +++ b/public/shared/i18n/en.json @@ -257,6 +257,20 @@ "datasets.save_new": "Save as new \"{{name}}\"", "datasets.save_new_empty": "Save as new", "datasets.overwrite": "Overwrite \"{{name}}\"", + "datasets.language": "Language", + "datasets.add_language": "Add language", + "datasets.add_language_hint": "To add a new language, use Save As", + "datasets.no_languages_available": "All languages already have variants", + "datasets.save_new_lang": "Save as \"{{name}}\" ({{lang}})", + "datasets.language_variant": "{{lang}} variant", + "datasets.lang_badge_tooltip": "Content language: {{lang}}", + "datasets.languages_count": "{{count}} languages", + + "lang_switcher.label": "Language", + "lang_switcher.switch_to": "Switch to {{lang}}", + + "toast.language_variant_created": "Language variant created", + "confirm.switch_language": "Switch to {{lang}}? Current changes will be auto-saved.", "custom_section.edit_title": "Edit Custom Section", "custom_section.add_title": "Add Custom Section", diff --git a/public/shared/i18n/es.json b/public/shared/i18n/es.json index 91d9403c..b3bedf83 100644 --- a/public/shared/i18n/es.json +++ b/public/shared/i18n/es.json @@ -243,6 +243,19 @@ "datasets.save_new": "Guardar como nuevo \"{{name}}\"", "datasets.save_new_empty": "Guardar como nuevo", "datasets.overwrite": "Sobrescribir \"{{name}}\"", + "datasets.language": "Idioma", + "datasets.add_language": "Añadir idioma", + "datasets.add_language_hint": "Para añadir un nuevo idioma, use Guardar como", + "datasets.no_languages_available": "Todos los idiomas ya tienen variantes", + "datasets.save_new_lang": "Guardar como \"{{name}}\" ({{lang}})", + "datasets.language_variant": "Variante {{lang}}", + "datasets.lang_badge_tooltip": "Idioma del contenido: {{lang}}", + "datasets.languages_count": "{{count}} idiomas", + "lang_switcher.label": "Idioma", + "lang_switcher.switch_to": "Cambiar a {{lang}}", + "toast.language_variant_created": "Variante de idioma creada", + "confirm.switch_language": "¿Cambiar a {{lang}}? Los cambios actuales se guardarán automáticamente.", + "custom_section.edit_title": "Editar sección personalizada", "custom_section.add_title": "Añadir sección personalizada", "custom_section.section_name": "Nombre de sección", diff --git a/public/shared/i18n/fr.json b/public/shared/i18n/fr.json index c93bf08d..9b0f1708 100644 --- a/public/shared/i18n/fr.json +++ b/public/shared/i18n/fr.json @@ -257,6 +257,18 @@ "datasets.save_new": "Enregistrer comme nouveau « {{name}} »", "datasets.save_new_empty": "Enregistrer comme nouveau", "datasets.overwrite": "Écraser « {{name}} »", + "datasets.language": "Langue", + "datasets.add_language": "Ajouter une langue", + "datasets.add_language_hint": "Pour ajouter une nouvelle langue, utilisez Enregistrer sous", + "datasets.no_languages_available": "Toutes les langues ont déjà des variantes", + "datasets.save_new_lang": "Enregistrer sous « {{name}} » ({{lang}})", + "datasets.language_variant": "Variante {{lang}}", + "datasets.lang_badge_tooltip": "Langue du contenu : {{lang}}", + "datasets.languages_count": "{{count}} langues", + "lang_switcher.label": "Langue", + "lang_switcher.switch_to": "Passer à {{lang}}", + "toast.language_variant_created": "Variante linguistique créée", + "confirm.switch_language": "Passer à {{lang}} ? Les modifications en cours seront enregistrées automatiquement.", "custom_section.edit_title": "Modifier la section personnalisée", "custom_section.add_title": "Ajouter une section personnalisée", diff --git a/public/shared/i18n/it.json b/public/shared/i18n/it.json index a9fc79ae..d332b7a9 100644 --- a/public/shared/i18n/it.json +++ b/public/shared/i18n/it.json @@ -243,6 +243,19 @@ "datasets.save_new": "Salva come nuovo \"{{name}}\"", "datasets.save_new_empty": "Salva come nuovo", "datasets.overwrite": "Sovrascrivi \"{{name}}\"", + "datasets.language": "Lingua", + "datasets.add_language": "Aggiungi lingua", + "datasets.add_language_hint": "Per aggiungere una nuova lingua, usa Salva con nome", + "datasets.no_languages_available": "Tutte le lingue hanno già varianti", + "datasets.save_new_lang": "Salva come \"{{name}}\" ({{lang}})", + "datasets.language_variant": "Variante {{lang}}", + "datasets.lang_badge_tooltip": "Lingua del contenuto: {{lang}}", + "datasets.languages_count": "{{count}} lingue", + "lang_switcher.label": "Lingua", + "lang_switcher.switch_to": "Passa a {{lang}}", + "toast.language_variant_created": "Variante linguistica creata", + "confirm.switch_language": "Passare a {{lang}}? Le modifiche attuali verranno salvate automaticamente.", + "custom_section.edit_title": "Modifica sezione personalizzata", "custom_section.add_title": "Aggiungi sezione personalizzata", "custom_section.section_name": "Nome sezione", diff --git a/public/shared/i18n/nl.json b/public/shared/i18n/nl.json index dbee483a..1e9ec4a6 100644 --- a/public/shared/i18n/nl.json +++ b/public/shared/i18n/nl.json @@ -243,6 +243,19 @@ "datasets.save_new": "Als nieuw opslaan \"{{name}}\"", "datasets.save_new_empty": "Als nieuw opslaan", "datasets.overwrite": "\"{{name}}\" overschrijven", + "datasets.language": "Taal", + "datasets.add_language": "Taal toevoegen", + "datasets.add_language_hint": "Gebruik Opslaan als om een nieuwe taal toe te voegen", + "datasets.no_languages_available": "Alle talen hebben al varianten", + "datasets.save_new_lang": "Opslaan als \"{{name}}\" ({{lang}})", + "datasets.language_variant": "{{lang}}-variant", + "datasets.lang_badge_tooltip": "Inhoudstaal: {{lang}}", + "datasets.languages_count": "{{count}} talen", + "lang_switcher.label": "Taal", + "lang_switcher.switch_to": "Overschakelen naar {{lang}}", + "toast.language_variant_created": "Taalvariant aangemaakt", + "confirm.switch_language": "Overschakelen naar {{lang}}? Huidige wijzigingen worden automatisch opgeslagen.", + "custom_section.edit_title": "Aangepaste sectie bewerken", "custom_section.add_title": "Aangepaste sectie toevoegen", "custom_section.section_name": "Sectienaam", diff --git a/public/shared/i18n/pt.json b/public/shared/i18n/pt.json index e456c70a..02c19008 100644 --- a/public/shared/i18n/pt.json +++ b/public/shared/i18n/pt.json @@ -243,6 +243,19 @@ "datasets.save_new": "Guardar como novo \"{{name}}\"", "datasets.save_new_empty": "Guardar como novo", "datasets.overwrite": "Substituir \"{{name}}\"", + "datasets.language": "Idioma", + "datasets.add_language": "Adicionar idioma", + "datasets.add_language_hint": "Para adicionar um novo idioma, use Salvar como", + "datasets.no_languages_available": "Todos os idiomas já possuem variantes", + "datasets.save_new_lang": "Salvar como \"{{name}}\" ({{lang}})", + "datasets.language_variant": "Variante {{lang}}", + "datasets.lang_badge_tooltip": "Idioma do conteúdo: {{lang}}", + "datasets.languages_count": "{{count}} idiomas", + "lang_switcher.label": "Idioma", + "lang_switcher.switch_to": "Mudar para {{lang}}", + "toast.language_variant_created": "Variante de idioma criada", + "confirm.switch_language": "Mudar para {{lang}}? As alterações atuais serão salvas automaticamente.", + "custom_section.edit_title": "Editar Secção Personalizada", "custom_section.add_title": "Adicionar Secção Personalizada", "custom_section.section_name": "Nome da Secção", diff --git a/public/shared/i18n/zh.json b/public/shared/i18n/zh.json index 06d8057d..a7722bbf 100644 --- a/public/shared/i18n/zh.json +++ b/public/shared/i18n/zh.json @@ -243,6 +243,19 @@ "datasets.save_new": "另存为新简历 \"{{name}}\"", "datasets.save_new_empty": "另存为新简历", "datasets.overwrite": "覆盖 \"{{name}}\"", + "datasets.language": "语言", + "datasets.add_language": "添加语言", + "datasets.add_language_hint": "要添加新语言,请使用另存为", + "datasets.no_languages_available": "所有语言已有变体", + "datasets.save_new_lang": "另存为 \"{{name}}\" ({{lang}})", + "datasets.language_variant": "{{lang}} 变体", + "datasets.lang_badge_tooltip": "内容语言:{{lang}}", + "datasets.languages_count": "{{count}} 种语言", + "lang_switcher.label": "语言", + "lang_switcher.switch_to": "切换到 {{lang}}", + "toast.language_variant_created": "语言变体已创建", + "confirm.switch_language": "切换到 {{lang}}?当前更改将自动保存。", + "custom_section.edit_title": "编辑自定义板块", "custom_section.add_title": "添加自定义板块", "custom_section.section_name": "板块名称", diff --git a/public/shared/styles.css b/public/shared/styles.css index a6ccc8f5..b17cb64e 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -1122,6 +1122,164 @@ body { flex-shrink: 0; } +/* Language badge (used in dataset rows, banners, etc.) */ +.dataset-lang-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + border-radius: 3px; + background: var(--primary); + color: var(--white); + text-transform: uppercase; + line-height: 1.4; + vertical-align: middle; +} + +/* Public language switcher */ +.public-lang-switcher { + position: fixed; + bottom: 24px; + right: 84px; + z-index: 1000; +} +.public-lang-switch-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + border-radius: 24px; + background: var(--primary); + color: var(--white); + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + box-shadow: 0 4px 12px rgba(0, 102, 255, 0.3); + transition: all 0.2s ease; +} +.public-lang-switch-btn:hover { + background: var(--primary-dark); + transform: scale(1.05); +} +.public-lang-dropdown { + display: none; + position: absolute; + bottom: calc(100% + 8px); + right: 0; + min-width: 160px; + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + overflow: hidden; +} +.public-lang-dropdown.active { display: block; } +.public-lang-option { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + text-decoration: none; + color: var(--gray-700); + font-size: 13px; + transition: background 0.15s; + cursor: pointer; +} +.public-lang-option:hover { background: var(--gray-50); } +.public-lang-option.active { + background: var(--primary-light, rgba(0, 102, 255, 0.08)); + color: var(--primary); + font-weight: 600; +} +.public-lang-code { + font-weight: 700; + font-size: 11px; + letter-spacing: 0.5px; + min-width: 24px; +} +.public-lang-name { flex: 1; } + +/* Admin dataset language switcher */ +.dataset-lang-switcher { + position: relative; + margin-left: 8px; +} +.dataset-lang-switch-btn { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; +} +.dataset-lang-dropdown { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 180px; + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + overflow: hidden; + z-index: 1001; +} +.dataset-lang-dropdown.active { display: block; } +.dataset-lang-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + color: var(--gray-700); + cursor: pointer; + transition: background 0.15s; +} +.dataset-lang-option:hover { background: var(--gray-50); } +.dataset-lang-option.active { + background: var(--primary-light, rgba(0, 102, 255, 0.08)); + color: var(--primary); + font-weight: 600; +} +.dataset-lang-option.disabled { + opacity: 0.5; + cursor: default; + border-top: 1px solid var(--gray-100); +} +.dataset-lang-option.disabled:hover { background: transparent; } +.dataset-lang-code { + font-weight: 700; + font-size: 11px; + letter-spacing: 0.5px; + min-width: 22px; +} +.dataset-lang-name { flex: 1; } + +/* Save As modal — language field */ +.save-as-language-field { + margin-top: 12px; + display: flex; + align-items: center; + gap: 10px; +} +.save-as-lang-select { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--gray-200); + border-radius: 6px; + font-size: 13px; + background: var(--white); + color: var(--gray-700); +} + +/* Save As modal — add language button */ +.save-as-newlang-btn { + margin-left: 4px; +} + /* Preview Banner (for versioned dataset URLs) */ .preview-banner { position: fixed; diff --git a/src/server.js b/src/server.js index a7c6a5c8..efeec92a 100644 --- a/src/server.js +++ b/src/server.js @@ -3,6 +3,7 @@ const Database = require('better-sqlite3'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); const multer = require('multer'); const PDFDocument = require('pdfkit'); const archiver = require('archiver'); @@ -185,7 +186,13 @@ function servePublicIndex(req, res) { // Check if a default dataset exists — serve from it instead of live DB let defaultDataset = null; try { - defaultDataset = db.prepare('SELECT * FROM saved_datasets WHERE is_default = 1').get(); + const requestedLang = req.query.lang; + if (requestedLang) { + defaultDataset = db.prepare('SELECT * FROM saved_datasets WHERE is_default = 1 AND language = ?').get(requestedLang); + } + if (!defaultDataset) { + defaultDataset = db.prepare("SELECT * FROM saved_datasets WHERE is_default = 1 ORDER BY CASE WHEN language = 'en' THEN 0 ELSE 1 END, language ASC LIMIT 1").get(); + } } catch (e) { /* is_default column may not exist yet */ } if (defaultDataset && defaultDataset.slug) { @@ -193,29 +200,32 @@ function servePublicIndex(req, res) { const name = data.profile?.name || defaultDataset.name; const bio = data.profile?.bio || 'Professional CV'; const description = bio.substring(0, 160).replace(/\n/g, ' '); - + const dsLang = defaultDataset.language || 'en'; + let html = fs.readFileSync(path.join(__dirname, '../public-readonly/index.html'), 'utf8'); + html = html.replace(/[^<]*<\/title>/, `${name} - CV`); html = html.replace(//, ``); - + // Inject robots meta from settings const robotsSetting = db.prepare('SELECT value FROM settings WHERE key = ?').get('robotsMeta'); const robotsValue = robotsSetting?.value || 'index, follow'; html = html.replace(/]*>/, ``); - + const ogTags = `\n \n \n `; html = html.replace(//, `${ogTags}`); - + // Inject tracking code right after (server-side for GA verification) const trackingCode = getTrackingCode(); if (trackingCode) { html = html.replace('', `\n${trackingCode}`); } - - // Inject default dataset slug (no DATASET_PREVIEW = no preview banner) - const datasetScript = ``; + + // Inject default dataset slug with language info (no DATASET_PREVIEW = no preview banner) + const siblings = getDatasetSiblings(defaultDataset); + const datasetScript = ``; html = html.replace('', `${datasetScript}`); - + return res.type('html').send(html); } @@ -224,63 +234,66 @@ function servePublicIndex(req, res) { const name = profile?.name || 'CV'; const bio = profile?.bio || 'Professional CV'; const description = bio.substring(0, 160).replace(/\n/g, ' '); - + let html = fs.readFileSync(path.join(__dirname, '../public-readonly/index.html'), 'utf8'); html = html.replace(/[^<]*<\/title>/, `<title>${name} - CV`); html = html.replace(//, ``); - + // Inject robots meta from settings const robotsSetting = db.prepare('SELECT value FROM settings WHERE key = ?').get('robotsMeta'); const robotsValue = robotsSetting?.value || 'index, follow'; html = html.replace(/]*>/, ``); - + const ogTags = `\n \n \n `; html = html.replace(//, `${ogTags}`); - + // Inject tracking code right after (server-side for GA verification) const trackingCode = getTrackingCode(); if (trackingCode) { html = html.replace('', `\n${trackingCode}`); } - + res.type('html').send(html); } catch (err) { res.sendFile(path.join(__dirname, '../public-readonly/index.html')); } } // Serve a public dataset page by slug (for /v/:slug on public server) -function serveDatasetPage(req, res) { +function serveDatasetPage(req, res, lang) { try { - const dataset = db.prepare('SELECT * FROM saved_datasets WHERE slug = ? AND (is_public = 1 OR is_default = 1)').get(req.params.slug); + const dataset = resolveDatasetBySlug(req.params.slug, lang || req.params.lang || req.query.lang, true); if (!dataset) return res.status(404).send('Not found'); - + const data = JSON.parse(dataset.data); const name = data.profile?.name || dataset.name; const bio = data.profile?.bio || ''; const description = bio.substring(0, 160).replace(/\n/g, ' '); - + const dsLang = dataset.language || 'en'; + let html = fs.readFileSync(path.join(__dirname, '../public-readonly/index.html'), 'utf8'); + html = html.replace(/[^<]*<\/title>/, `${name} - CV (${dataset.name})`); html = html.replace(//, ``); - + const ogTags = `\n \n \n `; html = html.replace(//, `${ogTags}`); - - // Inject slug only (no DATASET_PREVIEW = public view, no preview banner) - const datasetScript = ``; + + // Inject dataset context with language info + const siblings = getDatasetSiblings(dataset); + const datasetScript = ``; html = html.replace('', `${datasetScript}`); - + // Apply noindex if slugsIndex setting is not enabled const slugsIndexSetting = db.prepare('SELECT value FROM settings WHERE key = ?').get('slugsIndex'); if (!slugsIndexSetting || slugsIndexSetting.value !== 'true') { html = html.replace(/]*>/, ''); } - + // Inject tracking code right after (server-side for GA verification) const trackingCode = getTrackingCode(); if (trackingCode) { html = html.replace('', `\n${trackingCode}`); } - + res.type('html').send(html); } catch (err) { if (err.message?.includes('no such column')) return res.status(404).send('Not found'); @@ -291,11 +304,12 @@ function serveDatasetPage(req, res) { // Serve dataset data as JSON for public slug API function serveDatasetData(req, res) { try { - // Allow access if dataset is public OR is the default (default = served at /) - const dataset = db.prepare('SELECT * FROM saved_datasets WHERE slug = ? AND (is_public = 1 OR is_default = 1)').get(req.params.slug); + const lang = req.params.lang || req.query.lang; + const dataset = resolveDatasetBySlug(req.params.slug, lang, true); if (!dataset) return res.status(404).json({ error: 'Not found' }); const data = JSON.parse(dataset.data); - res.json({ name: dataset.name, slug: dataset.slug, ...data }); + const siblings = getDatasetSiblings(dataset); + res.json({ name: dataset.name, slug: dataset.slug, language: dataset.language, language_group: dataset.language_group, siblings, ...data }); } catch (err) { if (err.message?.includes('no such column')) return res.status(404).json({ error: 'Not found' }); res.status(500).json({ error: err.message }); @@ -577,6 +591,44 @@ if (!PUBLIC_ONLY) { } } catch (err) { console.log('Migration check (certifications logo_propagate):', err.message); } + // Step 2o: Migration - add language and language_group columns to saved_datasets + // Recreates table to change UNIQUE constraints: name → (name, language), slug → (slug, language) + try { + const dsLangInfo = db.prepare("PRAGMA table_info(saved_datasets)").all(); + if (!dsLangInfo.some(col => col.name === 'language')) { + console.log('Migrating saved_datasets table: adding language support'); + const migrate = db.transaction(() => { + db.exec(`CREATE TABLE saved_datasets_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + data TEXT NOT NULL, + slug TEXT, + language TEXT NOT NULL DEFAULT 'en', + language_group TEXT, + is_public INTEGER DEFAULT 0, + is_default INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(slug, language), + UNIQUE(name, language) + )`); + const existing = db.prepare('SELECT * FROM saved_datasets').all(); + const insertStmt = db.prepare(`INSERT INTO saved_datasets_new + (id, name, data, slug, language, language_group, is_public, is_default, created_at, updated_at) + VALUES (?, ?, ?, ?, 'en', ?, ?, ?, ?, ?)`); + for (const row of existing) { + const groupId = crypto.randomUUID(); + insertStmt.run(row.id, row.name, row.data, row.slug || null, groupId, + row.is_public || 0, row.is_default || 0, row.created_at, row.updated_at); + } + db.exec('DROP TABLE saved_datasets'); + db.exec('ALTER TABLE saved_datasets_new RENAME TO saved_datasets'); + }); + migrate(); + console.log('Migration complete: language support added to saved_datasets'); + } + } catch (err) { console.error('Migration error (saved_datasets language):', err.message); } + // Step 2h: Migration - normalize legacy date formats (e.g., "Jan 2020" → "2020-01") // Runs once; creates a flag in settings to avoid re-running on every startup try { @@ -702,13 +754,19 @@ if (!PUBLIC_ONLY) { if (!hasDefault) { console.log('Auto-creating default dataset from live CV data'); const cvData = gatherCvData(); - const existingDefault = db.prepare('SELECT id FROM saved_datasets WHERE name = ?').get('Default'); + const existingDefault = db.prepare("SELECT id FROM saved_datasets WHERE name = ? AND language = 'en'").get('Default'); if (existingDefault) { // A dataset named "Default" already exists — update it and mark as default db.prepare('UPDATE saved_datasets SET data = ?, is_default = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(cvData), existingDefault.id); + // Ensure it has a language_group + const ds = db.prepare('SELECT language_group FROM saved_datasets WHERE id = ?').get(existingDefault.id); + if (!ds.language_group) { + db.prepare('UPDATE saved_datasets SET language_group = ? WHERE id = ?').run(crypto.randomUUID(), existingDefault.id); + } console.log(`Updated existing "Default" dataset (id: ${existingDefault.id}) and set as default`); } else { - const result = db.prepare('INSERT INTO saved_datasets (name, data, is_default, is_public) VALUES (?, ?, 1, 0)').run('Default', JSON.stringify(cvData)); + const groupId = crypto.randomUUID(); + const result = db.prepare("INSERT INTO saved_datasets (name, data, language, language_group, is_default, is_public) VALUES (?, ?, 'en', ?, 1, 0)").run('Default', JSON.stringify(cvData), groupId); const newId = result.lastInsertRowid; try { const slug = generateSlug('Default', newId); @@ -817,6 +875,169 @@ function generateSlug(name, id) { return base ? `${base}-${id}` : `dataset-${id}`; } +// Propagate structural changes from a source dataset to its language siblings. +// Structure (section order, visibility, custom section layout) is shared across +// language variants; content (text, descriptions) stays per-language. +function propagateStructure(sourceId) { + try { + const source = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(sourceId); + if (!source || !source.language_group) return; + + const siblings = db.prepare('SELECT * FROM saved_datasets WHERE language_group = ? AND id != ?').all(source.language_group, sourceId); + if (siblings.length === 0) return; + + const srcData = JSON.parse(source.data); + + // Extract structural snapshot from source + const srcSectionVis = srcData.sectionVisibility || {}; + const srcSectionOrder = (srcData.sectionOrder || []).map(s => ({ + key: s.key, sort_order: s.sort_order, visible: s.visible + })); + const srcCustomStructure = (srcData.customSections || []).map(cs => ({ + section_key: cs.section_key, + layout_type: cs.layout_type, + icon: cs.icon, + sort_order: cs.sort_order, + visible: cs.visible, + metadata: cs.metadata, + items: (cs.items || []).map(item => ({ + sort_order: item.sort_order, + visible: item.visible, + icon: item.icon, + image: item.image + })) + })); + + const updateSibling = db.transaction(() => { + for (const sibling of siblings) { + const sibData = JSON.parse(sibling.data); + + // 1. Propagate sectionVisibility + sibData.sectionVisibility = { ...srcSectionVis }; + + // 2. Propagate sectionOrder (preserve sibling display_name) + const sibOrderMap = {}; + (sibData.sectionOrder || []).forEach(s => { sibOrderMap[s.key] = s; }); + sibData.sectionOrder = srcSectionOrder.map(s => { + const existing = sibOrderMap[s.key] || {}; + return { + ...existing, + key: s.key, + sort_order: s.sort_order, + visible: s.visible, + // Preserve sibling's display_name (content) and name/default_name + display_name: existing.display_name !== undefined ? existing.display_name : null, + name: existing.name || existing.display_name || s.key, + default_name: existing.default_name || s.key + }; + }); + + // 3. Propagate customSections structure + const sibCsMap = {}; + (sibData.customSections || []).forEach(cs => { sibCsMap[cs.section_key] = cs; }); + + sibData.customSections = srcCustomStructure.map(srcCs => { + const sibCs = sibCsMap[srcCs.section_key]; + + // Section content from sibling (or empty if new) + const sectionName = sibCs ? sibCs.name : (srcData.customSections.find(c => c.section_key === srcCs.section_key)?.name || srcCs.section_key); + const displayName = sibCs ? sibCs.display_name : null; + + // Sync items by position + const sibItems = sibCs ? (sibCs.items || []) : []; + const srcItems = srcCs.items || []; + const mergedItems = srcItems.map((srcItem, idx) => { + const sibItem = sibItems[idx] || {}; + return { + // Content from sibling (or empty placeholder) + title: sibItem.title !== undefined ? sibItem.title : '', + subtitle: sibItem.subtitle !== undefined ? sibItem.subtitle : '', + description: sibItem.description !== undefined ? sibItem.description : '', + link: sibItem.link !== undefined ? sibItem.link : '', + metadata: sibItem.metadata !== undefined ? sibItem.metadata : null, + // Structure from source + sort_order: srcItem.sort_order, + visible: srcItem.visible, + icon: srcItem.icon, + image: srcItem.image + }; + }); + + return { + // Preserve sibling's id if it existed (not critical for JSON blob) + ...(sibCs ? { id: sibCs.id } : {}), + name: sectionName, + section_key: srcCs.section_key, + layout_type: srcCs.layout_type, + icon: srcCs.icon, + sort_order: srcCs.sort_order, + visible: srcCs.visible, + metadata: srcCs.metadata, + display_name: displayName, + items: mergedItems + }; + }); + + db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(JSON.stringify(sibData), sibling.id); + } + }); + updateSibling(); + } catch (err) { + console.error('Structural propagation error:', err.message); + } +} + +// Resolve a dataset by slug and optional language, returning the best match +function resolveDatasetBySlug(slug, lang, requirePublic) { + const publicFilter = requirePublic ? ' AND (is_public = 1 OR is_default = 1)' : ''; + let dataset; + if (lang) { + dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ? AND language = ?${publicFilter}`).get(slug, lang); + } + if (!dataset) { + dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ?${publicFilter} ORDER BY CASE WHEN language = 'en' THEN 0 ELSE 1 END, language ASC LIMIT 1`).get(slug); + } + return dataset || null; +} + +// Get siblings list for injecting into HTML pages +function getDatasetSiblings(dataset) { + if (!dataset || !dataset.language_group) return []; + return db.prepare('SELECT id, language FROM saved_datasets WHERE language_group = ? AND (is_public = 1 OR is_default = 1) ORDER BY language ASC').all(dataset.language_group); +} + +// Serve admin dataset preview page with language support +function serveAdminDatasetPage(req, res, lang) { + try { + const dataset = resolveDatasetBySlug(req.params.slug, lang, false); + if (!dataset) return res.status(404).send('Dataset not found'); + + const data = JSON.parse(dataset.data); + const name = data.profile?.name || dataset.name; + const bio = data.profile?.bio || ''; + const description = bio.substring(0, 160).replace(/\n/g, ' '); + const dsLang = dataset.language || 'en'; + + let html = fs.readFileSync(path.join(__dirname, '../public-readonly/index.html'), 'utf8'); + html = html.replace(/[^<]*<\/title>/, `${name} - CV (${dataset.name})`); + html = html.replace(//, ``); + + // Inject dataset context for client + const siblings = dataset.language_group + ? db.prepare('SELECT id, language FROM saved_datasets WHERE language_group = ? ORDER BY language ASC').all(dataset.language_group) + : [{ id: dataset.id, language: dsLang }]; + const datasetScript = ``; + html = html.replace('', `${datasetScript}`); + + res.type('html').send(html); + } catch (err) { + if (err.message?.includes('no such column')) return res.status(404).send('Versioned datasets not available'); + res.status(500).send('Error loading dataset'); + } +} + // Known analytics providers and their required companion domains // These domains are used internally by the provider scripts for data collection, // beacons, and API calls but don't appear in the user-pasted snippet @@ -1014,8 +1235,10 @@ if (PUBLIC_ONLY) { res.json({ profile, experiences: experiences.map(e => ({ ...e, highlights: e.highlights ? JSON.parse(e.highlights) : [] })), certifications, education, skills: skillCategories.map(cat => ({ ...cat, skills: skills.filter(s => s.category_id === cat.id).map(s => s.name) })), projects: projects.map(p => ({ ...p, technologies: p.technologies ? JSON.parse(p.technologies) : [] })), sectionOrder: sectionOrder.map(s => s.section_name) }); }); - // Public versioned CV routes + // Public versioned CV routes (language-specific must come before generic) + publicApp.get('/v/:slug/:lang', (req, res) => { serveDatasetPage(req, res, req.params.lang); }); publicApp.get('/v/:slug', (req, res) => { serveDatasetPage(req, res); }); + publicApp.get('/api/datasets/slug/:slug/:lang', (req, res) => { serveDatasetData(req, res); }); publicApp.get('/api/datasets/slug/:slug', (req, res) => { serveDatasetData(req, res); }); publicApp.get('*', (req, res) => { servePublicIndex(req, res); }); @@ -1638,26 +1861,80 @@ if (PUBLIC_ONLY) { try { // Use SELECT * to avoid errors if slug column doesn't exist const datasets = db.prepare('SELECT * FROM saved_datasets ORDER BY updated_at DESC').all(); - res.json(datasets.map(d => ({ id: d.id, name: d.name, slug: d.slug || null, is_public: !!d.is_public, is_default: !!d.is_default, created_at: d.created_at, updated_at: d.updated_at }))); + res.json(datasets.map(d => ({ id: d.id, name: d.name, slug: d.slug || null, language: d.language || 'en', language_group: d.language_group || null, is_public: !!d.is_public, is_default: !!d.is_default, created_at: d.created_at, updated_at: d.updated_at }))); } catch (err) { res.status(500).json({ error: err.message }); } }); - app.post('/api/datasets', (req, res) => { const { name } = req.body; if (!name || !name.trim()) return res.status(400).json({ error: 'Name is required' }); const cvData = gatherCvData(); try { const existing = db.prepare('SELECT id FROM saved_datasets WHERE name = ?').get(name.trim()); if (existing) { db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(cvData), existing.id); const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(existing.id); res.json({ success: true, id: existing.id, slug: ds.slug || null, is_default: !!ds.is_default, updated: true }); } else { const result = db.prepare('INSERT INTO saved_datasets (name, data) VALUES (?, ?)').run(name.trim(), JSON.stringify(cvData)); const newId = result.lastInsertRowid; let slug = null; try { slug = generateSlug(name.trim(), newId); db.prepare('UPDATE saved_datasets SET slug = ? WHERE id = ?').run(slug, newId); } catch (slugErr) { console.log('Slug update skipped (column may not exist):', slugErr.message); } res.json({ success: true, id: newId, slug, is_default: false, created: true }); } } catch (err) { res.status(500).json({ error: err.message }); } }); + app.post('/api/datasets', (req, res) => { + const { name, language: reqLang, language_group: reqGroup } = req.body; + if (!name || !name.trim()) return res.status(400).json({ error: 'Name is required' }); + const language = (reqLang || 'en').trim().toLowerCase(); + const cvData = gatherCvData(); + try { + // Early check: if a language_group is provided, reject duplicate language before anything else + if (reqGroup) { + const dupLang = db.prepare('SELECT id FROM saved_datasets WHERE language_group = ? AND language = ?').get(reqGroup, language); + if (dupLang) return res.status(400).json({ error: 'This language already exists in the group' }); + } + const existing = db.prepare('SELECT id FROM saved_datasets WHERE name = ? AND language = ?').get(name.trim(), language); + if (existing) { + db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(cvData), existing.id); + const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(existing.id); + propagateStructure(existing.id); + res.json({ success: true, id: existing.id, slug: ds.slug || null, language: ds.language, language_group: ds.language_group, is_default: !!ds.is_default, updated: true }); + } else { + let languageGroup = reqGroup || null; + let slug = null; + let isPublic = 0; + let isDefault = 0; + + if (languageGroup) { + // Adding a language variant to an existing group + const sibling = db.prepare('SELECT * FROM saved_datasets WHERE language_group = ? LIMIT 1').get(languageGroup); + if (!sibling) return res.status(400).json({ error: 'Language group not found' }); + const dupLang = db.prepare('SELECT id FROM saved_datasets WHERE language_group = ? AND language = ?').get(languageGroup, language); + if (dupLang) return res.status(400).json({ error: 'This language already exists in the group' }); + slug = sibling.slug; + isPublic = sibling.is_public || 0; + isDefault = sibling.is_default || 0; + } else { + languageGroup = crypto.randomUUID(); + } - // Set a dataset as the default (public at /) + const result = db.prepare('INSERT INTO saved_datasets (name, data, language, language_group, is_public, is_default) VALUES (?, ?, ?, ?, ?, ?)').run(name.trim(), JSON.stringify(cvData), language, languageGroup, isPublic, isDefault); + const newId = result.lastInsertRowid; + if (!slug) { + try { + slug = generateSlug(name.trim(), newId); + db.prepare('UPDATE saved_datasets SET slug = ? WHERE id = ?').run(slug, newId); + } catch (slugErr) { console.log('Slug update skipped:', slugErr.message); } + } else { + db.prepare('UPDATE saved_datasets SET slug = ? WHERE id = ?').run(slug, newId); + } + propagateStructure(newId); + res.json({ success: true, id: newId, slug, language, language_group: languageGroup, is_default: !!isDefault, created: true }); + } + } catch (err) { res.status(500).json({ error: err.message }); } + }); + + // Set a dataset as the default (public at /) — applies to entire language group app.put('/api/datasets/:id/default', (req, res) => { try { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); - - // Clear any existing default, then set the new one (atomic) + + // Clear any existing default, then set all group members as default (atomic) const setDefault = db.transaction(() => { db.prepare('UPDATE saved_datasets SET is_default = 0 WHERE is_default = 1').run(); - db.prepare('UPDATE saved_datasets SET is_default = 1 WHERE id = ?').run(req.params.id); + if (dataset.language_group) { + db.prepare('UPDATE saved_datasets SET is_default = 1 WHERE language_group = ?').run(dataset.language_group); + } else { + db.prepare('UPDATE saved_datasets SET is_default = 1 WHERE id = ?').run(req.params.id); + } }); setDefault(); - + const updated = db.prepare('SELECT id, name, slug, is_public, is_default FROM saved_datasets WHERE id = ?').get(req.params.id); res.json({ success: true, id: updated.id, name: updated.name, slug: updated.slug, is_default: !!updated.is_default }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -1666,9 +1943,9 @@ if (PUBLIC_ONLY) { // Get the current default dataset info app.get('/api/datasets/default', (req, res) => { try { - const dataset = db.prepare('SELECT id, name, slug, is_public, is_default, updated_at FROM saved_datasets WHERE is_default = 1').get(); + const dataset = db.prepare('SELECT id, name, slug, language, language_group, is_public, is_default, updated_at FROM saved_datasets WHERE is_default = 1 ORDER BY language ASC LIMIT 1').get(); if (!dataset) return res.json({ exists: false }); - res.json({ exists: true, id: dataset.id, name: dataset.name, slug: dataset.slug, is_public: !!dataset.is_public, updated_at: dataset.updated_at }); + res.json({ exists: true, id: dataset.id, name: dataset.name, slug: dataset.slug, language: dataset.language || 'en', language_group: dataset.language_group, is_public: !!dataset.is_public, updated_at: dataset.updated_at }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -1679,10 +1956,11 @@ if (PUBLIC_ONLY) { if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); const cvData = gatherCvData(); db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(cvData), req.params.id); - res.json({ success: true, id: dataset.id, name: dataset.name, is_default: !!dataset.is_default }); + propagateStructure(req.params.id); + res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, is_default: !!dataset.is_default }); } catch (err) { res.status(500).json({ error: err.message }); } }); - app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.profile) { const p = data.profile; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, is_default: !!dataset.is_default }); } catch (err) { res.status(500).json({ error: err.message }); } }); + app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.profile) { const p = data.profile; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, is_default: !!dataset.is_default }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.delete('/api/datasets/:id', (req, res) => { try { const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); @@ -1693,62 +1971,76 @@ if (PUBLIC_ONLY) { } catch (err) { res.status(500).json({ error: err.message }); } }); - // Toggle dataset public visibility + // Toggle dataset public visibility — applies to entire language group app.put('/api/datasets/:id/public', (req, res) => { const { is_public } = req.body; try { - db.prepare('UPDATE saved_datasets SET is_public = ? WHERE id = ?').run(is_public ? 1 : 0, req.params.id); - const ds = db.prepare('SELECT id, name, slug, is_public, is_default FROM saved_datasets WHERE id = ?').get(req.params.id); + const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!ds) return res.status(404).json({ error: 'Dataset not found' }); - res.json({ success: true, id: ds.id, slug: ds.slug, is_public: !!ds.is_public, is_default: !!ds.is_default }); + if (ds.language_group) { + db.prepare('UPDATE saved_datasets SET is_public = ? WHERE language_group = ?').run(is_public ? 1 : 0, ds.language_group); + } else { + db.prepare('UPDATE saved_datasets SET is_public = ? WHERE id = ?').run(is_public ? 1 : 0, req.params.id); + } + const updated = db.prepare('SELECT id, name, slug, is_public, is_default FROM saved_datasets WHERE id = ?').get(req.params.id); + res.json({ success: true, id: updated.id, slug: updated.slug, is_public: !!updated.is_public, is_default: !!updated.is_default }); + } catch (err) { res.status(500).json({ error: err.message }); } + }); + + // Get all siblings of a dataset (same language_group) + app.get('/api/datasets/:id/siblings', (req, res) => { + try { + const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); + if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); + if (!dataset.language_group) return res.json([]); + const siblings = db.prepare('SELECT id, name, language, slug, is_default, is_public, updated_at FROM saved_datasets WHERE language_group = ? ORDER BY language ASC').all(dataset.language_group); + res.json(siblings.map(s => ({ ...s, is_default: !!s.is_default, is_public: !!s.is_public }))); } catch (err) { res.status(500).json({ error: err.message }); } }); // Dataset preview API - returns CV data for a specific dataset (admin only) - app.get('/api/datasets/slug/:slug', (req, res) => { + // Language-specific: /api/datasets/slug/:slug/:lang + app.get('/api/datasets/slug/:slug/:lang', (req, res) => { try { - const dataset = db.prepare('SELECT * FROM saved_datasets WHERE slug = ?').get(req.params.slug); + const dataset = db.prepare('SELECT * FROM saved_datasets WHERE slug = ? AND language = ?').get(req.params.slug, req.params.lang); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); const data = JSON.parse(dataset.data); - res.json({ name: dataset.name, slug: dataset.slug, ...data }); - } catch (err) { - // If slug column doesn't exist, return 404 - if (err.message.includes('no such column')) { - return res.status(404).json({ error: 'Versioned datasets not available' }); - } - res.status(500).json({ error: err.message }); + const siblings = dataset.language_group + ? db.prepare('SELECT id, language FROM saved_datasets WHERE language_group = ? ORDER BY language ASC').all(dataset.language_group) + : [{ id: dataset.id, language: dataset.language || 'en' }]; + res.json({ name: dataset.name, slug: dataset.slug, language: dataset.language, language_group: dataset.language_group, siblings, ...data }); + } catch (err) { + if (err.message?.includes('no such column')) return res.status(404).json({ error: 'Versioned datasets not available' }); + res.status(500).json({ error: err.message }); } }); - - // Dataset preview page route (admin only) - serves the public-readonly template with dataset context - app.get('/v/:slug', (req, res) => { + // Fallback (no lang): return first available variant (prefer 'en') + app.get('/api/datasets/slug/:slug', (req, res) => { try { - const dataset = db.prepare('SELECT * FROM saved_datasets WHERE slug = ?').get(req.params.slug); - if (!dataset) return res.status(404).send('Dataset not found'); - - const data = JSON.parse(dataset.data); - const name = data.profile?.name || dataset.name; - const bio = data.profile?.bio || ''; - const description = bio.substring(0, 160).replace(/\n/g, ' '); - - let html = fs.readFileSync(path.join(__dirname, '../public-readonly/index.html'), 'utf8'); - html = html.replace(/[^<]*<\/title>/, `<title>${name} - CV (${dataset.name})`); - html = html.replace(//, ``); - - // Inject script to load dataset data instead of live data (admin preview mode) - const datasetScript = ``; - html = html.replace('', `${datasetScript}`); - - res.type('html').send(html); - } catch (err) { - // If slug column doesn't exist, return 404 - if (err.message.includes('no such column')) { - return res.status(404).send('Versioned datasets not available'); + const lang = req.query.lang; + let dataset; + if (lang) { + dataset = db.prepare('SELECT * FROM saved_datasets WHERE slug = ? AND language = ?').get(req.params.slug, lang); } - res.status(500).send('Error loading dataset'); + if (!dataset) { + dataset = db.prepare('SELECT * FROM saved_datasets WHERE slug = ? ORDER BY CASE WHEN language = \'en\' THEN 0 ELSE 1 END, language ASC LIMIT 1').get(req.params.slug); + } + if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); + const data = JSON.parse(dataset.data); + const siblings = dataset.language_group + ? db.prepare('SELECT id, language FROM saved_datasets WHERE language_group = ? ORDER BY language ASC').all(dataset.language_group) + : [{ id: dataset.id, language: dataset.language || 'en' }]; + res.json({ name: dataset.name, slug: dataset.slug, language: dataset.language, language_group: dataset.language_group, siblings, ...data }); + } catch (err) { + if (err.message?.includes('no such column')) return res.status(404).json({ error: 'Versioned datasets not available' }); + res.status(500).json({ error: err.message }); } }); + // Dataset preview page route (admin only) — language-specific + app.get('/v/:slug/:lang', (req, res) => { serveAdminDatasetPage(req, res, req.params.lang); }); + app.get('/v/:slug', (req, res) => { serveAdminDatasetPage(req, res, req.query.lang); }); + // Custom Sections API app.get('/api/custom-sections', (req, res) => { const sections = db.prepare('SELECT * FROM custom_sections ORDER BY sort_order ASC').all(); @@ -2544,8 +2836,10 @@ if (PUBLIC_ONLY) { publicApp.get('/api/layout-types', (req, res) => { res.json(LAYOUT_TYPES); }); publicApp.get('/api/social-platforms', (req, res) => { res.json(SOCIAL_PLATFORMS); }); publicApp.get('/api/cv', (req, res) => { const profile = db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, open_to_work FROM profile WHERE id = 1').get(); const experiences = db.prepare('SELECT job_title, company_name, start_date, end_date, location, country_code, highlights, logo_filename FROM experiences WHERE visible = 1 ORDER BY sort_order ASC, start_date DESC').all(); const certifications = db.prepare('SELECT name, provider, issue_date, expiry_date, credential_id, logo_filename FROM certifications WHERE visible = 1 ORDER BY sort_order ASC, issue_date DESC').all(); const education = db.prepare('SELECT degree_title, institution_name, start_date, end_date, description, logo_filename FROM education WHERE visible = 1 ORDER BY sort_order ASC, end_date DESC').all(); const skillCategories = db.prepare('SELECT id, name, icon FROM skill_categories WHERE visible = 1 ORDER BY sort_order ASC').all(); const skills = db.prepare('SELECT * FROM skills ORDER BY sort_order ASC').all(); const projects = db.prepare('SELECT title, description, technologies, link FROM projects WHERE visible = 1 ORDER BY sort_order ASC').all(); const sectionOrder = db.prepare('SELECT section_name, sort_order FROM section_visibility WHERE visible = 1 ORDER BY sort_order ASC').all(); res.json({ profile, experiences: experiences.map(e => ({ ...e, highlights: e.highlights ? JSON.parse(e.highlights) : [] })), certifications, education, skills: skillCategories.map(cat => ({ ...cat, skills: skills.filter(s => s.category_id === cat.id).map(s => s.name) })), projects: projects.map(p => ({ ...p, technologies: p.technologies ? JSON.parse(p.technologies) : [] })), sectionOrder: sectionOrder.map(s => s.section_name) }); }); - // Public versioned CV routes + // Public versioned CV routes (language-specific must come before generic) + publicApp.get('/v/:slug/:lang', (req, res) => { serveDatasetPage(req, res, req.params.lang); }); publicApp.get('/v/:slug', (req, res) => { serveDatasetPage(req, res); }); + publicApp.get('/api/datasets/slug/:slug/:lang', (req, res) => { serveDatasetData(req, res); }); publicApp.get('/api/datasets/slug/:slug', (req, res) => { serveDatasetData(req, res); }); publicApp.get('*', (req, res) => { servePublicIndex(req, res); }); diff --git a/tests/backend.test.js b/tests/backend.test.js index 29195044..ff75dba7 100644 --- a/tests/backend.test.js +++ b/tests/backend.test.js @@ -736,6 +736,217 @@ describe('Backend API', () => { assert.strictEqual(data.is_public, true); }); + // --- Dataset Language Variants --- + it('POST /api/datasets with language creates dataset with language fields', async () => { + const res = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Lang Test', language: 'en' }), + }); + assert.strictEqual(res.status, 200); + const data = await res.json(); + assert.ok(data.id); + assert.strictEqual(data.language, 'en'); + assert.ok(data.language_group); + // Clean up + await fetch(`${BASE_URL}/api/datasets/${data.id}`, { method: 'DELETE' }); + }); + + it('creates language sibling sharing slug and group', async () => { + // Create base dataset + const res1 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Multi Lang', language: 'en' }), + }); + const d1 = await res1.json(); + + // Create sibling in same group + const res2 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Multi Lang', language: 'de', language_group: d1.language_group }), + }); + const d2 = await res2.json(); + assert.ok(d2.id); + assert.strictEqual(d2.language, 'de'); + assert.strictEqual(d2.language_group, d1.language_group); + assert.strictEqual(d2.slug, d1.slug); // Share slug + + // Clean up + await fetch(`${BASE_URL}/api/datasets/${d2.id}`, { method: 'DELETE' }); + await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); + }); + + it('rejects duplicate language in group', async () => { + const res1 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Dup Test', language: 'en' }), + }); + const d1 = await res1.json(); + + const res2 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Dup Test', language: 'en', language_group: d1.language_group }), + }); + assert.strictEqual(res2.status, 400); + + await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); + }); + + it('GET /api/datasets returns language fields', async () => { + const res = await fetch(`${BASE_URL}/api/datasets`); + const datasets = await res.json(); + assert.ok(Array.isArray(datasets)); + if (datasets.length > 0) { + assert.ok('language' in datasets[0]); + assert.ok('language_group' in datasets[0]); + } + }); + + it('GET /api/datasets/:id/siblings returns group members', async () => { + const res1 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Sib Test', language: 'en' }), + }); + const d1 = await res1.json(); + + const res2 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Sib Test', language: 'fr', language_group: d1.language_group }), + }); + const d2 = await res2.json(); + + const sibRes = await fetch(`${BASE_URL}/api/datasets/${d1.id}/siblings`); + const siblings = await sibRes.json(); + assert.ok(Array.isArray(siblings)); + assert.strictEqual(siblings.length, 2); + assert.ok(siblings.some(s => s.language === 'en')); + assert.ok(siblings.some(s => s.language === 'fr')); + + await fetch(`${BASE_URL}/api/datasets/${d2.id}`, { method: 'DELETE' }); + await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); + }); + + it('set default applies to entire language group', async () => { + const res1 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Def Group', language: 'en' }), + }); + const d1 = await res1.json(); + + const res2 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Def Group', language: 'de', language_group: d1.language_group }), + }); + const d2 = await res2.json(); + + // Set d1 as default + await fetch(`${BASE_URL}/api/datasets/${d1.id}/default`, { method: 'PUT' }); + + // Check both are default + const listRes = await fetch(`${BASE_URL}/api/datasets`); + const all = await listRes.json(); + const g1 = all.find(d => d.id === d1.id); + const g2 = all.find(d => d.id === d2.id); + assert.strictEqual(g1.is_default, true); + assert.strictEqual(g2.is_default, true); + + // Create another dataset and set it as default to allow cleanup + const res3 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Other Default' }), + }); + const d3 = await res3.json(); + await fetch(`${BASE_URL}/api/datasets/${d3.id}/default`, { method: 'PUT' }); + + await fetch(`${BASE_URL}/api/datasets/${d2.id}`, { method: 'DELETE' }); + await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); + await fetch(`${BASE_URL}/api/datasets/${d3.id}`, { method: 'DELETE' }); + }); + + it('toggle public applies to entire language group', async () => { + const res1 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Pub Group', language: 'en' }), + }); + const d1 = await res1.json(); + + const res2 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Pub Group', language: 'es', language_group: d1.language_group }), + }); + const d2 = await res2.json(); + + // Toggle public on d1 + await fetch(`${BASE_URL}/api/datasets/${d1.id}/public`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_public: true }), + }); + + // Check both are public + const listRes = await fetch(`${BASE_URL}/api/datasets`); + const all = await listRes.json(); + const g1 = all.find(d => d.id === d1.id); + const g2 = all.find(d => d.id === d2.id); + assert.strictEqual(g1.is_public, true); + assert.strictEqual(g2.is_public, true); + + await fetch(`${BASE_URL}/api/datasets/${d2.id}`, { method: 'DELETE' }); + await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); + }); + + it('structural propagation syncs section order to siblings', async () => { + const res1 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Prop Test', language: 'en' }), + }); + const d1 = await res1.json(); + + const res2 = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Prop Test', language: 'de', language_group: d1.language_group }), + }); + const d2 = await res2.json(); + + // Modify section order (reorder sections on live data) + await fetch(`${BASE_URL}/api/sections/order`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sections: [ + { key: 'skills', visible: true, sort_order: 0 }, + { key: 'about', visible: true, sort_order: 1 }, + { key: 'experience', visible: false, sort_order: 2 }, + ] }), + }); + + // Save d1 (triggers propagation) + await fetch(`${BASE_URL}/api/datasets/${d1.id}/save`, { method: 'POST' }); + + // Check d2 got the structural update + const slugRes = await fetch(`${BASE_URL}/api/datasets/slug/${d2.slug}/de`); + const d2Data = await slugRes.json(); + if (d2Data.sectionOrder) { + const skills = d2Data.sectionOrder.find(s => s.key === 'skills'); + if (skills) assert.strictEqual(skills.sort_order, 0); + } + + await fetch(`${BASE_URL}/api/datasets/${d2.id}`, { method: 'DELETE' }); + await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); + }); + // --- Custom Sections --- it('custom sections lifecycle: create, get, update, add items, delete', async () => { // Create section diff --git a/version.json b/version.json index 8bba13ee..83cbb7cf 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.27.0", + "version": "1.28.0", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From 387b3a85cc1a41c970b3285c715c992eb756b531 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 06:55:02 +0000 Subject: [PATCH 02/43] Fix language switcher UX: locale sync, toolbar integration, public pill redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Switching dataset language now also changes the UI locale (headers, sections, menus) to match — both in admin and public mode. 2. Toolbar language picker greys out languages not in the active dataset's language group with a hint to use Save As for new ones. Clicking an available language in the toolbar switches to that dataset variant (equivalent to the banner switcher). 3. Public language pill moved to top-right corner with country flag emoji next to the ISO code instead of the translate icon. Dropdown also shows flags per language. 4. "Add language" button moved to sub-version level (each dataset row) instead of parent group level in the Save As modal. 5. Added flag emoji property to I18n.languages entries. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public-readonly/index.html | 26 ++++++++---- public/shared/admin.css | 18 +++++++++ public/shared/admin.js | 83 +++++++++++++++++++++++++++++--------- public/shared/i18n.js | 16 ++++---- public/shared/styles.css | 46 +++++++++++++++------ 5 files changed, 143 insertions(+), 46 deletions(-) diff --git a/public-readonly/index.html b/public-readonly/index.html index 429001a8..de5d8199 100644 --- a/public-readonly/index.html +++ b/public-readonly/index.html @@ -23,7 +23,7 @@ - - ${addLangBtn(ds)} +
+ + ${addLangBtn(ds)} +
`; } const children = group.items.map(ds => { const escName = escapeHtml(ds.name); return ` - +
+ + ${addLangBtn(ds)} +
`; }).join(''); const countLabel = escapeHtml(t('datasets.versions_count', { count: group.items.length })); - // Find a representative item for the add-language button - const repDs = group.items[0]; return `
@@ -2903,7 +2911,6 @@ function renderSaveAsList(datasets) { add ${newVersionLabel} - ${addLangBtn(repDs)}
`; @@ -3280,6 +3287,10 @@ async function loadDataset(id, name) { activeDatasetLanguage = result.language || 'en'; activeDatasetLanguageGroup = result.language_group || null; await loadActiveDatasetSiblings(); + // Sync UI locale to match dataset language + if (typeof I18n !== 'undefined' && activeDatasetLanguage && I18n.locale !== activeDatasetLanguage) { + await I18n.setLocale(activeDatasetLanguage); + } closeDatasetsModal(); await initAdmin(); toast(t('toast.dataset_loaded', { name: result.name })); @@ -3612,18 +3623,52 @@ function renderLanguageGrid() { const container = document.getElementById('toolbarLanguageGrid'); if (!container) return; - container.innerHTML = I18n.languages.map(lang => ` -
+ // Determine available languages based on active dataset's language group + const hasMultiLang = activeDatasetSiblings.length > 0; + const groupLangs = new Set(); + if (hasMultiLang) { + groupLangs.add(activeDatasetLanguage || 'en'); + activeDatasetSiblings.forEach(s => groupLangs.add(s.language)); + } + + let html = I18n.languages.map(lang => { + const isCurrent = I18n.locale === lang.code; + const isDisabled = hasMultiLang && !groupLangs.has(lang.code); + const flag = lang.flag || ''; + return ` +
-
${lang.native}
+
${flag} ${lang.native}
${lang.name}
- `).join(''); + `; + }).join(''); + + if (hasMultiLang) { + html += `
${escapeHtml(t('datasets.add_language_hint'))}
`; + } + + container.innerHTML = html; } -// Handle language selection +// Handle language selection from toolbar picker async function selectLanguage(code) { + // If a multi-language dataset is active, switch to that language variant + if (activeDatasetSiblings.length > 0) { + const sibling = activeDatasetSiblings.find(s => s.language === code); + if (sibling) { + await switchDatasetLanguage(sibling.id, sibling.name, code); + return; + } + // If the code matches the active dataset language, just switch locale + if (code === activeDatasetLanguage) { + await I18n.setLocale(code); + renderLanguageGrid(); + document.getElementById('languagePickerDropdown').classList.remove('active'); + return; + } + } await I18n.setLocale(code); renderLanguageGrid(); document.getElementById('languagePickerDropdown').classList.remove('active'); diff --git a/public/shared/i18n.js b/public/shared/i18n.js index 898ead5a..6d8fbfb8 100644 --- a/public/shared/i18n.js +++ b/public/shared/i18n.js @@ -7,14 +7,14 @@ const I18n = { loaded: false, languages: [ - { code: 'en', name: 'English', native: 'English' }, - { code: 'de', name: 'German', native: 'Deutsch' }, - { code: 'fr', name: 'French', native: 'Français' }, - { code: 'nl', name: 'Dutch', native: 'Nederlands' }, - { code: 'es', name: 'Spanish', native: 'Español' }, - { code: 'it', name: 'Italian', native: 'Italiano' }, - { code: 'pt', name: 'Portuguese', native: 'Português' }, - { code: 'zh', name: 'Chinese', native: '中文' } + { code: 'en', name: 'English', native: 'English', flag: '🇬🇧' }, + { code: 'de', name: 'German', native: 'Deutsch', flag: '🇩🇪' }, + { code: 'fr', name: 'French', native: 'Français', flag: '🇫🇷' }, + { code: 'nl', name: 'Dutch', native: 'Nederlands', flag: '🇳🇱' }, + { code: 'es', name: 'Spanish', native: 'Español', flag: '🇪🇸' }, + { code: 'it', name: 'Italian', native: 'Italiano', flag: '🇮🇹' }, + { code: 'pt', name: 'Portuguese', native: 'Português', flag: '🇵🇹' }, + { code: 'zh', name: 'Chinese', native: '中文', flag: '🇨🇳' } ], async init() { diff --git a/public/shared/styles.css b/public/shared/styles.css index b17cb64e..874a00e0 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -1139,38 +1139,42 @@ body { vertical-align: middle; } -/* Public language switcher */ +/* Public language switcher — top-right pill with flag */ .public-lang-switcher { position: fixed; - bottom: 24px; - right: 84px; + top: 16px; + right: 16px; z-index: 1000; } .public-lang-switch-btn { display: flex; align-items: center; gap: 6px; - padding: 10px 16px; + padding: 8px 14px; border-radius: 24px; - background: var(--primary); - color: var(--white); - border: none; + background: var(--white); + color: var(--gray-700); + border: 1px solid var(--gray-200); cursor: pointer; font-size: 13px; font-weight: 600; - box-shadow: 0 4px 12px rgba(0, 102, 255, 0.3); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: all 0.2s ease; } .public-lang-switch-btn:hover { - background: var(--primary-dark); - transform: scale(1.05); + border-color: var(--primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} +.public-lang-flag { + font-size: 16px; + line-height: 1; } .public-lang-dropdown { display: none; position: absolute; - bottom: calc(100% + 8px); + top: calc(100% + 8px); right: 0; - min-width: 160px; + min-width: 170px; background: var(--white); border: 1px solid var(--gray-200); border-radius: 8px; @@ -1278,6 +1282,24 @@ body { /* Save As modal — add language button */ .save-as-newlang-btn { margin-left: 4px; + flex-shrink: 0; +} + +/* Save As modal — version child row with add-language */ +.save-as-version-child-row { + display: flex; + align-items: center; + gap: 4px; +} +.save-as-version-child-row .save-as-version-child { + flex: 1; + min-width: 0; +} +.save-as-row-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; } /* Preview Banner (for versioned dataset URLs) */ From cdb83b831b8125a229ec293adbf6b4b33555e0b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 06:59:50 +0000 Subject: [PATCH 03/43] Collapse older dataset versions and make public visibility per-language 1. Collapsible version groups: In both Open and Save As modals, only the latest version is shown when a dataset has multiple versions. Older versions are hidden behind an expandable "N older versions" toggle that can be clicked to reveal them. 2. Per-language public visibility: The public/shared toggle now applies to individual language variants instead of the entire group. Users can choose exactly which languages to make publicly accessible on the public site. New language variants start private by default. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/shared/admin.css | 20 +++++++++++++ public/shared/admin.js | 65 ++++++++++++++++++++++++++++++++++++----- src/server.js | 10 ++----- tests/backend.test.js | 12 ++++---- 4 files changed, 87 insertions(+), 20 deletions(-) diff --git a/public/shared/admin.css b/public/shared/admin.css index 81dbe684..4c2c60ce 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -2163,6 +2163,26 @@ font-size: 12px; color: var(--gray-500, #6b7280); } +/* Collapsible older versions toggle */ +.version-collapse-toggle { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--gray-500); + padding: 4px 8px; + margin: 2px 0 2px 18px; + cursor: pointer; +} +.version-collapse-toggle:hover { + color: var(--primary); +} +.version-collapsed-group { + border-top: 1px dashed var(--gray-200); + margin-top: 2px; + padding-top: 2px; +} + .language-option-disabled { opacity: 0.35; cursor: default; diff --git a/public/shared/admin.js b/public/shared/admin.js index 21d0f76a..94d5f915 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2884,7 +2884,21 @@ function renderSaveAsList(datasets) {
`; } - const children = group.items.map(ds => { + // Latest version shown, older ones collapsed + const latest = group.items[group.items.length - 1]; + const older = group.items.slice(0, -1); + const latestName = escapeHtml(latest.name); + const latestHtml = ` +
+ + ${addLangBtn(latest)} +
`; + const olderHtml = older.map(ds => { const escName = escapeHtml(ds.name); return `
@@ -2895,17 +2909,26 @@ function renderSaveAsList(datasets) { ${formatDateTime(ds.updated_at)} ${addLangBtn(ds)} -
- `; +
`; }).join(''); const countLabel = escapeHtml(t('datasets.versions_count', { count: group.items.length })); + const olderCount = older.length; return `
${escBase} ${countLabel}
-
${children}
+
+ ${latestHtml} + ${olderCount > 0 ? ` + + + ` : ''} +
`; }).join(''); } +// Toggle older versions visibility in both modals +function toggleOlderVersions(btn) { + const container = btn.nextElementSibling; + if (!container) return; + const isHidden = container.style.display === 'none'; + container.style.display = isHidden ? '' : 'none'; + const icon = btn.querySelector('.material-symbols-outlined'); + if (icon) icon.textContent = isHidden ? 'expand_less' : 'expand_more'; + const label = btn.querySelector('span:last-child'); + if (label) { + const text = label.textContent; + label.textContent = isHidden ? text.replace('older', 'older') : text; + } +} + // Preview dataset in new tab (admin only) function previewDataset(slug) { window.open(`/v/${slug}`, '_blank'); diff --git a/src/server.js b/src/server.js index efeec92a..ebdc9c6d 100644 --- a/src/server.js +++ b/src/server.js @@ -1896,7 +1896,7 @@ if (PUBLIC_ONLY) { const dupLang = db.prepare('SELECT id FROM saved_datasets WHERE language_group = ? AND language = ?').get(languageGroup, language); if (dupLang) return res.status(400).json({ error: 'This language already exists in the group' }); slug = sibling.slug; - isPublic = sibling.is_public || 0; + isPublic = 0; // New language variants start private; user chooses which to make public isDefault = sibling.is_default || 0; } else { languageGroup = crypto.randomUUID(); @@ -1971,17 +1971,13 @@ if (PUBLIC_ONLY) { } catch (err) { res.status(500).json({ error: err.message }); } }); - // Toggle dataset public visibility — applies to entire language group + // Toggle dataset public visibility — per individual language variant app.put('/api/datasets/:id/public', (req, res) => { const { is_public } = req.body; try { const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!ds) return res.status(404).json({ error: 'Dataset not found' }); - if (ds.language_group) { - db.prepare('UPDATE saved_datasets SET is_public = ? WHERE language_group = ?').run(is_public ? 1 : 0, ds.language_group); - } else { - db.prepare('UPDATE saved_datasets SET is_public = ? WHERE id = ?').run(is_public ? 1 : 0, req.params.id); - } + db.prepare('UPDATE saved_datasets SET is_public = ? WHERE id = ?').run(is_public ? 1 : 0, req.params.id); const updated = db.prepare('SELECT id, name, slug, is_public, is_default FROM saved_datasets WHERE id = ?').get(req.params.id); res.json({ success: true, id: updated.id, slug: updated.slug, is_public: !!updated.is_public, is_default: !!updated.is_default }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/tests/backend.test.js b/tests/backend.test.js index ff75dba7..81941e1b 100644 --- a/tests/backend.test.js +++ b/tests/backend.test.js @@ -872,35 +872,35 @@ describe('Backend API', () => { await fetch(`${BASE_URL}/api/datasets/${d3.id}`, { method: 'DELETE' }); }); - it('toggle public applies to entire language group', async () => { + it('toggle public applies per individual language variant', async () => { const res1 = await fetch(`${BASE_URL}/api/datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Pub Group', language: 'en' }), + body: JSON.stringify({ name: 'Pub Indiv', language: 'en' }), }); const d1 = await res1.json(); const res2 = await fetch(`${BASE_URL}/api/datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Pub Group', language: 'es', language_group: d1.language_group }), + body: JSON.stringify({ name: 'Pub Indiv', language: 'es', language_group: d1.language_group }), }); const d2 = await res2.json(); - // Toggle public on d1 + // Toggle public on d1 only await fetch(`${BASE_URL}/api/datasets/${d1.id}/public`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_public: true }), }); - // Check both are public + // d1 should be public, d2 should remain private const listRes = await fetch(`${BASE_URL}/api/datasets`); const all = await listRes.json(); const g1 = all.find(d => d.id === d1.id); const g2 = all.find(d => d.id === d2.id); assert.strictEqual(g1.is_public, true); - assert.strictEqual(g2.is_public, true); + assert.strictEqual(g2.is_public, false); await fetch(`${BASE_URL}/api/datasets/${d2.id}`, { method: 'DELETE' }); await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); From 395e2b796fe2f49638764685af128a0d3a337da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 07:11:58 +0000 Subject: [PATCH 04/43] =?UTF-8?q?Restructure=20dataset=20display:=20Datase?= =?UTF-8?q?t=E2=86=92Version=E2=86=92Language=20hierarchy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Three-level grouping: Datasets are now displayed as Dataset → Version → Language in both Open and Save As modals. Languages appear nested under each version. The "Add language" button sits at version level (or dataset level if single version). 2. Remove flag emojis from I18n.languages — reverted to plain text. Public language pill now uses round flagcdn.com images (same source as timeline country flags) with border-radius: 50% for a circular appearance. Language-to-country-code mapping added to scripts.js. 3. Save As modal: Cancel and Save/Overwrite buttons moved inline next to the name input and language dropdown for a more intuitive layout. The modal footer is removed. 4. Older versions remain collapsible behind an "N older versions" toggle in both modals. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public-readonly/index.html | 20 ++-- public/index.html | 31 +++--- public/shared/admin.css | 61 ++++++++++- public/shared/admin.js | 218 +++++++++++++++++++++---------------- public/shared/i18n.js | 16 +-- public/shared/scripts.js | 16 +++ public/shared/styles.css | 14 ++- 7 files changed, 238 insertions(+), 138 deletions(-) diff --git a/public-readonly/index.html b/public-readonly/index.html index de5d8199..fac2437d 100644 --- a/public-readonly/index.html +++ b/public-readonly/index.html @@ -917,28 +917,22 @@

${escapeHtml(proj.title)}

const currentLang = (window.DATASET_LANG || 'en').toLowerCase(); if (label) label.textContent = currentLang.toUpperCase(); - // Build language info maps from I18n - const langInfo = {}; + // Build language name map from I18n + const langNames = {}; if (typeof I18n !== 'undefined' && I18n.languages) { - I18n.languages.forEach(l => { langInfo[l.code] = l; }); + I18n.languages.forEach(l => { langNames[l.code] = l.native; }); } - // Set flag on the pill button - const currentInfo = langInfo[currentLang]; - if (flagEl && currentInfo && currentInfo.flag) { - flagEl.textContent = currentInfo.flag; - } + // Set round flag on the pill button using flagcdn + if (flagEl) flagEl.innerHTML = langFlagImg(currentLang, 18); // Build dropdown items let html = ''; for (const sib of siblings) { const lang = (sib.language || 'en').toLowerCase(); const isActive = lang === currentLang; - const info = langInfo[lang] || {}; - const name = info.native || lang.toUpperCase(); - const flag = info.flag || ''; + const name = langNames[lang] || lang.toUpperCase(); - // Build URL for this language variant let href; if (window.DATASET_SLUG) { href = `/v/${window.DATASET_SLUG}/${lang}`; @@ -947,7 +941,7 @@

${escapeHtml(proj.title)}

} html += `
- ${flag} + ${langFlagImg(lang, 16)} ${lang.toUpperCase()} ${name} ${isActive ? 'check' : ''} diff --git a/public/index.html b/public/index.html index 0218734a..50b0dd72 100644 --- a/public/index.html +++ b/public/index.html @@ -349,28 +349,27 @@ diff --git a/public/shared/admin.css b/public/shared/admin.css index 4c2c60ce..ec697071 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -1047,8 +1047,17 @@ .btn-warning { background: #ea580c; color: var(--white); } .btn-warning:hover { background: #c2410c; transform: translateY(-1px); } +/* Save As — inline row: name + language + action buttons */ +.save-as-name-row { + display: flex; + align-items: flex-end; + gap: 10px; + margin-bottom: 10px; + flex-wrap: wrap; +} .save-as-name-field { - margin-bottom: 18px; + flex: 1; + min-width: 180px; } .save-as-name-label { display: block; @@ -1074,8 +1083,14 @@ border-color: var(--primary); box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.15); } +.save-as-name-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + padding-bottom: 1px; +} .save-as-hint { - margin-top: 6px; + margin-bottom: 12px; font-size: 12px; color: var(--gray-500); } @@ -1217,6 +1232,48 @@ .save-as-version-child .save-as-row-name { flex: 1; } .save-as-version-child .save-as-row-date { margin-left: auto; } +/* Save As — Version row with nested language children */ +.save-as-version-row { + padding: 4px 0; +} +.save-as-version-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; +} +.save-as-version-name { + font-size: 13px; + font-weight: 500; + color: var(--gray-700); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.save-as-version-langs { + padding-left: 8px; +} +.save-as-lang-child { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 12px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + font-family: inherit; + font-size: 12px; + color: var(--gray-600); + transition: background 0.15s ease; + border-radius: var(--radius-sm); +} +.save-as-lang-child:hover { background: var(--gray-100); } +.save-as-lang-child.save-as-row-selected { background: rgba(0, 102, 255, 0.08); } + .dataset-version-badge { display: inline-flex; align-items: center; diff --git a/public/shared/admin.js b/public/shared/admin.js index 94d5f915..06d95e39 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2779,6 +2779,33 @@ function groupDatasetsByBase(datasets) { return groups; } +// Group datasets into a 3-level hierarchy: Dataset → Version → Language. +// Returns [{ base, versions: [{ version, name, languages: [ds...], language_group }] }] +function groupDatasetsHierarchy(datasets) { + const baseMap = new Map(); + for (const ds of datasets) { + const { base, version } = parseDatasetVersion(ds.name); + if (!baseMap.has(base)) baseMap.set(base, new Map()); + const versionMap = baseMap.get(base); + if (!versionMap.has(version)) versionMap.set(version, []); + versionMap.get(version).push({ ...ds, _version: version, _base: base }); + } + const groups = Array.from(baseMap.entries()).map(([base, versionMap]) => { + const versions = Array.from(versionMap.entries()).map(([version, langs]) => { + langs.sort((a, b) => (a.language || 'en').localeCompare(b.language || 'en')); + return { version, name: langs[0].name, languages: langs, language_group: langs[0].language_group }; + }); + versions.sort((a, b) => a.version - b.version); + const latest = versions.reduce((max, v) => { + const t = v.languages.reduce((m, ds) => Math.max(m, new Date(ds.updated_at).getTime()), 0); + return Math.max(max, t); + }, 0); + return { base, versions, latestUpdated: latest }; + }); + groups.sort((a, b) => b.latestUpdated - a.latestUpdated); + return groups; +} + // Given a base name and the current dataset list, return the next free "Base vN". function suggestNextVersion(baseName, datasets) { let max = 0; @@ -2826,93 +2853,79 @@ function renderSaveAsList(datasets) { container.innerHTML = `

${escapeHtml(t('datasets.no_existing'))}

`; return; } - const groups = groupDatasetsByBase(datasets); + const hierarchy = groupDatasetsHierarchy(datasets); const newVersionLabel = escapeHtml(t('datasets.new_version')); const addLanguageLabel = escapeHtml(t('datasets.add_language')); const lastUpdatedLabel = escapeHtml(t('datasets.last_updated')); - // Collect languages per language_group for "Add language" buttons - const groupLanguages = {}; - for (const ds of datasets) { - if (ds.language_group) { - if (!groupLanguages[ds.language_group]) groupLanguages[ds.language_group] = new Set(); - groupLanguages[ds.language_group].add(ds.language || 'en'); - } - } - - function langBadge(ds) { - const lg = ds.language_group; - if (lg && groupLanguages[lg] && groupLanguages[lg].size > 1) { - return `${(ds.language || 'en').toUpperCase()}`; - } - return ''; - } - - function addLangBtn(ds) { - if (!ds.language_group) return ''; - const existing = groupLanguages[ds.language_group]; - if (!existing) return ''; + function addLangBtn(ver) { + if (!ver.language_group) return ''; + const existingLangs = new Set(ver.languages.map(d => d.language || 'en')); const allLangs = (typeof I18n !== 'undefined' ? I18n.languages : []).map(l => l.code); - const available = allLangs.filter(c => !existing.has(c)); + const available = allLangs.filter(c => !existingLangs.has(c)); if (available.length === 0) return ''; - return ``; } - container.innerHTML = groups.map(group => { + function renderLangRows(ver) { + if (ver.languages.length <= 1) { + // Single language — just show the dataset row + const ds = ver.languages[0]; + return ``; + } + // Multiple languages — show each with a lang badge + return ver.languages.map(ds => ` + + `).join(''); + } + + function renderVersion(ver, showVersionBadge) { + const badge = showVersionBadge ? `v${ver.version}` : ''; + return ` +
+
+ ${badge} + ${escapeHtml(ver.name)} + ${addLangBtn(ver)} +
+
${renderLangRows(ver)}
+
`; + } + + container.innerHTML = hierarchy.map(group => { const escBase = escapeHtml(group.base); - if (group.items.length === 1) { - const ds = group.items[0]; - const escName = escapeHtml(ds.name); + const hasMultipleVersions = group.versions.length > 1; + + if (!hasMultipleVersions) { + // Single version — render standalone + const ver = group.versions[0]; return `
- + ${renderVersion(ver, false)}
- ${addLangBtn(ds)} + ${!ver.language_group ? '' : addLangBtn(ver)}
-
- `; - } - // Latest version shown, older ones collapsed - const latest = group.items[group.items.length - 1]; - const older = group.items.slice(0, -1); - const latestName = escapeHtml(latest.name); - const latestHtml = ` -
- - ${addLangBtn(latest)} -
`; - const olderHtml = older.map(ds => { - const escName = escapeHtml(ds.name); - return ` -
- - ${addLangBtn(ds)}
`; - }).join(''); - const countLabel = escapeHtml(t('datasets.versions_count', { count: group.items.length })); + } + + // Multiple versions — show latest, collapse older + const latest = group.versions[group.versions.length - 1]; + const older = group.versions.slice(0, -1); const olderCount = older.length; + const countLabel = escapeHtml(t('datasets.versions_count', { count: group.versions.length })); return `
@@ -2920,13 +2933,15 @@ function renderSaveAsList(datasets) { ${countLabel}
- ${latestHtml} + ${renderVersion(latest, true)} ${olderCount > 0 ? ` - + ` : ''}
-
- `; + `; }).join(''); } @@ -3173,32 +3187,48 @@ async function loadDatasetsList() { return; } - // Detect language groups with multiple languages to show badges - const langGroupCounts = {}; - for (const ds of datasets) { - if (ds.language_group) { - if (!langGroupCounts[ds.language_group]) langGroupCounts[ds.language_group] = new Set(); - langGroupCounts[ds.language_group].add(ds.language || 'en'); - } + const hierarchy = groupDatasetsHierarchy(datasets); + + function renderVersionBlock(ver, showVersionBadge) { + const showLang = ver.languages.length > 1; + const rows = ver.languages.map(ds => + renderDatasetOpenRow(ds, { isChild: true, versionBadge: showVersionBadge ? ds._version : null, showLangBadge: showLang }) + ).join(''); + return rows; } - const multiLangGroups = new Set(Object.entries(langGroupCounts).filter(([, s]) => s.size > 1).map(([k]) => k)); - const groups = groupDatasetsByBase(datasets); - container.innerHTML = groups.map(group => { - const showLang = group.items.some(ds => multiLangGroups.has(ds.language_group)); - if (group.items.length === 1) { - // Standalone dataset — render as today, no grouping chrome. - return renderDatasetOpenRow(group.items[0], { showLangBadge: showLang }); + container.innerHTML = hierarchy.map(group => { + const hasMultipleVersions = group.versions.length > 1; + + if (!hasMultipleVersions) { + const ver = group.versions[0]; + if (ver.languages.length === 1) { + // Single version, single language — standalone row + return renderDatasetOpenRow(ver.languages[0]); + } + // Single version, multiple languages — group with lang badges + const rows = ver.languages.map(ds => + renderDatasetOpenRow(ds, { isChild: true, showLangBadge: true }) + ).join(''); + return ` +
+
+ ${escapeHtml(group.base)} + ${escapeHtml(t('datasets.languages_count', { count: ver.languages.length }))} +
+
${rows}
+
`; } - // Multi-version group — show latest, collapse older versions. - const countLabel = escapeHtml(t('datasets.versions_count', { count: group.items.length })); - const latest = group.items[group.items.length - 1]; - const older = group.items.slice(0, -1); - const latestHtml = renderDatasetOpenRow(latest, { isChild: true, versionBadge: latest._version, showLangBadge: showLang }); - const olderHtml = older.map(ds => - renderDatasetOpenRow(ds, { isChild: true, versionBadge: ds._version, showLangBadge: showLang }) - ).join(''); + + // Multiple versions — show latest, collapse older + const latest = group.versions[group.versions.length - 1]; + const older = group.versions.slice(0, -1); const olderCount = older.length; + const countLabel = escapeHtml(t('datasets.versions_count', { count: group.versions.length })); + + const latestHtml = renderVersionBlock(latest, true); + const olderHtml = older.map(v => renderVersionBlock(v, true)).join(''); + return `
@@ -3215,8 +3245,7 @@ async function loadDatasetsList() { ` : ''}
-
- `; + `; }).join(''); } @@ -3685,11 +3714,10 @@ function renderLanguageGrid() { let html = I18n.languages.map(lang => { const isCurrent = I18n.locale === lang.code; const isDisabled = hasMultiLang && !groupLangs.has(lang.code); - const flag = lang.flag || ''; return `
-
${flag} ${lang.native}
+
${lang.native}
${lang.name}
diff --git a/public/shared/i18n.js b/public/shared/i18n.js index 6d8fbfb8..898ead5a 100644 --- a/public/shared/i18n.js +++ b/public/shared/i18n.js @@ -7,14 +7,14 @@ const I18n = { loaded: false, languages: [ - { code: 'en', name: 'English', native: 'English', flag: '🇬🇧' }, - { code: 'de', name: 'German', native: 'Deutsch', flag: '🇩🇪' }, - { code: 'fr', name: 'French', native: 'Français', flag: '🇫🇷' }, - { code: 'nl', name: 'Dutch', native: 'Nederlands', flag: '🇳🇱' }, - { code: 'es', name: 'Spanish', native: 'Español', flag: '🇪🇸' }, - { code: 'it', name: 'Italian', native: 'Italiano', flag: '🇮🇹' }, - { code: 'pt', name: 'Portuguese', native: 'Português', flag: '🇵🇹' }, - { code: 'zh', name: 'Chinese', native: '中文', flag: '🇨🇳' } + { code: 'en', name: 'English', native: 'English' }, + { code: 'de', name: 'German', native: 'Deutsch' }, + { code: 'fr', name: 'French', native: 'Français' }, + { code: 'nl', name: 'Dutch', native: 'Nederlands' }, + { code: 'es', name: 'Spanish', native: 'Español' }, + { code: 'it', name: 'Italian', native: 'Italiano' }, + { code: 'pt', name: 'Portuguese', native: 'Português' }, + { code: 'zh', name: 'Chinese', native: '中文' } ], async init() { diff --git a/public/shared/scripts.js b/public/shared/scripts.js index 269d55b8..41ffbc3f 100644 --- a/public/shared/scripts.js +++ b/public/shared/scripts.js @@ -1,5 +1,21 @@ /* CV Manager - Shared JavaScript */ +// Map ISO 639-1 language codes to ISO 3166-1 alpha-2 country codes for flag images +const LANG_TO_COUNTRY = { + en: 'gb', de: 'de', fr: 'fr', nl: 'nl', es: 'es', it: 'it', pt: 'pt', zh: 'cn', + ja: 'jp', ko: 'kr', ar: 'sa', hi: 'in', ru: 'ru', pl: 'pl', sv: 'se', da: 'dk', + fi: 'fi', no: 'no', cs: 'cz', ro: 'ro', hu: 'hu', el: 'gr', tr: 'tr', uk: 'ua', + th: 'th', vi: 'vn', id: 'id', ms: 'my', he: 'il', bg: 'bg', hr: 'hr', sk: 'sk', + sl: 'si', sr: 'rs', lt: 'lt', lv: 'lv', et: 'ee', ca: 'es', gl: 'es' +}; +function langToCountryCode(lang) { return LANG_TO_COUNTRY[(lang || '').toLowerCase()] || null; } +function langFlagImg(lang, size) { + const cc = langToCountryCode(lang); + if (!cc) return ''; + const s = size || 20; + return `${(lang || '').toUpperCase()}`; +} + // Material Icon helper function function materialIcon(name, size = 16) { return ``; diff --git a/public/shared/styles.css b/public/shared/styles.css index 874a00e0..5d1a3fa5 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -1166,9 +1166,15 @@ body { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .public-lang-flag { - font-size: 16px; + display: inline-flex; + align-items: center; line-height: 1; } +.lang-flag-img { + border-radius: 50%; + object-fit: cover; + vertical-align: middle; +} .public-lang-dropdown { display: none; position: absolute; @@ -1264,10 +1270,10 @@ body { /* Save As modal — language field */ .save-as-language-field { - margin-top: 12px; display: flex; - align-items: center; - gap: 10px; + flex-direction: column; + gap: 0; + flex-shrink: 0; } .save-as-lang-select { flex: 1; From 1b8c87f124b07ad1912e8ff3845c31a004f2004d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 07:39:02 +0000 Subject: [PATCH 05/43] Fix default dataset language routing and public pill styling 1. Default CV now serves the correct language variant when multiple languages are available. Added /:lang([a-z]{2}) route on the public server so visitors can access /en, /de, /fr etc. directly. 2. Open modal: default datasets with multiple languages now show /{lang} URLs instead of hiding the URL. Non-default datasets continue to show /v/{slug}/{lang}. 3. Public language pill: uses the same styling as the print button (primary background, white text, matching shadow). Round flagcdn country flag images displayed both in the pill and dropdown items. Active dropdown item uses primary background with white text. 4. Language switcher URLs for default datasets use clean /{lang} paths instead of /?lang=xx. Server injects DATASET_IS_DEFAULT flag so the client-side switcher builds the correct URLs. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public-readonly/index.html | 7 +++++-- public/shared/admin.js | 37 ++++++++++++++++++++++++++----------- public/shared/styles.css | 17 +++++++++-------- src/server.js | 6 +++++- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/public-readonly/index.html b/public-readonly/index.html index fac2437d..bcf2d8ee 100644 --- a/public-readonly/index.html +++ b/public-readonly/index.html @@ -934,10 +934,13 @@

${escapeHtml(proj.title)}

const name = langNames[lang] || lang.toUpperCase(); let href; - if (window.DATASET_SLUG) { + if (window.DATASET_IS_DEFAULT) { + // Default dataset: use clean /{lang} URLs + href = `/${lang}`; + } else if (window.DATASET_SLUG) { href = `/v/${window.DATASET_SLUG}/${lang}`; } else { - href = `/?lang=${lang}`; + href = `/${lang}`; } html += `
diff --git a/public/shared/admin.js b/public/shared/admin.js index 06d95e39..74b804f3 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -3117,7 +3117,6 @@ function closeDatasetsModal() { function renderDatasetOpenRow(ds, opts = {}) { const isActive = ds.id === activeDatasetId; const isDefault = !!ds.is_default; - const showSlugUrl = ds.slug && !isDefault; const showPublicToggle = ds.slug && !isDefault; const classes = ['dataset-item']; if (isActive) classes.push('dataset-item-active'); @@ -3133,6 +3132,28 @@ function renderDatasetOpenRow(ds, opts = {}) { const dsLang = ds.language || 'en'; const slugUrlSuffix = opts.showLangBadge ? `/${dsLang}` : ''; + // URL display: default datasets show /{lang}, non-default show /v/{slug}/{lang} + let urlHtml = ''; + if (isDefault && opts.showLangBadge) { + // Default dataset with languages — show /{lang} + urlHtml = `
+ /${dsLang} + +
`; + } else if (isDefault) { + urlHtml = '
Served at root URL /
'; + } else if (ds.slug) { + urlHtml = `
+ /v/${escapeHtml(ds.slug)}${slugUrlSuffix} + + ${ds.is_public ? 'Public' : ''} +
`; + } + return `
@@ -3150,14 +3171,7 @@ function renderDatasetOpenRow(ds, opts = {}) { ${isActive ? 'Editing' : ''}
${escapeHtml(t('datasets.last_updated'))} ${formatDateTime(ds.updated_at)}
- ${showSlugUrl ? `
- /v/${escapeHtml(ds.slug)}${slugUrlSuffix} - - ${ds.is_public ? 'Public' : ''} -
` : ''} - ${isDefault ? '
Served at root URL /
' : ''} + ${urlHtml}
${showPublicToggle ? ` @@ -3270,9 +3284,10 @@ function previewDataset(slug) { } // Copy dataset URL to clipboard -function copyDatasetUrl(slug, isPublic) { +function copyDatasetUrl(pathOrSlug, isPublic) { // Use current origin — works for both admin preview and public site - const url = `${window.location.origin}/v/${slug}`; + const path = pathOrSlug.startsWith('v/') || pathOrSlug.length <= 2 ? pathOrSlug : `v/${pathOrSlug}`; + const url = `${window.location.origin}/${path}`; // Try modern clipboard API first if (navigator.clipboard && navigator.clipboard.writeText) { diff --git a/public/shared/styles.css b/public/shared/styles.css index 5d1a3fa5..cb96cc40 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -1152,18 +1152,19 @@ body { gap: 6px; padding: 8px 14px; border-radius: 24px; - background: var(--white); - color: var(--gray-700); - border: 1px solid var(--gray-200); + background: var(--primary); + color: var(--white); + border: none; cursor: pointer; font-size: 13px; font-weight: 600; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 12px rgba(0, 102, 255, 0.3); transition: all 0.2s ease; } .public-lang-switch-btn:hover { - border-color: var(--primary); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + background: var(--primary-dark); + transform: scale(1.05); + box-shadow: 0 6px 16px rgba(0, 102, 255, 0.4); } .public-lang-flag { display: inline-flex; @@ -1201,8 +1202,8 @@ body { } .public-lang-option:hover { background: var(--gray-50); } .public-lang-option.active { - background: var(--primary-light, rgba(0, 102, 255, 0.08)); - color: var(--primary); + background: var(--primary); + color: var(--white); font-weight: 600; } .public-lang-code { diff --git a/src/server.js b/src/server.js index ebdc9c6d..4b5c72cf 100644 --- a/src/server.js +++ b/src/server.js @@ -223,7 +223,7 @@ function servePublicIndex(req, res) { // Inject default dataset slug with language info (no DATASET_PREVIEW = no preview banner) const siblings = getDatasetSiblings(defaultDataset); - const datasetScript = ``; + const datasetScript = ``; html = html.replace('', `${datasetScript}`); return res.type('html').send(html); @@ -1240,6 +1240,8 @@ if (PUBLIC_ONLY) { publicApp.get('/v/:slug', (req, res) => { serveDatasetPage(req, res); }); publicApp.get('/api/datasets/slug/:slug/:lang', (req, res) => { serveDatasetData(req, res); }); publicApp.get('/api/datasets/slug/:slug', (req, res) => { serveDatasetData(req, res); }); + // Clean language URLs for default dataset: /en, /de, /fr, etc. + publicApp.get('/:lang([a-z]{2})', (req, res) => { req.query.lang = req.params.lang; servePublicIndex(req, res); }); publicApp.get('*', (req, res) => { servePublicIndex(req, res); }); publicApp.listen(PUBLIC_PORT, '0.0.0.0', () => { console.log(`CV Manager (Public Read-Only) running at http://localhost:${PUBLIC_PORT}`); }); @@ -2837,6 +2839,8 @@ if (PUBLIC_ONLY) { publicApp.get('/v/:slug', (req, res) => { serveDatasetPage(req, res); }); publicApp.get('/api/datasets/slug/:slug/:lang', (req, res) => { serveDatasetData(req, res); }); publicApp.get('/api/datasets/slug/:slug', (req, res) => { serveDatasetData(req, res); }); + // Clean language URLs for default dataset: /en, /de, /fr, etc. + publicApp.get('/:lang([a-z]{2})', (req, res) => { req.query.lang = req.params.lang; servePublicIndex(req, res); }); publicApp.get('*', (req, res) => { servePublicIndex(req, res); }); app.listen(PORT, '0.0.0.0', () => { console.log(`CV Manager v${CURRENT_VERSION} (Admin) running at http://localhost:${PORT}`); }); From 3ba68503ee7f965b6ac3cc4dacc52c58f43d01e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 07:46:58 +0000 Subject: [PATCH 06/43] Fix language persistence and round flag rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Admin now persists the active dataset ID in sessionStorage, so refreshing the page restores the exact dataset+language variant the user was editing — not just the first default variant. This also fixes the public site showing the wrong language after an admin edit, since the correct variant is now consistently loaded. 2. Fixed round flag images: flagcdn only serves specific widths (w20, w40, w80), so always fetch w40 and let CSS scale to the desired display size with border-radius: 50%. 3. persistActiveDataset() is called on every state change: loadDataset, submitSaveAs, switchDatasetLanguage, hideActiveDatasetBanner. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/shared/admin.js | 41 ++++++++++++++++++++++++++++++++++++---- public/shared/scripts.js | 2 +- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/public/shared/admin.js b/public/shared/admin.js index 74b804f3..e825bef9 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -194,12 +194,34 @@ function updateBannerMargins() { document.querySelector('.container').style.marginTop = (baseMargin + extraHeight) + 'px'; } -// Load default dataset on admin startup +// Load default dataset on admin startup — restores last-edited dataset if available async function loadDefaultDatasetOnStartup() { try { + // Try to restore the last-edited dataset from sessionStorage + const lastId = sessionStorage.getItem('cv_active_dataset_id'); + if (lastId) { + try { + const result = await api(`/api/datasets/${lastId}/load`, { method: 'POST' }); + if (result.success) { + activeDatasetId = result.id; + activeDatasetName = result.name; + activeDatasetIsDefault = !!result.is_default; + activeDatasetLanguage = result.language || 'en'; + activeDatasetLanguageGroup = result.language_group || null; + await loadActiveDatasetSiblings(); + // Sync UI locale to match + if (typeof I18n !== 'undefined' && activeDatasetLanguage && I18n.locale !== activeDatasetLanguage) { + await I18n.setLocale(activeDatasetLanguage); + } + await renderAdminUI(); + showActiveDatasetBanner(activeDatasetId, activeDatasetName, activeDatasetIsDefault); + return; + } + } catch (err) { /* stored dataset may have been deleted, fall through */ } + } + const defaultDs = await api('/api/datasets/default'); if (defaultDs && defaultDs.exists) { - // Load the default dataset into the working copy const result = await api(`/api/datasets/${defaultDs.id}/load`, { method: 'POST' }); if (result.success) { activeDatasetId = result.id; @@ -208,8 +230,7 @@ async function loadDefaultDatasetOnStartup() { activeDatasetLanguage = result.language || 'en'; activeDatasetLanguageGroup = result.language_group || null; await loadActiveDatasetSiblings(); - - // Render UI from the now-loaded data + persistActiveDataset(); await renderAdminUI(); showActiveDatasetBanner(activeDatasetId, activeDatasetName, activeDatasetIsDefault); } @@ -219,6 +240,15 @@ async function loadDefaultDatasetOnStartup() { } } +// Persist / clear active dataset ID in sessionStorage +function persistActiveDataset() { + if (activeDatasetId) { + sessionStorage.setItem('cv_active_dataset_id', activeDatasetId); + } else { + sessionStorage.removeItem('cv_active_dataset_id'); + } +} + // Load siblings for the active dataset async function loadActiveDatasetSiblings() { activeDatasetSiblings = []; @@ -268,6 +298,7 @@ function hideActiveDatasetBanner() { activeDatasetLanguage = null; activeDatasetLanguageGroup = null; activeDatasetSiblings = []; + persistActiveDataset(); updateBannerMargins(); } @@ -3058,6 +3089,7 @@ async function submitSaveAs() { activeDatasetLanguage = result.language || language; activeDatasetLanguageGroup = result.language_group || null; await loadActiveDatasetSiblings(); + persistActiveDataset(); showActiveDatasetBanner(activeDatasetId, activeDatasetName, activeDatasetIsDefault); closeSaveAsModal(); toast(result.created && languageGroup ? t('toast.language_variant_created') : result.updated ? t('toast.dataset_updated') : t('toast.dataset_saved')); @@ -3382,6 +3414,7 @@ async function loadDataset(id, name) { activeDatasetLanguage = result.language || 'en'; activeDatasetLanguageGroup = result.language_group || null; await loadActiveDatasetSiblings(); + persistActiveDataset(); // Sync UI locale to match dataset language if (typeof I18n !== 'undefined' && activeDatasetLanguage && I18n.locale !== activeDatasetLanguage) { await I18n.setLocale(activeDatasetLanguage); diff --git a/public/shared/scripts.js b/public/shared/scripts.js index 41ffbc3f..0a949ca3 100644 --- a/public/shared/scripts.js +++ b/public/shared/scripts.js @@ -13,7 +13,7 @@ function langFlagImg(lang, size) { const cc = langToCountryCode(lang); if (!cc) return ''; const s = size || 20; - return `${(lang || '').toUpperCase()}`; + return `${(lang || '').toUpperCase()}`; } // Material Icon helper function From f8ea3e55682d79246af8e6c22449667aab54278f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 07:54:12 +0000 Subject: [PATCH 07/43] Fix default public site to serve the most recently edited language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root URL / now serves whichever default language variant was most recently updated (by updated_at timestamp), instead of always preferring English. This means when the admin edits and saves the German variant, visitors at / immediately see the German CV. The same logic applies to /v/{slug} when no language is specified — it now serves the most recently updated variant rather than defaulting to English. Visitors can still explicitly request a language via /{lang} URLs (e.g. /en, /de) or /v/{slug}/{lang}. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- src/server.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server.js b/src/server.js index 4b5c72cf..7ec74529 100644 --- a/src/server.js +++ b/src/server.js @@ -191,7 +191,8 @@ function servePublicIndex(req, res) { defaultDataset = db.prepare('SELECT * FROM saved_datasets WHERE is_default = 1 AND language = ?').get(requestedLang); } if (!defaultDataset) { - defaultDataset = db.prepare("SELECT * FROM saved_datasets WHERE is_default = 1 ORDER BY CASE WHEN language = 'en' THEN 0 ELSE 1 END, language ASC LIMIT 1").get(); + // Serve the most recently updated default variant (reflects admin's last edit) + defaultDataset = db.prepare("SELECT * FROM saved_datasets WHERE is_default = 1 ORDER BY updated_at DESC LIMIT 1").get(); } } catch (e) { /* is_default column may not exist yet */ } @@ -996,7 +997,7 @@ function resolveDatasetBySlug(slug, lang, requirePublic) { dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ? AND language = ?${publicFilter}`).get(slug, lang); } if (!dataset) { - dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ?${publicFilter} ORDER BY CASE WHEN language = 'en' THEN 0 ELSE 1 END, language ASC LIMIT 1`).get(slug); + dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ?${publicFilter} ORDER BY updated_at DESC LIMIT 1`).get(slug); } return dataset || null; } From 72ca8663668cd05950d5ae8eb111ad66809f8e1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 08:00:09 +0000 Subject: [PATCH 08/43] Fix default to be per-variant: radio button sets exactly one dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default radio button in the Open modal now sets exactly ONE specific dataset variant as the default served at /. Previously it marked the entire language group as default. How it works now: - User clicks the radio on "My CV v2 (FR)" → only that row gets is_default = 1, all others become 0 - Root / serves that French v2 variant - Language siblings that are marked public appear in the language switcher so visitors can switch to /en, /de etc. - New language variants are NOT auto-set as default The public root / now finds THE one default variant. When a visitor requests /{lang}, it looks for a public sibling in the same group with that language. If not found, falls back to the default. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- src/server.js | 38 ++++++++++++++++++++------------------ tests/backend.test.js | 19 +++++++++++++------ 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/server.js b/src/server.js index 7ec74529..1f3573ba 100644 --- a/src/server.js +++ b/src/server.js @@ -186,13 +186,17 @@ function servePublicIndex(req, res) { // Check if a default dataset exists — serve from it instead of live DB let defaultDataset = null; try { - const requestedLang = req.query.lang; - if (requestedLang) { - defaultDataset = db.prepare('SELECT * FROM saved_datasets WHERE is_default = 1 AND language = ?').get(requestedLang); - } - if (!defaultDataset) { - // Serve the most recently updated default variant (reflects admin's last edit) - defaultDataset = db.prepare("SELECT * FROM saved_datasets WHERE is_default = 1 ORDER BY updated_at DESC LIMIT 1").get(); + // Find THE one default variant + const primary = db.prepare('SELECT * FROM saved_datasets WHERE is_default = 1').get(); + if (primary) { + const requestedLang = req.query.lang; + if (requestedLang && primary.language_group && requestedLang !== primary.language) { + // Visitor requested a different language — find that sibling (must be public) + const sibling = db.prepare('SELECT * FROM saved_datasets WHERE language_group = ? AND language = ? AND is_public = 1').get(primary.language_group, requestedLang); + defaultDataset = sibling || primary; + } else { + defaultDataset = primary; + } } } catch (e) { /* is_default column may not exist yet */ } @@ -997,15 +1001,18 @@ function resolveDatasetBySlug(slug, lang, requirePublic) { dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ? AND language = ?${publicFilter}`).get(slug, lang); } if (!dataset) { - dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ?${publicFilter} ORDER BY updated_at DESC LIMIT 1`).get(slug); + // Prefer the default variant, then most recently updated + dataset = db.prepare(`SELECT * FROM saved_datasets WHERE slug = ?${publicFilter} ORDER BY is_default DESC, updated_at DESC LIMIT 1`).get(slug); } return dataset || null; } -// Get siblings list for injecting into HTML pages +// Get siblings list for injecting into HTML pages. +// Returns the dataset itself plus any public siblings in the same language group. function getDatasetSiblings(dataset) { if (!dataset || !dataset.language_group) return []; - return db.prepare('SELECT id, language FROM saved_datasets WHERE language_group = ? AND (is_public = 1 OR is_default = 1) ORDER BY language ASC').all(dataset.language_group); + // Include: the dataset itself (always) + siblings that are public or default + return db.prepare('SELECT id, language FROM saved_datasets WHERE language_group = ? AND (is_public = 1 OR is_default = 1 OR id = ?) ORDER BY language ASC').all(dataset.language_group, dataset.id); } // Serve admin dataset preview page with language support @@ -1900,7 +1907,7 @@ if (PUBLIC_ONLY) { if (dupLang) return res.status(400).json({ error: 'This language already exists in the group' }); slug = sibling.slug; isPublic = 0; // New language variants start private; user chooses which to make public - isDefault = sibling.is_default || 0; + isDefault = 0; // Default is per-variant; user sets it explicitly via the radio button } else { languageGroup = crypto.randomUUID(); } @@ -1921,20 +1928,15 @@ if (PUBLIC_ONLY) { } catch (err) { res.status(500).json({ error: err.message }); } }); - // Set a dataset as the default (public at /) — applies to entire language group + // Set a dataset as the default (public at /) — sets this ONE specific variant app.put('/api/datasets/:id/default', (req, res) => { try { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); - // Clear any existing default, then set all group members as default (atomic) const setDefault = db.transaction(() => { db.prepare('UPDATE saved_datasets SET is_default = 0 WHERE is_default = 1').run(); - if (dataset.language_group) { - db.prepare('UPDATE saved_datasets SET is_default = 1 WHERE language_group = ?').run(dataset.language_group); - } else { - db.prepare('UPDATE saved_datasets SET is_default = 1 WHERE id = ?').run(req.params.id); - } + db.prepare('UPDATE saved_datasets SET is_default = 1 WHERE id = ?').run(req.params.id); }); setDefault(); diff --git a/tests/backend.test.js b/tests/backend.test.js index 81941e1b..b16a6efa 100644 --- a/tests/backend.test.js +++ b/tests/backend.test.js @@ -832,31 +832,38 @@ describe('Backend API', () => { await fetch(`${BASE_URL}/api/datasets/${d1.id}`, { method: 'DELETE' }); }); - it('set default applies to entire language group', async () => { + it('set default applies to one specific variant only', async () => { const res1 = await fetch(`${BASE_URL}/api/datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Def Group', language: 'en' }), + body: JSON.stringify({ name: 'Def Single', language: 'en' }), }); const d1 = await res1.json(); const res2 = await fetch(`${BASE_URL}/api/datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Def Group', language: 'de', language_group: d1.language_group }), + body: JSON.stringify({ name: 'Def Single', language: 'de', language_group: d1.language_group }), }); const d2 = await res2.json(); - // Set d1 as default + // Set d1 (EN) as default await fetch(`${BASE_URL}/api/datasets/${d1.id}/default`, { method: 'PUT' }); - // Check both are default + // Only d1 should be default, not d2 const listRes = await fetch(`${BASE_URL}/api/datasets`); const all = await listRes.json(); const g1 = all.find(d => d.id === d1.id); const g2 = all.find(d => d.id === d2.id); assert.strictEqual(g1.is_default, true); - assert.strictEqual(g2.is_default, true); + assert.strictEqual(g2.is_default, false); + + // Now set d2 (DE) as default — d1 should lose default + await fetch(`${BASE_URL}/api/datasets/${d2.id}/default`, { method: 'PUT' }); + const listRes2 = await fetch(`${BASE_URL}/api/datasets`); + const all2 = await listRes2.json(); + assert.strictEqual(all2.find(d => d.id === d1.id).is_default, false); + assert.strictEqual(all2.find(d => d.id === d2.id).is_default, true); // Create another dataset and set it as default to allow cleanup const res3 = await fetch(`${BASE_URL}/api/datasets`, { From 292477004afde48939c392fc915b62d2837676d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 08:03:50 +0000 Subject: [PATCH 09/43] Carry over all language variants when creating a new version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When clicking "New version" on a dataset that has multiple languages (e.g. EN + DE), the backend now copies all language siblings from the source version into the new version's language group. The primary language uses the current live data; other languages copy their data from the source group. Frontend passes source_group (the source version's language_group UUID) when clicking "New version". The backend uses this to find and copy sibling language variants. This means renaming doesn't affect language carry-over — it uses the UUID-based language_group, not the parsed name convention. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/index.html | 1 + public/shared/admin.js | 18 +++++++++++++++--- src/server.js | 19 ++++++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/public/index.html b/public/index.html index 50b0dd72..ecfb40a4 100644 --- a/public/index.html +++ b/public/index.html @@ -358,6 +358,7 @@ +
diff --git a/public/shared/admin.js b/public/shared/admin.js index e825bef9..c6d2ce0b 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2864,9 +2864,11 @@ async function saveAsDataset() { `` ).join(''); } - // Reset language group + // Reset language group and source group const langGroupInput = document.getElementById('saveAsLangGroup'); if (langGroupInput) langGroupInput.value = ''; + const srcGroupInput = document.getElementById('saveAsSourceGroup'); + if (srcGroupInput) srcGroupInput.value = ''; updateSaveAsSubmitState(); document.getElementById('saveAsModalOverlay').classList.add('active'); setTimeout(() => { input.focus(); input.select(); }, 30); @@ -2939,11 +2941,12 @@ function renderSaveAsList(datasets) { if (!hasMultipleVersions) { // Single version — render standalone const ver = group.versions[0]; + const srcGroup = ver.language_group ? escapeHtml(ver.language_group) : ''; return `
${renderVersion(ver, false)}
- @@ -2976,7 +2979,7 @@ function renderSaveAsList(datasets) { ` : ''}
diff --git a/public/shared/admin.js b/public/shared/admin.js index c6d2ce0b..59321c80 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2782,67 +2782,55 @@ async function saveSettingsSectionOrder() { let saveAsDatasetsCache = []; // Parse a name like "My CV v2" into { base: "My CV", version: 2 }. -// Names without an explicit " vN" suffix are treated as version 1. +// Used for display purposes only — grouping uses version_group from the backend. function parseDatasetVersion(name) { const m = /^(.+?)\s+v(\d+)$/i.exec((name || '').trim()); if (m) return { base: m[1].trim(), version: parseInt(m[2], 10) }; return { base: (name || '').trim(), version: 1 }; } -// Group an array of datasets by their parsed base name. -// Returns [{ base, items: [...] }] sorted by most-recently-updated group desc. -function groupDatasetsByBase(datasets) { - const map = new Map(); - for (const ds of datasets) { - const { base, version } = parseDatasetVersion(ds.name); - if (!map.has(base)) map.set(base, []); - map.get(base).push({ ...ds, _version: version, _base: base }); - } - const groups = Array.from(map.entries()).map(([base, items]) => { - items.sort((a, b) => a._version - b._version); - const latest = items.reduce((max, it) => { - const t = new Date(it.updated_at).getTime(); - return t > max ? t : max; - }, 0); - return { base, items, latestUpdated: latest }; - }); - groups.sort((a, b) => b.latestUpdated - a.latestUpdated); - return groups; -} - // Group datasets into a 3-level hierarchy: Dataset → Version → Language. -// Returns [{ base, versions: [{ version, name, languages: [ds...], language_group }] }] +// Uses version_group (UUID) from backend for grouping, NOT name parsing. +// Returns [{ base, versionGroup, versions: [{ version, name, languages: [ds...], language_group }] }] function groupDatasetsHierarchy(datasets) { - const baseMap = new Map(); + const vgMap = new Map(); for (const ds of datasets) { - const { base, version } = parseDatasetVersion(ds.name); - if (!baseMap.has(base)) baseMap.set(base, new Map()); - const versionMap = baseMap.get(base); - if (!versionMap.has(version)) versionMap.set(version, []); - versionMap.get(version).push({ ...ds, _version: version, _base: base }); - } - const groups = Array.from(baseMap.entries()).map(([base, versionMap]) => { + const vg = ds.version_group || ds.id.toString(); // fallback for legacy + const ver = ds.version || 1; + if (!vgMap.has(vg)) vgMap.set(vg, new Map()); + const versionMap = vgMap.get(vg); + if (!versionMap.has(ver)) versionMap.set(ver, []); + versionMap.get(ver).push(ds); + } + const groups = Array.from(vgMap.entries()).map(([vg, versionMap]) => { const versions = Array.from(versionMap.entries()).map(([version, langs]) => { langs.sort((a, b) => (a.language || 'en').localeCompare(b.language || 'en')); return { version, name: langs[0].name, languages: langs, language_group: langs[0].language_group }; }); versions.sort((a, b) => a.version - b.version); + // Derive display base name from the first version's name + const base = parseDatasetVersion(versions[0].name).base; const latest = versions.reduce((max, v) => { - const t = v.languages.reduce((m, ds) => Math.max(m, new Date(ds.updated_at).getTime()), 0); + const t = v.languages.reduce((m, d) => Math.max(m, new Date(d.updated_at).getTime()), 0); return Math.max(max, t); }, 0); - return { base, versions, latestUpdated: latest }; + return { base, versionGroup: vg, versions, latestUpdated: latest }; }); groups.sort((a, b) => b.latestUpdated - a.latestUpdated); return groups; } // Given a base name and the current dataset list, return the next free "Base vN". -function suggestNextVersion(baseName, datasets) { +// Uses version_group from backend when available. +function suggestNextVersion(baseName, datasets, versionGroup) { let max = 0; for (const ds of datasets) { - const parsed = parseDatasetVersion(ds.name); - if (parsed.base === baseName && parsed.version > max) max = parsed.version; + if (versionGroup && ds.version_group === versionGroup) { + if ((ds.version || 1) > max) max = ds.version || 1; + } else if (!versionGroup) { + const parsed = parseDatasetVersion(ds.name); + if (parsed.base === baseName && parsed.version > max) max = parsed.version; + } } const next = Math.max(max + 1, 2); return `${baseName} v${next}`; @@ -2864,11 +2852,11 @@ async function saveAsDataset() { `` ).join(''); } - // Reset language group and source group + // Reset language group and version group const langGroupInput = document.getElementById('saveAsLangGroup'); if (langGroupInput) langGroupInput.value = ''; - const srcGroupInput = document.getElementById('saveAsSourceGroup'); - if (srcGroupInput) srcGroupInput.value = ''; + const vgInput = document.getElementById('saveAsVersionGroup'); + if (vgInput) vgInput.value = ''; updateSaveAsSubmitState(); document.getElementById('saveAsModalOverlay').classList.add('active'); setTimeout(() => { input.focus(); input.select(); }, 30); @@ -2946,7 +2934,7 @@ function renderSaveAsList(datasets) {
${renderVersion(ver, false)}
- @@ -2979,7 +2967,7 @@ function renderSaveAsList(datasets) { ` : ''}
`; } - // Add language hint - html += `
+ // Add language option + html += `
add ${escapeHtml(t('datasets.add_language'))} - info
`; dropdown.innerHTML = html; @@ -350,6 +349,33 @@ function toggleDatasetLangSwitcher() { if (dropdown) dropdown.classList.toggle('active'); } +// Open Save As modal pre-filled for adding a new language to the current language group +async function addLanguageFromSwitcher() { + const dropdown = document.getElementById('datasetLangDropdown'); + if (dropdown) dropdown.classList.remove('active'); + if (!activeDatasetLanguageGroup) return; + // Open Save As modal, then override fields for add-language mode + await saveAsDataset(); + const input = document.getElementById('saveAsNameInput'); + const langGroupInput = document.getElementById('saveAsLangGroup'); + const langSelect = document.getElementById('saveAsLangSelect'); + if (input) input.value = activeDatasetName || ''; + if (langGroupInput) langGroupInput.value = activeDatasetLanguageGroup; + // Filter language dropdown to only languages not already in this group + if (langSelect && typeof I18n !== 'undefined') { + const existingLangs = new Set(); + existingLangs.add(activeDatasetLanguage || 'en'); + activeDatasetSiblings.forEach(s => existingLangs.add(s.language)); + const available = I18n.languages.filter(l => !existingLangs.has(l.code)); + if (available.length > 0) { + langSelect.innerHTML = available.map(l => + `` + ).join(''); + } + } + updateSaveAsSubmitState(); +} + // Close language switcher when clicking outside document.addEventListener('click', (e) => { const switcher = document.getElementById('datasetLangSwitcher'); diff --git a/public/shared/styles.css b/public/shared/styles.css index cb96cc40..437959a5 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -1251,16 +1251,17 @@ body { } .dataset-lang-option:hover { background: var(--gray-50); } .dataset-lang-option.active { - background: var(--primary-light, rgba(0, 102, 255, 0.08)); - color: var(--primary); + background: var(--primary); + color: var(--white, #fff); font-weight: 600; } -.dataset-lang-option.disabled { - opacity: 0.5; - cursor: default; +.dataset-lang-option.active:hover { background: var(--primary); } +.dataset-lang-option.add-lang { + cursor: pointer; border-top: 1px solid var(--gray-100); + opacity: 0.8; } -.dataset-lang-option.disabled:hover { background: transparent; } +.dataset-lang-option.add-lang:hover { opacity: 1; background: var(--gray-50); } .dataset-lang-code { font-weight: 700; font-size: 11px; diff --git a/version.json b/version.json index f6cd921d..9b88e5ef 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.29.0", + "version": "1.29.1", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From 2400bccb53c07f19c87062006f75e99b0a2381df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 11:34:29 +0000 Subject: [PATCH 15/43] =?UTF-8?q?Revert=20version=20bump=20=E2=80=94=20fol?= =?UTF-8?q?d=20fixes=20into=201.29.0=20for=20this=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- CHANGELOG.md | 10 ++++------ package-lock.json | 4 ++-- package.json | 2 +- version.json | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e3dcc89..37ea5376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,6 @@ All notable changes to CV Manager will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/). -## [1.29.1] - 2026-04-16 - -### Fixed -- Active language in the banner language switcher dropdown now has white text on blue background for proper contrast -- "Add language" item in the language switcher dropdown is now clickable and opens the Save As modal pre-filled for adding a new language variant - ## [1.29.0] - 2026-04-16 ### Added @@ -22,6 +16,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follo - `suggestNextVersion` uses `version_group` and the `version` field to find the max version, falling back to name parsing for legacy datasets. - "New version" buttons pass `version_group` UUID to the backend instead of `source_group`. +### Fixed +- Active language in the banner language switcher dropdown now has white text on blue background for proper contrast +- "Add language" item in the language switcher dropdown is now clickable and opens the Save As modal pre-filled for adding a new language variant + ## [1.28.0] - 2026-04-16 ### Added diff --git a/package-lock.json b/package-lock.json index a3d57714..534e66cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.29.1", + "version": "1.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.29.1", + "version": "1.29.0", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index 9830110f..5f6eb0d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.29.1", + "version": "1.29.0", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/version.json b/version.json index 9b88e5ef..f6cd921d 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.29.1", + "version": "1.29.0", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From d6b3be5b4015c80d80410e9e35175b7f1a19acca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 11:51:03 +0000 Subject: [PATCH 16/43] Improve Open/Save As modal UI: straight edges, consistent structure, auto-expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove border-radius from dataset items, groups, and Save As items for straight left-side borders - Add version header rows to the Open modal (badge + name) matching the Save As modal's version→language nesting pattern - Auto-expand collapsed older versions when they contain the active or default dataset so the user can always see them https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/shared/admin.css | 32 ++++++++++++++++++++++++++++---- public/shared/admin.js | 24 ++++++++++++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/public/shared/admin.css b/public/shared/admin.css index ec697071..255d9a0f 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -757,7 +757,7 @@ align-items: center; padding: 12px 16px; background: var(--very-light); - border-radius: var(--radius-md); + border-radius: 0; margin-bottom: 8px; border-left: 3px solid var(--gray-300); gap: 12px; @@ -976,7 +976,7 @@ margin-bottom: 10px; border: 1px solid var(--gray-200); border-left: 3px solid var(--primary); - border-radius: var(--radius-md); + border-radius: 0; background: var(--white); overflow: hidden; } @@ -1038,6 +1038,30 @@ height: 1px; background: var(--gray-300); } +/* Version sub-block inside the Open modal — mirrors save-as-version-row */ +.dataset-open-version-row { + padding: 4px 0; +} +.dataset-open-version-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; +} +.dataset-open-version-name { + font-size: 13px; + font-weight: 500; + color: var(--gray-700); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dataset-open-version-langs { + padding-left: 8px; +} + /* Version badge inside the Open modal rows — shares .dataset-version-badge styling */ .dataset-name-row .dataset-version-badge { margin-right: 4px; @@ -1117,7 +1141,7 @@ .save-as-item { margin-bottom: 8px; border: 1px solid var(--gray-200); - border-radius: var(--radius-md, 8px); + border-radius: 0; background: var(--white); overflow: hidden; } @@ -1269,7 +1293,7 @@ font-size: 12px; color: var(--gray-600); transition: background 0.15s ease; - border-radius: var(--radius-sm); + border-radius: 0; } .save-as-lang-child:hover { background: var(--gray-100); } .save-as-lang-child.save-as-row-selected { background: rgba(0, 102, 255, 0.08); } diff --git a/public/shared/admin.js b/public/shared/admin.js index 4a1bc663..7e98359d 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -3261,10 +3261,24 @@ async function loadDatasetsList() { function renderVersionBlock(ver, showVersionBadge) { const showLang = ver.languages.length > 1; + if (!showVersionBadge && !showLang) { + // Single version, single language inside a group — just the row + return renderDatasetOpenRow(ver.languages[0], { isChild: true }); + } + const badge = showVersionBadge ? `v${ver.version || 1}` : ''; const rows = ver.languages.map(ds => - renderDatasetOpenRow(ds, { isChild: true, versionBadge: showVersionBadge ? (ds.version || 1) : null, showLangBadge: showLang }) + renderDatasetOpenRow(ds, { isChild: true, showLangBadge: showLang }) ).join(''); - return rows; + return ` +
+ ${badge ? `
${badge}${escapeHtml(ver.name)}
` : ''} +
${rows}
+
`; + } + + // Check whether a version block contains the active or default dataset + function versionHasHighlight(ver) { + return ver.languages.some(ds => ds.id === activeDatasetId || ds.is_default); } container.innerHTML = hierarchy.map(group => { @@ -3298,6 +3312,8 @@ async function loadDatasetsList() { const latestHtml = renderVersionBlock(latest, true); const olderHtml = older.map(v => renderVersionBlock(v, true)).join(''); + // Auto-expand if an older version contains the active or default dataset + const olderHasHighlight = older.some(v => versionHasHighlight(v)); return `
@@ -3309,10 +3325,10 @@ async function loadDatasetsList() { ${latestHtml} ${olderCount > 0 ? ` - +
${olderHtml}
` : ''}
`; From c5b515acf76fd5c69ff69a9112a47b39a62854ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 12:02:58 +0000 Subject: [PATCH 17/43] Remove tree nesting from standalone single-language datasets in Save As When a dataset has no versions and no language variants, render it as a flat clickable row instead of a version header + tree-connected child, which duplicated the name and looked confusing. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/shared/admin.css | 17 +++++++++++++++++ public/shared/admin.js | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/public/shared/admin.css b/public/shared/admin.css index 255d9a0f..789e5c5a 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -1298,6 +1298,23 @@ .save-as-lang-child:hover { background: var(--gray-100); } .save-as-lang-child.save-as-row-selected { background: rgba(0, 102, 255, 0.08); } +/* Flat row for standalone single-language datasets (no tree nesting) */ +.save-as-flat-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 12px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + font-family: inherit; + transition: background 0.15s ease; +} +.save-as-flat-row:hover { background: var(--gray-100); } +.save-as-flat-row.save-as-row-selected { background: rgba(0, 102, 255, 0.08); } + .dataset-version-badge { display: inline-flex; align-items: center; diff --git a/public/shared/admin.js b/public/shared/admin.js index 7e98359d..abb341da 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2937,6 +2937,19 @@ function renderSaveAsList(datasets) { function renderVersion(ver, showVersionBadge) { const badge = showVersionBadge ? `v${ver.version}` : ''; + if (ver.languages.length <= 1 && !showVersionBadge) { + // Single language, no version badge — flat row (no tree nesting) + const ds = ver.languages[0]; + return ` +
+ +
`; + } return `
From 2af97fe201b58e65aba6d29747ecf5f9c3588f80 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 12:11:39 +0000 Subject: [PATCH 18/43] Fix New Version button passing wrong group ID Both "New version" buttons in the Save As modal were passing language_group instead of version_group. This caused new versions to get a fresh version_group UUID instead of inheriting the original's, so they appeared ungrouped in the list. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/shared/admin.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/shared/admin.js b/public/shared/admin.js index abb341da..667aa7e4 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2968,12 +2968,12 @@ function renderSaveAsList(datasets) { if (!hasMultipleVersions) { // Single version — render standalone const ver = group.versions[0]; - const srcGroup = ver.language_group ? escapeHtml(ver.language_group) : ''; + const escVersionGroup = group.versionGroup ? escapeHtml(group.versionGroup) : ''; return `
${renderVersion(ver, false)}
- @@ -3006,7 +3006,7 @@ function renderSaveAsList(datasets) { ` : ''}
+ ${formatDateTime(ds.updated_at)}
${showPublicToggle ? `
From f758f79387c1394e21ae010c489f813d5946fa5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 16:54:03 +0000 Subject: [PATCH 25/43] Show language badge on all Save As modal rows The flat row (single-language) and single-language tree child were missing the language badge. Now every row in the Save As modal shows the language code, matching the Open modal's behavior. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/shared/admin.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/shared/admin.js b/public/shared/admin.js index e7f0ea01..b27f0151 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2931,8 +2931,9 @@ function renderSaveAsList(datasets) { // Single language — just show the dataset row const ds = ver.languages[0]; return ``; } // Multiple languages — show each with a lang badge @@ -2953,6 +2954,7 @@ function renderSaveAsList(datasets) {
From 42c5f1a678ad3370b7d94156c58d516d59fecaee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 17:21:08 +0000 Subject: [PATCH 26/43] Auto-expand active dataset in Save As and sort languages by date Save As modal now auto-expands collapsed older versions when one contains the dataset being edited, matching the Open modal behavior. Languages within each version now sort by most recently modified instead of alphabetically, so the most active variant appears first in both modals. https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX --- public/shared/admin.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/public/shared/admin.js b/public/shared/admin.js index b27f0151..7154a5eb 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -2839,7 +2839,7 @@ function groupDatasetsHierarchy(datasets) { } const groups = Array.from(vgMap.entries()).map(([vg, versionMap]) => { const versions = Array.from(versionMap.entries()).map(([version, langs]) => { - langs.sort((a, b) => (a.language || 'en').localeCompare(b.language || 'en')); + langs.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); return { version, name: langs[0].name, languages: langs, language_group: langs[0].language_group }; }); versions.sort((a, b) => a.version - b.version); @@ -2997,6 +2997,8 @@ function renderSaveAsList(datasets) { const older = group.versions.slice(0, -1); const olderCount = older.length; const countLabel = escapeHtml(t('datasets.versions_count', { count: group.versions.length })); + // Auto-expand if an older version contains the dataset being edited + const olderHasActive = older.some(v => v.languages.some(ds => ds.id === activeDatasetId)); return `
@@ -3007,10 +3009,10 @@ function renderSaveAsList(datasets) { ${renderVersion(latest, true)} ${olderCount > 0 ? ` -