diff --git a/job-tracker-ui/src/cv-builder-deep-link.test.tsx b/job-tracker-ui/src/cv-builder-deep-link.test.tsx
index 53e5ab4..45c9e83 100644
--- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx
+++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx
@@ -57,11 +57,14 @@ function renderAt(id: number) {
}
beforeEach(() => {
+ window.localStorage.removeItem('uiLanguage');
jest.clearAllMocks();
mockedApi.put.mockReset();
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '
cv
', suggestedFileName: 'cv.pdf' } } as any);
});
+afterEach(() => window.localStorage.removeItem('uiLanguage'));
+
function routeGet(onVariant: () => Promise, outline: any = { sections: [] }) {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/cv/variants/3') return onVariant();
@@ -158,6 +161,22 @@ test('professional editor separates template, design and layout controls', async
expect(screen.getByLabelText('Language')).toBeInTheDocument();
});
+test('editor sections, design controls and document settings render in Bokmål', async () => {
+ window.localStorage.setItem('uiLanguage', 'nb');
+ routeGet(() => Promise.resolve({ data: variant } as any));
+ renderAt(3);
+
+ expect(await screen.findByLabelText('Egen overskrift')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Utvid Erfaring' })).toBeInTheDocument();
+ expect(screen.getByLabelText('Seksjonstype')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('tab', { name: 'Design' }));
+ expect(screen.getByText('Typografi')).toBeInTheDocument();
+ expect(screen.getByLabelText('Visning av ferdigheter')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('tab', { name: 'Oppsett' }));
+ expect(screen.getByLabelText('Sidestørrelse')).toBeInTheDocument();
+ expect(screen.getByLabelText('Dokumentspråk')).toBeInTheDocument();
+});
+
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts
index e7ba77b..fb75210 100644
--- a/job-tracker-ui/src/i18n/translations.ts
+++ b/job-tracker-ui/src/i18n/translations.ts
@@ -229,6 +229,148 @@ export const translations = {
cvEditorVersion: "Version {version}",
cvEditorCurrent: "current",
cvEditorRestore: "Restore",
+ cvEditorNewSection: "New section",
+ cvEditorUntitled: "Untitled",
+ cvEditorDeleteCustomMessage: "Delete the custom section ‘{title}’ and all of its entries?",
+ cvEditorDeleteCustomTitle: "Delete custom section",
+ cvEditorDeleteSection: "Delete section",
+ cvEditorEditMasterProfile: "Edit master profile",
+ cvEditorMasterProfileHelp: "Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.",
+ cvEditorHeadlineOverride: "Headline override",
+ cvEditorHeadlineHelp: "Blank uses the headline from your master profile.",
+ cvEditorSections: "Sections",
+ cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Content comes from your master profile.",
+ cvEditorAddSection: "Add a section",
+ cvEditorAddSectionButton: "Add section",
+ cvEditorAddSectionHelp: "Preset sections use the same editable, reorderable card as every other section.",
+ cvEditorSectionType: "Section type",
+ cvEditorFreeTextSection: "Custom free-text section",
+ cvEditorMoveSectionUp: "Move {section} section up",
+ cvEditorMoveSectionDown: "Move {section} section down",
+ cvEditorSectionName: "Section name for {section}",
+ cvEditorCustom: "Custom",
+ cvEditorShowSection: "Show {section} section",
+ cvEditorHideSection: "Hide {section} section",
+ cvEditorRemoveSection: "Remove {section} section from this CV",
+ cvEditorRemoveSectionMessage: "Remove {section} from this CV? Your master career data will be preserved.",
+ cvEditorRemoveSectionTitle: "Remove section",
+ cvEditorRemoveFromCv: "Remove from CV",
+ cvEditorCollapseSection: "Collapse {section}",
+ cvEditorExpandSection: "Expand {section}",
+ cvEditorContentFormat: "Content format",
+ cvEditorParagraphs: "Paragraphs",
+ cvEditorBulletList: "Bullet list",
+ cvEditorMultipleEntries: "Multiple entries",
+ cvEditorEntry: "Entry {number}",
+ cvEditorMoveEntryUp: "Move custom entry {number} up",
+ cvEditorMoveEntryDown: "Move custom entry {number} down",
+ cvEditorDeleteEntryNumber: "Delete custom entry {number}",
+ cvEditorDeleteEntryMessage: "Delete this custom section entry?",
+ cvEditorDeleteEntryTitle: "Delete custom entry",
+ cvEditorDeleteEntry: "Delete entry",
+ cvEditorAddEntry: "Add entry",
+ cvEditorParagraphsHelp: "Paragraphs (separate with a blank line)",
+ cvEditorBulletsHelp: "Bullet points (one per line)",
+ cvEditorItemsHelp: "Items (one per line)",
+ cvEditorContentItemsHelp: "Content (one item per line)",
+ cvEditorResetMasterProfile: "Reset to master profile",
+ cvEditorMoveNamedEntryUp: "Move {entry} entry up",
+ cvEditorMoveNamedEntryDown: "Move {entry} entry down",
+ cvEditorShowEntry: "Show {entry} entry",
+ cvEditorHideEntry: "Hide {entry} entry",
+ cvEditorEntryTitle: "Title",
+ cvEditorEntrySubtitle: "Subtitle",
+ cvEditorResetMasterBullets: "Reset to master bullets",
+ cvSectionSummary: "Professional Summary",
+ cvSectionExperience: "Experience",
+ cvSectionEducation: "Education",
+ cvSectionProjects: "Projects",
+ cvSectionSkills: "Skills",
+ cvSectionCertifications: "Certifications",
+ cvSectionLanguages: "Languages",
+ cvSectionInterests: "Interests",
+ cvSectionAwards: "Awards",
+ cvSectionPublications: "Publications",
+ cvSectionOrganisations: "Organisations",
+ cvSectionReferences: "References",
+ cvSectionCourses: "Courses",
+ cvSectionVolunteer: "Volunteer Experience",
+ cvSectionAdditionalExperience: "Additional Experience",
+ cvSectionDeclaration: "Declaration",
+ cvEditorTheme: "Theme",
+ cvEditorThemeHelp: "Templates change presentation only. Your content and hidden-section choices stay intact.",
+ cvEditorAtsFriendly: "ATS-friendly",
+ cvEditorPro: "Pro",
+ cvEditorProUnlocked: "Pro unlocked",
+ cvEditorColour: "Colour",
+ cvEditorUseAccent: "Use accent {colour}",
+ cvEditorCustomAccent: "Custom accent colour",
+ cvEditorThemeDefault: "Theme default",
+ cvEditorTypography: "Typography",
+ cvEditorHeadingFont: "Heading font",
+ cvEditorBodyFont: "Body font",
+ cvEditorBodySize: "Body size",
+ cvEditorHeadingSize: "Heading size",
+ cvEditorLineHeight: "Line height",
+ cvEditorHeadingTreatment: "Heading treatment",
+ cvEditorTemplateDefault: "Template default",
+ cvEditorUppercaseDivider: "Uppercase divider",
+ cvEditorPlain: "Plain",
+ cvEditorAccentBar: "Accent bar",
+ cvEditorHeaderTreatment: "Header treatment",
+ cvEditorColourBand: "Colour band",
+ cvEditorCentred: "Centred",
+ cvEditorEditorial: "Editorial",
+ cvEditorSkillsPresentation: "Skills presentation",
+ cvEditorTags: "Tags",
+ cvEditorCompactText: "Compact text",
+ cvEditorGroupedSkills: "Grouped skills",
+ cvEditorSkillGroups: "Skill groups",
+ cvEditorSkillGroupsHelp: "One group per line, for example: Backend: C#, .NET, Python",
+ cvEditorDocument: "Document",
+ cvEditorPageSize: "Page size",
+ cvEditorDensity: "Density",
+ cvEditorCompact: "Compact",
+ cvEditorBalanced: "Balanced",
+ cvEditorRoomy: "Roomy",
+ cvEditorDocumentLanguage: "Language",
+ cvEditorDateFormat: "Date format",
+ cvEditorColumns: "Columns",
+ cvEditorOneColumn: "One column",
+ cvEditorHeaderBandColumn: "One column with header band",
+ cvEditorLeftSidebar: "Left sidebar",
+ cvEditorRightSidebar: "Right sidebar",
+ cvEditorPageMargins: "Page margins",
+ cvEditorSectionSpacing: "Section spacing",
+ cvEditorEntrySpacing: "Entry spacing",
+ cvEditorSidebarContent: "Sidebar content",
+ cvEditorSidebarWidth: "Sidebar width",
+ cvEditorContactDetails: "Contact details",
+ cvEditorShowPhoto: "Show profile photo",
+ cvEditorContactIcons: "Contact icons",
+ cvEditorOpenFailed: "Could not open this CV.",
+ cvEditorDiscardMessage: "This CV has unsaved changes. Leave and discard them?",
+ cvEditorDiscardTitle: "Discard unsaved CV changes?",
+ cvEditorDiscardLeave: "Discard and leave",
+ cvEditorSaveBeforeVisibility: "Save the current CV before changing its public link.",
+ cvEditorNowPublic: "CV is now public.",
+ cvEditorNowPrivate: "CV is now private.",
+ cvEditorVisibilityFailed: "Could not change visibility.",
+ cvEditorLinkCopied: "Public link copied.",
+ cvEditorSaveBeforeExport: "Save the current CV before exporting it.",
+ cvEditorPdfFailed: "PDF export failed.",
+ cvEditorSaveBeforeDuplicate: "Save the current CV before duplicating it.",
+ cvEditorCopyName: "{name} copy",
+ cvEditorUntitledCv: "Untitled CV",
+ cvEditorDuplicated: "CV duplicated. You are editing the copy.",
+ cvEditorDuplicateFailed: "Could not duplicate this CV.",
+ cvEditorHistoryFailed: "Could not load history.",
+ cvEditorRestoreMessage: "Restore version {version}? Your current saved CV remains in the version history.",
+ cvEditorRestoreTitle: "Restore version {version}",
+ cvEditorRestoreVersion: "Restore version",
+ cvEditorSaveBeforeRestore: "Save the current CV before restoring an older version.",
+ cvEditorRestoredVersion: "Restored version {version}.",
+ cvEditorRestoreFailed: "Restore failed.",
richTextBold: "Bold",
richTextBoldPlaceholder: "bold text",
richTextItalic: "Italic",
@@ -1657,6 +1799,148 @@ export const translations = {
cvEditorVersion: "Versjon {version}",
cvEditorCurrent: "gjeldende",
cvEditorRestore: "Gjenopprett",
+ cvEditorNewSection: "Ny seksjon",
+ cvEditorUntitled: "Uten tittel",
+ cvEditorDeleteCustomMessage: "Slette den egendefinerte seksjonen «{title}» og alle oppføringene?",
+ cvEditorDeleteCustomTitle: "Slett egendefinert seksjon",
+ cvEditorDeleteSection: "Slett seksjon",
+ cvEditorEditMasterProfile: "Rediger hovedprofil",
+ cvEditorMasterProfileHelp: "Kontaktopplysninger og karrierehistorikk deles fra hovedprofilen. CV-spesifikke overskrifter, formuleringer, rekkefølge og synlighet beholdes i denne versjonen.",
+ cvEditorHeadlineOverride: "Egen overskrift",
+ cvEditorHeadlineHelp: "Tomt felt bruker overskriften fra hovedprofilen.",
+ cvEditorSections: "Seksjoner",
+ cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Innholdet kommer fra hovedprofilen.",
+ cvEditorAddSection: "Legg til seksjon",
+ cvEditorAddSectionButton: "Legg til seksjon",
+ cvEditorAddSectionHelp: "Forhåndsdefinerte seksjoner bruker det samme redigerbare kortet som alle andre seksjoner.",
+ cvEditorSectionType: "Seksjonstype",
+ cvEditorFreeTextSection: "Egendefinert fritekstseksjon",
+ cvEditorMoveSectionUp: "Flytt seksjonen {section} opp",
+ cvEditorMoveSectionDown: "Flytt seksjonen {section} ned",
+ cvEditorSectionName: "Seksjonsnavn for {section}",
+ cvEditorCustom: "Egendefinert",
+ cvEditorShowSection: "Vis seksjonen {section}",
+ cvEditorHideSection: "Skjul seksjonen {section}",
+ cvEditorRemoveSection: "Fjern seksjonen {section} fra denne CV-en",
+ cvEditorRemoveSectionMessage: "Fjerne {section} fra denne CV-en? Karrieredataene i hovedprofilen bevares.",
+ cvEditorRemoveSectionTitle: "Fjern seksjon",
+ cvEditorRemoveFromCv: "Fjern fra CV",
+ cvEditorCollapseSection: "Slå sammen {section}",
+ cvEditorExpandSection: "Utvid {section}",
+ cvEditorContentFormat: "Innholdsformat",
+ cvEditorParagraphs: "Avsnitt",
+ cvEditorBulletList: "Punktliste",
+ cvEditorMultipleEntries: "Flere oppføringer",
+ cvEditorEntry: "Oppføring {number}",
+ cvEditorMoveEntryUp: "Flytt egendefinert oppføring {number} opp",
+ cvEditorMoveEntryDown: "Flytt egendefinert oppføring {number} ned",
+ cvEditorDeleteEntryNumber: "Slett egendefinert oppføring {number}",
+ cvEditorDeleteEntryMessage: "Slette denne oppføringen i den egendefinerte seksjonen?",
+ cvEditorDeleteEntryTitle: "Slett egendefinert oppføring",
+ cvEditorDeleteEntry: "Slett oppføring",
+ cvEditorAddEntry: "Legg til oppføring",
+ cvEditorParagraphsHelp: "Avsnitt (skill med en tom linje)",
+ cvEditorBulletsHelp: "Punkter (ett per linje)",
+ cvEditorItemsHelp: "Elementer (ett per linje)",
+ cvEditorContentItemsHelp: "Innhold (ett element per linje)",
+ cvEditorResetMasterProfile: "Tilbakestill til hovedprofil",
+ cvEditorMoveNamedEntryUp: "Flytt oppføringen {entry} opp",
+ cvEditorMoveNamedEntryDown: "Flytt oppføringen {entry} ned",
+ cvEditorShowEntry: "Vis oppføringen {entry}",
+ cvEditorHideEntry: "Skjul oppføringen {entry}",
+ cvEditorEntryTitle: "Tittel",
+ cvEditorEntrySubtitle: "Undertittel",
+ cvEditorResetMasterBullets: "Tilbakestill til punkter fra hovedprofilen",
+ cvSectionSummary: "Profesjonelt sammendrag",
+ cvSectionExperience: "Erfaring",
+ cvSectionEducation: "Utdanning",
+ cvSectionProjects: "Prosjekter",
+ cvSectionSkills: "Ferdigheter",
+ cvSectionCertifications: "Sertifiseringer",
+ cvSectionLanguages: "Språk",
+ cvSectionInterests: "Interesser",
+ cvSectionAwards: "Utmerkelser",
+ cvSectionPublications: "Publikasjoner",
+ cvSectionOrganisations: "Organisasjoner",
+ cvSectionReferences: "Referanser",
+ cvSectionCourses: "Kurs",
+ cvSectionVolunteer: "Frivillig erfaring",
+ cvSectionAdditionalExperience: "Annen erfaring",
+ cvSectionDeclaration: "Erklæring",
+ cvEditorTheme: "Tema",
+ cvEditorThemeHelp: "Maler endrer bare presentasjonen. Innholdet og valgene for skjulte seksjoner beholdes.",
+ cvEditorAtsFriendly: "ATS-vennlig",
+ cvEditorPro: "Pro",
+ cvEditorProUnlocked: "Pro låst opp",
+ cvEditorColour: "Farge",
+ cvEditorUseAccent: "Bruk aksentfargen {colour}",
+ cvEditorCustomAccent: "Egendefinert aksentfarge",
+ cvEditorThemeDefault: "Standard for tema",
+ cvEditorTypography: "Typografi",
+ cvEditorHeadingFont: "Overskriftsfont",
+ cvEditorBodyFont: "Brødtekstfont",
+ cvEditorBodySize: "Brødtekststørrelse",
+ cvEditorHeadingSize: "Overskriftsstørrelse",
+ cvEditorLineHeight: "Linjehøyde",
+ cvEditorHeadingTreatment: "Overskriftsstil",
+ cvEditorTemplateDefault: "Standard for mal",
+ cvEditorUppercaseDivider: "Skillelinje med store bokstaver",
+ cvEditorPlain: "Enkel",
+ cvEditorAccentBar: "Aksentlinje",
+ cvEditorHeaderTreatment: "Topptekststil",
+ cvEditorColourBand: "Fargefelt",
+ cvEditorCentred: "Sentrert",
+ cvEditorEditorial: "Redaksjonell",
+ cvEditorSkillsPresentation: "Visning av ferdigheter",
+ cvEditorTags: "Etiketter",
+ cvEditorCompactText: "Kompakt tekst",
+ cvEditorGroupedSkills: "Grupperte ferdigheter",
+ cvEditorSkillGroups: "Ferdighetsgrupper",
+ cvEditorSkillGroupsHelp: "Én gruppe per linje, for eksempel: Backend: C#, .NET, Python",
+ cvEditorDocument: "Dokument",
+ cvEditorPageSize: "Sidestørrelse",
+ cvEditorDensity: "Tetthet",
+ cvEditorCompact: "Kompakt",
+ cvEditorBalanced: "Balansert",
+ cvEditorRoomy: "Luftig",
+ cvEditorDocumentLanguage: "Dokumentspråk",
+ cvEditorDateFormat: "Datoformat",
+ cvEditorColumns: "Kolonner",
+ cvEditorOneColumn: "Én kolonne",
+ cvEditorHeaderBandColumn: "Én kolonne med toppfelt",
+ cvEditorLeftSidebar: "Venstre sidestolpe",
+ cvEditorRightSidebar: "Høyre sidestolpe",
+ cvEditorPageMargins: "Sidemarger",
+ cvEditorSectionSpacing: "Avstand mellom seksjoner",
+ cvEditorEntrySpacing: "Avstand mellom oppføringer",
+ cvEditorSidebarContent: "Innhold i sidestolpe",
+ cvEditorSidebarWidth: "Bredde på sidestolpe",
+ cvEditorContactDetails: "Kontaktopplysninger",
+ cvEditorShowPhoto: "Vis profilbilde",
+ cvEditorContactIcons: "Kontaktikoner",
+ cvEditorOpenFailed: "Kunne ikke åpne denne CV-en.",
+ cvEditorDiscardMessage: "Denne CV-en har ulagrede endringer. Vil du forlate siden og forkaste dem?",
+ cvEditorDiscardTitle: "Forkaste ulagrede CV-endringer?",
+ cvEditorDiscardLeave: "Forkast og forlat",
+ cvEditorSaveBeforeVisibility: "Lagre CV-en før du endrer den offentlige lenken.",
+ cvEditorNowPublic: "CV-en er nå offentlig.",
+ cvEditorNowPrivate: "CV-en er nå privat.",
+ cvEditorVisibilityFailed: "Kunne ikke endre synligheten.",
+ cvEditorLinkCopied: "Offentlig lenke kopiert.",
+ cvEditorSaveBeforeExport: "Lagre CV-en før du eksporterer den.",
+ cvEditorPdfFailed: "PDF-eksport mislyktes.",
+ cvEditorSaveBeforeDuplicate: "Lagre CV-en før du dupliserer den.",
+ cvEditorCopyName: "Kopi av {name}",
+ cvEditorUntitledCv: "CV uten tittel",
+ cvEditorDuplicated: "CV-en er duplisert. Du redigerer kopien.",
+ cvEditorDuplicateFailed: "Kunne ikke duplisere denne CV-en.",
+ cvEditorHistoryFailed: "Kunne ikke laste historikken.",
+ cvEditorRestoreMessage: "Gjenopprette versjon {version}? Den nåværende lagrede CV-en beholdes i versjonshistorikken.",
+ cvEditorRestoreTitle: "Gjenopprett versjon {version}",
+ cvEditorRestoreVersion: "Gjenopprett versjon",
+ cvEditorSaveBeforeRestore: "Lagre CV-en før du gjenoppretter en eldre versjon.",
+ cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
+ cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
richTextBold: "Fet",
richTextBoldPlaceholder: "fet tekst",
richTextItalic: "Kursiv",
diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx
index 354282a..416ed9c 100644
--- a/job-tracker-ui/src/views/CvBuilderEditor.tsx
+++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx
@@ -125,7 +125,7 @@ export default function CvBuilderEditor() {
setThemes(themeList);
setOutline(outlineData);
} catch (err) {
- if (alive) setLoadError(getApiErrorMessage(err, "Could not open this CV."));
+ if (alive) setLoadError(getApiErrorMessage(err, t("cvEditorOpenFailed")));
}
})();
return () => {
@@ -262,15 +262,15 @@ export default function CvBuilderEditor() {
}
if (blockerPromptOpen.current) return;
blockerPromptOpen.current = true;
- void confirmAction("This CV has unsaved changes. Leave and discard them?", {
- title: "Discard unsaved CV changes?",
- confirmLabel: "Discard and leave",
+ void confirmAction(t("cvEditorDiscardMessage"), {
+ title: t("cvEditorDiscardTitle"),
+ confirmLabel: t("cvEditorDiscardLeave"),
destructive: true,
}).then((confirmed) => {
if (confirmed) blocker.proceed();
else blocker.reset();
});
- }, [blocker, confirmAction]);
+ }, [blocker, confirmAction, t]);
useEffect(() => {
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -288,7 +288,7 @@ export default function CvBuilderEditor() {
const togglePublic = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
- toast("Save the current CV before changing its public link.", "error");
+ toast(t("cvEditorSaveBeforeVisibility"), "error");
return;
}
setPublishing(true);
@@ -296,9 +296,9 @@ export default function CvBuilderEditor() {
const updated = await cvBuilderApi.setPublic(variantId, !isPublic);
setIsPublic(updated.isPublic);
setPublicSlug(updated.publicSlug);
- toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success");
+ toast(t(updated.isPublic ? "cvEditorNowPublic" : "cvEditorNowPrivate"), "success");
} catch (err) {
- toast(getApiErrorMessage(err, "Could not change visibility."), "error");
+ toast(getApiErrorMessage(err, t("cvEditorVisibilityFailed")), "error");
} finally {
setPublishing(false);
}
@@ -306,12 +306,12 @@ export default function CvBuilderEditor() {
const copyPublicLink = () => {
navigator.clipboard?.writeText(`${window.location.origin}/cv/${publicSlug}`);
- toast("Public link copied.", "success");
+ toast(t("cvEditorLinkCopied"), "success");
};
const exportPdf = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
- toast("Save the current CV before exporting it.", "error");
+ toast(t("cvEditorSaveBeforeExport"), "error");
return;
}
setExporting(true);
@@ -324,7 +324,7 @@ export default function CvBuilderEditor() {
a.click();
URL.revokeObjectURL(url);
} catch (err) {
- toast(getApiErrorMessage(err, "PDF export failed."), "error");
+ toast(getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
} finally {
setExporting(false);
}
@@ -332,15 +332,15 @@ export default function CvBuilderEditor() {
const duplicateVariant = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
- toast("Save the current CV before duplicating it.", "error");
+ toast(t("cvEditorSaveBeforeDuplicate"), "error");
return;
}
try {
- const copy = await cvBuilderApi.duplicate(variantId, `${name || "Untitled CV"} copy`);
+ const copy = await cvBuilderApi.duplicate(variantId, t("cvEditorCopyName", { name: name || t("cvEditorUntitledCv") }));
navigate(`/career/builder/${copy.id}`);
- toast("CV duplicated. You are editing the copy.", "success");
+ toast(t("cvEditorDuplicated"), "success");
} catch (err) {
- toast(getApiErrorMessage(err, "Could not duplicate this CV."), "error");
+ toast(getApiErrorMessage(err, t("cvEditorDuplicateFailed")), "error");
}
};
@@ -348,26 +348,26 @@ export default function CvBuilderEditor() {
try {
setVersions(await cvBuilderApi.versions(variantId));
} catch (err) {
- toast(getApiErrorMessage(err, "Could not load history."), "error");
+ toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
}
};
const restore = async (version: number) => {
- if (!(await confirmAction(`Restore version ${version}? Your current saved CV remains in the version history.`, {
- title: `Restore version ${version}`,
- confirmLabel: "Restore version",
+ if (!(await confirmAction(t("cvEditorRestoreMessage", { version }), {
+ title: t("cvEditorRestoreTitle", { version }),
+ confirmLabel: t("cvEditorRestoreVersion"),
}))) return;
if (hasUnsavedChanges && !(await retrySave())) {
- toast("Save the current CV before restoring an older version.", "error");
+ toast(t("cvEditorSaveBeforeRestore"), "error");
return;
}
try {
const updated = await cvBuilderApi.restore(variantId, version);
applyVariant(updated);
await loadVersions();
- toast(`Restored version ${version}.`, "success");
+ toast(t("cvEditorRestoredVersion", { version }), "success");
} catch (err) {
- toast(getApiErrorMessage(err, "Restore failed."), "error");
+ toast(getApiErrorMessage(err, t("cvEditorRestoreFailed")), "error");
}
};
@@ -552,11 +552,32 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
// ---------- Content tab ----------
+type Translate = ReturnType["t"];
+
+function sectionName(key: string, fallback: string, t: Translate) {
+ const labels: Record = {
+ summary: t("cvSectionSummary"), experience: t("cvSectionExperience"), education: t("cvSectionEducation"),
+ projects: t("cvSectionProjects"), skills: t("cvSectionSkills"), certifications: t("cvSectionCertifications"),
+ languages: t("cvSectionLanguages"), interests: t("cvSectionInterests"), awards: t("cvSectionAwards"),
+ publications: t("cvSectionPublications"), organisations: t("cvSectionOrganisations"), references: t("cvSectionReferences"),
+ };
+ return labels[key] ?? fallback;
+}
+
+function presetLabel(key: string, fallback: string, t: Translate) {
+ const labels: Record = {
+ courses: t("cvSectionCourses"), volunteer: t("cvSectionVolunteer"),
+ "additional-experience": t("cvSectionAdditionalExperience"), declaration: t("cvSectionDeclaration"),
+ };
+ return labels[key] ?? fallback;
+}
+
function ContentTab({ settings, update, outline }: {
settings: CvVariantSettings;
update: (p: Partial) => void;
outline: CvOutline | null;
}) {
+ const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [sectionToAdd, setSectionToAdd] = useState("free-text");
// Full section list = configured order (once touched) else default, always including every known key.
@@ -601,7 +622,7 @@ function ContentTab({ settings, update, outline }: {
update({
customSections: [...settings.customSections, {
key,
- title: preset?.title ?? "New section",
+ title: preset?.title ?? t("cvEditorNewSection"),
items: [],
presetKey: preset?.key ?? null,
contentType: preset?.contentType ?? "paragraphs",
@@ -612,9 +633,9 @@ function ContentTab({ settings, update, outline }: {
const updateCustom = (key: string, patch: Partial) =>
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
const removeCustom = async (section: CvCustomSectionSetting) => {
- if (!(await confirmAction(`Delete the custom section "${section.title || "Untitled"}" and all of its entries?`, {
- title: "Delete custom section",
- confirmLabel: "Delete section",
+ if (!(await confirmAction(t("cvEditorDeleteCustomMessage", { title: section.title || t("cvEditorUntitled") }), {
+ title: t("cvEditorDeleteCustomTitle"),
+ confirmLabel: t("cvEditorDeleteSection"),
destructive: true,
}))) return;
update({
@@ -624,16 +645,16 @@ function ContentTab({ settings, update, outline }: {
};
return (
- Edit master profile}>
- Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.
+ {t("cvEditorEditMasterProfile")}}>
+ {t("cvEditorMasterProfileHelp")}
- update({ headline: e.target.value || null })}
- helperText="Blank uses the headline from your master profile." />
+ helperText={t("cvEditorHeadlineHelp")} />
- Sections
- Drag to reorder, or use the arrows. Content comes from your master profile.
+ {t("cvEditorSections")}
+ {t("cvEditorSectionsHelp")}
{sectionRows.map((row, i) => (
- Add a section
- Preset sections use the same editable, reorderable card as every other section.
+ {t("cvEditorAddSection")}
+ {t("cvEditorAddSectionHelp")}
- setSectionToAdd(event.target.value)}>
-
- {CUSTOM_SECTION_PRESETS.map((preset) => )}
+ setSectionToAdd(event.target.value)}>
+
+ {CUSTOM_SECTION_PRESETS.map((preset) => )}
- } onClick={addCustom}>Add section
+ } onClick={addCustom}>{t("cvEditorAddSectionButton")}
@@ -701,9 +722,10 @@ function SectionRow({
onRemoveCustom: () => void;
onUpdateSettings: (p: Partial) => void;
}) {
+ const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [expanded, setExpanded] = useState(false);
- const sectionLabel = customSection?.title ?? row.title ?? SECTION_LABELS[row.key] ?? row.key;
+ const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
const editableItems = row.items ?? sourceItems;
const customText = customSection?.items.join("\n\n") ?? "";
@@ -722,21 +744,21 @@ function SectionRow({
- onMove(-1)}>
- onMove(1)}>
+ onMove(-1)}>
+ onMove(1)}>
customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
- slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
- {customSection && }
- onPatch({ hidden: !row.hidden })}>
+ slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": t("cvEditorSectionName", { section: row.key }) } }} />
+ {customSection && }
+ onPatch({ hidden: !row.hidden })}>
{row.hidden ? : }
- {
+ {
if (customSection) onRemoveCustom();
- else void confirmAction(`Remove ${sectionLabel} from this CV? Your master career data will be preserved.`, { title: "Remove section", confirmLabel: "Remove from CV", destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
+ else void confirmAction(t("cvEditorRemoveSectionMessage", { section: sectionLabel }), { title: t("cvEditorRemoveSectionTitle"), confirmLabel: t("cvEditorRemoveFromCv"), destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
}}>
- setExpanded((value) => !value)}
+ setExpanded((value) => !value)}
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
@@ -745,18 +767,18 @@ function SectionRow({
{customSection ? (
- onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
-
+ onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
+
{customSection.contentType === "entries" ?
{customSection.items.map((item, itemIndex) =>
- onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
- onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}>
- onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}>
- void confirmAction("Delete this custom section entry?", { title: "Delete custom entry", confirmLabel: "Delete entry", destructive: true }).then((confirmed) => { if (confirmed) onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); })}>
+ onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
+ onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}>
+ onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}>
+ void confirmAction(t("cvEditorDeleteEntryMessage"), { title: t("cvEditorDeleteEntryTitle"), confirmLabel: t("cvEditorDeleteEntry"), destructive: true }).then((confirmed) => { if (confirmed) onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); })}>
)}
- } sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>Add entry
- : } sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}
+ : onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
@@ -764,8 +786,8 @@ function SectionRow({
) : (
- onPatch({ items: value.split("\n") })} />
- {row.items && }
+ onPatch({ items: value.split("\n") })} />
+ {row.items && }
onPatch({ items: value.split("\n") })} />
)}
@@ -782,6 +804,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
onPatch: (patch: Partial) => void;
onUpdateSettings: (p: Partial) => void;
}) {
+ const { t } = useI18n();
// Entry order: itemOrder if set, else master order; always covering every entry key.
const orderedKeys: string[] = useMemo(() => {
const master = section.entries.map((e) => e.key ?? "").filter(Boolean);
@@ -810,7 +833,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
if (!entry) return null;
const ov = settings.overrides[key] ?? {};
const hidden = !!ov.hidden;
- const entryLabel = ov.title || entry.title || "Untitled";
+ const entryLabel = ov.title || entry.title || t("cvEditorUntitled");
return (
- {ov.title || entry.title || "Untitled"}
- onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}>
- onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}>
- setOverride(key, { hidden: !hidden })}>
+ {entryLabel}
+ onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}>
+ onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}>
+ setOverride(key, { hidden: !hidden })}>
{hidden ? : }
{!hidden && (
- setOverride(key, { title: e.target.value })} />
- setOverride(key, { subtitle: e.target.value })} />
- setOverride(key, { bullets: v.split("\n") })} />
setOverride(key, { bullets: value.split("\n") })} />
{ov.bullets && (
-
+
)}
)}
@@ -857,6 +880,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
update: (p: Partial) => void;
themes: CvTheme[];
}) {
+ const { t } = useI18n();
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
@@ -868,24 +892,24 @@ function CustomizeTab({ mode, settings, update, themes }: {
return (
{mode === "template" &&
- Theme
- Templates change presentation only. Your content and hidden-section choices stay intact.
+ {t("cvEditorTheme")}
+ {t("cvEditorThemeHelp")}
- {themes.map((t) => {
- const active = t.id === settings.themeId;
- const locked = t.available === false;
+ {themes.map((theme) => {
+ const active = theme.id === settings.themeId;
+ const locked = theme.available === false;
return (
- { if (!locked) update({ themeId: t.id }); }}
- onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }}
+ { if (!locked) update({ themeId: theme.id }); }}
+ onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: theme.id }); } }}
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
-
- {t.name}
- {t.category}
- {t.description}
+
+ {theme.name}
+ {theme.category}
+ {theme.description}
- {t.atsFriendly && }
- {t.requiresPro && }
+ {theme.atsFriendly && }
+ {theme.requiresPro && }
);
@@ -894,55 +918,55 @@ function CustomizeTab({ mode, settings, update, themes }: {
}
{mode === "design" && <>
- {supports("accent") && <>Colour
+ {supports("accent") && <>{t("cvEditorColour")}
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
- update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
+ update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
))}
- update({ accentColor: e.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
+ update({ accentColor: e.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
- {settings.accentColor && }
+ {settings.accentColor && }
>}
- {supports("typography") && <>Typography
+ {supports("typography") && <>{t("cvEditorTypography")}
- Heading font
- Body font
+ {t("cvEditorHeadingFont")}
+ {t("cvEditorBodyFont")}
- update({ baseFontSizePt: value })} />
- update({ headingSizePt: value })} />
- update({ lineHeight: value })} />>}
- {supports("heading") && Heading treatment}
- {supports("header") && Header treatment}
- {supports("skills") && <>Skills presentation
- {settings.skillsStyle === "grouped" && `${group.name}: ${group.items.join(", ")}`).join("\n")} onChange={(event) => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map((line) => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map((item) => item.trim()).filter(Boolean) }; }) })} />}>}
+ update({ baseFontSizePt: value })} />
+ update({ headingSizePt: value })} />
+ update({ lineHeight: value })} />>}
+ {supports("heading") && {t("cvEditorHeadingTreatment")}}
+ {supports("header") && {t("cvEditorHeaderTreatment")}}
+ {supports("skills") && <>{t("cvEditorSkillsPresentation")}
+ {settings.skillsStyle === "grouped" && `${group.name}: ${group.items.join(", ")}`).join("\n")} onChange={(event) => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map((line) => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map((item) => item.trim()).filter(Boolean) }; }) })} />}>}
>}
{mode === "layout" && <>
- Document
+ {t("cvEditorDocument")}
{supports("page") && <>
- Page size
- Density
+ {t("cvEditorPageSize")}
+ {t("cvEditorDensity")}
>}
- Language
- Date format
+ {t("cvEditorDocumentLanguage")}
+ {t("cvEditorDateFormat")}
- {supports("layout") && Columns}
+ {supports("layout") && {t("cvEditorColumns")}}
{supports("spacing") && <>
- update({ pageMarginMm: value })} />
- update({ sectionGapMm: value })} />
- update({ entryGapMm: value })} />
+ update({ pageMarginMm: value })} />
+ update({ sectionGapMm: value })} />
+ update({ entryGapMm: value })} />
>}
{supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") &&
- Sidebar content
- update({ sidebarWidthMm: value })} />
- {["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => toggleSidebarSection(key)} />} label={SECTION_LABELS[key] ?? "Contact details"} />)}
+ {t("cvEditorSidebarContent")}
+ update({ sidebarWidthMm: value })} />
+ {["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => toggleSidebarSection(key)} />} label={key === "contact" ? t("cvEditorContactDetails") : sectionName(key, SECTION_LABELS[key] ?? key, t)} />)}
}
- {supports("photo") && update({ showPhoto: e.target.checked })} />} label="Show profile photo" />}
- {supports("icons") && update({ showIcons: e.target.checked })} />} label="Contact icons" />}
+ {supports("photo") && update({ showPhoto: e.target.checked })} />} label={t("cvEditorShowPhoto")} />}
+ {supports("icons") && update({ showIcons: e.target.checked })} />} label={t("cvEditorContactIcons")} />}
>}
);