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 free-text section - {CUSTOM_SECTION_PRESETS.map((preset) => item.presetKey === preset.key)}>{preset.title})} + setSectionToAdd(event.target.value)}> + {t("cvEditorFreeTextSection")} + {CUSTOM_SECTION_PRESETS.map((preset) => item.presetKey === preset.key)}>{presetLabel(preset.key, preset.title, t)})} - + @@ -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"] })}> - ParagraphsBullet listMultiple entries + onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}> + {t("cvEditorParagraphs")}{t("cvEditorBulletList")}{t("cvEditorMultipleEntries")} {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, ""] })}>{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")} />} } );