Skip to content

Add multi-language CV variants with language switcher#111

Merged
vincentmakes merged 43 commits into
mainfrom
claude/add-cv-localization-qpdzF
Apr 16, 2026
Merged

Add multi-language CV variants with language switcher#111
vincentmakes merged 43 commits into
mainfrom
claude/add-cv-localization-qpdzF

Conversation

@vincentmakes

Copy link
Copy Markdown
Owner

Description

This PR adds support for creating and managing language-specific variants of CVs. Users can now save multiple language versions of the same CV (e.g., English + German) and switch between them seamlessly. Language variants share a common slug and are grouped together, with the ability to switch languages both in the admin interface and on public CV pages.

Key Features

  • Language-specific datasets: Save CV variants in different languages with language and language_group fields
  • Admin language switcher: Dropdown in the active dataset banner to switch between language variants
  • Public language switcher: Fixed pill button in top-right of public CV pages showing available language variants with flag icons
  • Save As enhancements: Language selection dropdown when saving new datasets, with ability to add new language variants to existing groups
  • Hierarchical dataset organization: Save As modal now groups datasets by base name → version → language for clearer navigation
  • Automatic locale sync: UI language automatically matches the selected dataset language
  • Database migration: Adds language and language_group columns to saved_datasets table with proper UNIQUE constraints

Technical Changes

  • Database schema: saved_datasets table now has language (default 'en') and language_group (UUID) fields
  • New API endpoints: GET /api/datasets/:id/siblings to retrieve language variants
  • Language badge styling and switcher UI components
  • Flag icons for language identification (using country code mapping)
  • Sibling dataset loading and management in admin interface

Type of Change

  • New feature (non-breaking change that adds functionality)

Checklist

Required for all code changes

  • I have tested my changes locally (npm test passes)
  • Version has been bumped in all 3 files (package.json, package-lock.json, version.json) — bumped to 1.28.0
  • CHANGELOG.md has been updated with a new entry under version 1.28.0

If adding or changing user-visible strings

  • No hardcoded English — all strings use t('key') in JS or data-i18n in HTML
  • New i18n keys added to en.json and all 7 other locale files (de, fr, nl, es, it, pt, zh)
  • escapeHtml() used for any user-provided content rendered as HTML

Test Coverage

  • Added comprehensive backend tests for language variant creation, sibling retrieval, and default dataset behavior with language groups
  • Tests verify UNIQUE constraints on (name, language) and (slug, language) pairs
  • Tests confirm setting a dataset as default applies to entire language group
  • Existing tests continue to pass with new language fields

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX

claude added 30 commits April 16, 2026 06:28
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
…ll redesign

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
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
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
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
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
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
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
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
Replaces the fragile name-parsing approach ("Name vN" regex) with
explicit database-backed version tracking. Two new columns on
saved_datasets:

- version_group (TEXT UUID): links all versions of the same dataset
- version (INTEGER): the version number within the group

Migration (Step 2p) assigns version_group and version to existing
datasets by parsing their names, so same base names get the same
version_group UUID. The UNIQUE constraint changes from (slug, language)
to (slug, version, language) so versions can share slugs.

Backend: POST /api/datasets accepts version_group param. When provided,
auto-increments version, copies slug, and carries over all language
siblings from the previous version. All endpoints return version_group
and version in responses.

Frontend: groupDatasetsHierarchy now groups by version_group UUID
from the API instead of regex name parsing. suggestNextVersion uses
the version field. Renaming a dataset no longer breaks version grouping.

4 new backend tests covering version increment, language sibling
carry-over, slug sharing, and version fields in responses.

Bumps version to 1.29.0.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Root cause: the slug+language query was ambiguous when multiple versions
share the same slug. Selecting "French v2" as default set is_default=1
on that row, but the public site loaded data by slug+lang which could
match v1 French instead.

Fix: the server now injects window.DATASET_ID (the exact row ID) for
the default dataset. The public-readonly client uses /api/datasets/id/:id
to fetch data by exact ID, guaranteeing the correct version+language.

Also injects DATASET_ID for /v/:slug/:lang and admin preview pages.
The slug+lang API remains as fallback for non-default datasets.

New endpoint: GET /api/datasets/id/:id on both public server instances
(filters by is_public=1 OR is_default=1 for security).

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Root cause: language siblings of the default dataset were not
accessible on the public site because they had is_public=0 (new
variants start private). The sibling lookup in servePublicIndex
required is_public=1, so clicking a language in the switcher
fell back to the default language instead of switching.

Three fixes:
1. servePublicIndex: when looking up a language sibling of the
   default, no longer requires is_public=1. All siblings in the
   default's language_group are accessible for language switching.
2. getDatasetSiblings: when the dataset is default, returns ALL
   siblings in the group (not just public ones), so the language
   switcher pill shows all available languages.
3. serveDatasetDataById: allows API access to non-public datasets
   if they share a language_group with a default dataset.

Verified with end-to-end test: setting FR as default with EN not
public → / serves FR, /en correctly serves EN, language switcher
shows both languages, API serves both by ID.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Root cause: getDatasetSiblings checked dataset.is_default to decide
whether to include all siblings. But only the ONE default row (e.g. DE)
has is_default=1. When a visitor navigated to /en, the EN row was
served — it has is_default=0, so getDatasetSiblings took the restricted
path and excluded FR (is_public=0, is_default=0).

Fix: getDatasetSiblings now checks if ANY member of the language_group
has is_default=1 (not just the passed dataset). If so, ALL siblings
in the group are returned regardless of is_public status. This ensures
the language switcher shows all available languages on every page.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Active language option now uses white text on blue background for proper
contrast. The "Add language" item is no longer greyed out — clicking it
opens the Save As modal pre-filled for adding a new language variant to
the current language group.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
…auto-expand

- 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
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
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
Datasets created via the broken "New version" button ended up with
different version_group UUIDs despite sharing the same base name.
On startup, detect datasets whose base names match but have different
version_groups, and consolidate them under the canonical (earliest)
version_group. This runs every startup but is a no-op when data is
already consistent.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Export now adds the active dataset language to the JSON and names the
file cv-data-{lang}.json. Import reads the language field back, switches
the UI locale to match, and sets activeDatasetLanguage so subsequent
Save As picks up the correct language.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
The Open modal now shows a clickable language badge on every dataset
row. Clicking it opens a floating picker listing all supported
languages. Selecting one PUTs /api/datasets/:id/language, which
validates the change against name/slug/version/language_group uniqueness
constraints and rejects conflicts with a 409.

This lets users relabel legacy datasets whose language was auto-defaulted
to 'en' during migration, without a blind migration that could mislabel
intentionally-English datasets.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
When a version has only one language, skip the intermediate version
header + tree-connected child and render the dataset row directly
with the version badge inline. This eliminates the redundant three-
level nesting (group name → version name → dataset name) that
repeated the same name three times.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
The "Last updated" text now sits on the same line as the dataset name,
pushed to the right with margin-left: auto. This balances the row
layout and reduces vertical space.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
In the Open modal, move the date out of dataset-info into its own
element between info and actions so it sits to the far right of the row.
In the Save As modal, apply margin-left:auto to save-as-row-date
universally and restructure the flat row so the date is a direct flex
child pushed right.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
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
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
When a dataset is the default, its language siblings in the same
language_group are now treated as implicitly public: the private/shared
toggle is hidden and the URL shows /?lang={code} instead of
/v/{slug}/{lang}. The left border also highlights them as default
siblings. This matches the backend behavior where siblings of the
default are always accessible without needing is_public.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
The Add Language button now appears on single-language flat rows in the
Save As modal, so users can always add language variants regardless of
the dataset structure.

The New Version button moves from the group footer into the group header,
placed between the base name and the version count indicator.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Replace the two separate dataset modals with one unified "CV Manager"
modal. The toolbar now has a single "CV Manager" button that opens a
modal with:

- Top zone: save-as form (name + language + submit)
- Bottom zone: full dataset list with Load + overflow menu per row

Each row renders flat: [radio] [lang] [version] name ... date [Load] [⋮]
The ⋮ menu contains: share/private, change language, preview, copy URL,
delete. No tree nesting for single-language versions.

Group headers show: base name + New Version button + version count.
Compact legend moved to the footer. Old modal code removed.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
claude added 4 commits April 16, 2026 18:54
…d visual grouping

- Hide native radio input inside .cvm-radio (was rendering both native
  and styled dot)
- Switch UI locale and reload editor after saving a language variant
- Add New Version button to single-version multi-language group headers
- Indent language rows inside groups and remove individual left borders
  (only show active/default highlights)

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Single-version datasets now render with the same group header + body
structure as multi-version ones: base name, New Version button, Add
Language button, and a count badge (1 VERSION or N LANGUAGES). This
eliminates the visual inconsistency where standalone datasets had a
different layout.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
When the active locale is not English, the ATS PDF modal shows a
checkbox to optionally render section headers (Work Experience,
Education, etc.) in English while keeping everything else in the
active language. Useful for international CVs targeting English-
speaking ATS systems.

The toggle is hidden when the locale is already English. The server
uses a separate tHeader() translator scoped to 'en' for section
names, while dates and other text stay in the active locale.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Rewrite datasets.md for unified CV Manager modal, language variants,
version management, overflow menu, and default siblings. Expand
language.md with CV content language, language variants, and public
site switching. Update import-export.md with language in exports.
Add ATS English headers toggle to ats.md. Add language variant FAQs
to faq.md. Update guide index descriptions.

Translations in progress — will follow in separate commit.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 16, 2026

Copy link
Copy Markdown

Deploying cv-manager with  Cloudflare Pages  Cloudflare Pages

Latest commit: f22a647
Status: ✅  Deploy successful!
Preview URL: https://beda9044.cv-manager-a5e.pages.dev
Branch Preview URL: https://claude-add-cv-localization-q.cv-manager-a5e.pages.dev

View logs

claude added 9 commits April 16, 2026 20:13
- Fix preview passing dataset name instead of slug to previewDataset()
- Fix copy URL using name instead of slug
- Show share icon inline on shared dataset rows (visible without
  opening the overflow menu)
- Make legend more explicit: "Served on public site" and "Shared at
  /v/ URL" instead of just "Default" and "Shared"

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Translated datasets.md, language.md, import-export.md, ats.md, and
faq.md to de, fr, nl, es, it, pt, zh. Some index files also updated.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
The /api/datasets/id/:id and /api/datasets/slug/:slug routes were
only registered on the public server. When the admin previewed a
dataset at /v/slug, the page fetched the data API from the admin
server which didn't have the route, returning HTML instead of JSON.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
The admin server's dataset data routes were reusing the public
server's functions which require is_public or is_default. Admin
preview of private datasets returned 404. Now the admin routes
query without visibility filters — admin can preview any dataset.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
The icon is now inside .cvm-name so it appears right after the text,
before flex: 1 pushes the remaining elements to the right.

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
Green tinted background and border, same dimensions as the version
badge (min-width 26px, radius-sm, 1px border).

https://claude.ai/code/session_01CnDenkpciR4WdBHQGMDuGX
@vincentmakes vincentmakes merged commit c3bde1c into main Apr 16, 2026
3 checks passed
@vincentmakes vincentmakes deleted the claude/add-cv-localization-qpdzF branch April 16, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants