feat(i18n): localise CV builder workflow
This commit is contained in:
@@ -57,11 +57,14 @@ function renderAt(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
window.localStorage.removeItem('uiLanguage');
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
mockedApi.put.mockReset();
|
mockedApi.put.mockReset();
|
||||||
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => window.localStorage.removeItem('uiLanguage'));
|
||||||
|
|
||||||
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }) {
|
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }) {
|
||||||
mockedApi.get.mockImplementation((url: string) => {
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
if (url === '/cv/variants/3') return onVariant();
|
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();
|
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 () => {
|
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
|
||||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||||
renderAt(3);
|
renderAt(3);
|
||||||
|
|||||||
@@ -229,6 +229,148 @@ export const translations = {
|
|||||||
cvEditorVersion: "Version {version}",
|
cvEditorVersion: "Version {version}",
|
||||||
cvEditorCurrent: "current",
|
cvEditorCurrent: "current",
|
||||||
cvEditorRestore: "Restore",
|
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",
|
richTextBold: "Bold",
|
||||||
richTextBoldPlaceholder: "bold text",
|
richTextBoldPlaceholder: "bold text",
|
||||||
richTextItalic: "Italic",
|
richTextItalic: "Italic",
|
||||||
@@ -1657,6 +1799,148 @@ export const translations = {
|
|||||||
cvEditorVersion: "Versjon {version}",
|
cvEditorVersion: "Versjon {version}",
|
||||||
cvEditorCurrent: "gjeldende",
|
cvEditorCurrent: "gjeldende",
|
||||||
cvEditorRestore: "Gjenopprett",
|
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",
|
richTextBold: "Fet",
|
||||||
richTextBoldPlaceholder: "fet tekst",
|
richTextBoldPlaceholder: "fet tekst",
|
||||||
richTextItalic: "Kursiv",
|
richTextItalic: "Kursiv",
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default function CvBuilderEditor() {
|
|||||||
setThemes(themeList);
|
setThemes(themeList);
|
||||||
setOutline(outlineData);
|
setOutline(outlineData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (alive) setLoadError(getApiErrorMessage(err, "Could not open this CV."));
|
if (alive) setLoadError(getApiErrorMessage(err, t("cvEditorOpenFailed")));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -262,15 +262,15 @@ export default function CvBuilderEditor() {
|
|||||||
}
|
}
|
||||||
if (blockerPromptOpen.current) return;
|
if (blockerPromptOpen.current) return;
|
||||||
blockerPromptOpen.current = true;
|
blockerPromptOpen.current = true;
|
||||||
void confirmAction("This CV has unsaved changes. Leave and discard them?", {
|
void confirmAction(t("cvEditorDiscardMessage"), {
|
||||||
title: "Discard unsaved CV changes?",
|
title: t("cvEditorDiscardTitle"),
|
||||||
confirmLabel: "Discard and leave",
|
confirmLabel: t("cvEditorDiscardLeave"),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
}).then((confirmed) => {
|
}).then((confirmed) => {
|
||||||
if (confirmed) blocker.proceed();
|
if (confirmed) blocker.proceed();
|
||||||
else blocker.reset();
|
else blocker.reset();
|
||||||
});
|
});
|
||||||
}, [blocker, confirmAction]);
|
}, [blocker, confirmAction, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
@@ -288,7 +288,7 @@ export default function CvBuilderEditor() {
|
|||||||
|
|
||||||
const togglePublic = async () => {
|
const togglePublic = async () => {
|
||||||
if (hasUnsavedChanges && !(await retrySave())) {
|
if (hasUnsavedChanges && !(await retrySave())) {
|
||||||
toast("Save the current CV before changing its public link.", "error");
|
toast(t("cvEditorSaveBeforeVisibility"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
@@ -296,9 +296,9 @@ export default function CvBuilderEditor() {
|
|||||||
const updated = await cvBuilderApi.setPublic(variantId, !isPublic);
|
const updated = await cvBuilderApi.setPublic(variantId, !isPublic);
|
||||||
setIsPublic(updated.isPublic);
|
setIsPublic(updated.isPublic);
|
||||||
setPublicSlug(updated.publicSlug);
|
setPublicSlug(updated.publicSlug);
|
||||||
toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success");
|
toast(t(updated.isPublic ? "cvEditorNowPublic" : "cvEditorNowPrivate"), "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(getApiErrorMessage(err, "Could not change visibility."), "error");
|
toast(getApiErrorMessage(err, t("cvEditorVisibilityFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setPublishing(false);
|
setPublishing(false);
|
||||||
}
|
}
|
||||||
@@ -306,12 +306,12 @@ export default function CvBuilderEditor() {
|
|||||||
|
|
||||||
const copyPublicLink = () => {
|
const copyPublicLink = () => {
|
||||||
navigator.clipboard?.writeText(`${window.location.origin}/cv/${publicSlug}`);
|
navigator.clipboard?.writeText(`${window.location.origin}/cv/${publicSlug}`);
|
||||||
toast("Public link copied.", "success");
|
toast(t("cvEditorLinkCopied"), "success");
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportPdf = async () => {
|
const exportPdf = async () => {
|
||||||
if (hasUnsavedChanges && !(await retrySave())) {
|
if (hasUnsavedChanges && !(await retrySave())) {
|
||||||
toast("Save the current CV before exporting it.", "error");
|
toast(t("cvEditorSaveBeforeExport"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
@@ -324,7 +324,7 @@ export default function CvBuilderEditor() {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(getApiErrorMessage(err, "PDF export failed."), "error");
|
toast(getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -332,15 +332,15 @@ export default function CvBuilderEditor() {
|
|||||||
|
|
||||||
const duplicateVariant = async () => {
|
const duplicateVariant = async () => {
|
||||||
if (hasUnsavedChanges && !(await retrySave())) {
|
if (hasUnsavedChanges && !(await retrySave())) {
|
||||||
toast("Save the current CV before duplicating it.", "error");
|
toast(t("cvEditorSaveBeforeDuplicate"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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}`);
|
navigate(`/career/builder/${copy.id}`);
|
||||||
toast("CV duplicated. You are editing the copy.", "success");
|
toast(t("cvEditorDuplicated"), "success");
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
setVersions(await cvBuilderApi.versions(variantId));
|
setVersions(await cvBuilderApi.versions(variantId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(getApiErrorMessage(err, "Could not load history."), "error");
|
toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const restore = async (version: number) => {
|
const restore = async (version: number) => {
|
||||||
if (!(await confirmAction(`Restore version ${version}? Your current saved CV remains in the version history.`, {
|
if (!(await confirmAction(t("cvEditorRestoreMessage", { version }), {
|
||||||
title: `Restore version ${version}`,
|
title: t("cvEditorRestoreTitle", { version }),
|
||||||
confirmLabel: "Restore version",
|
confirmLabel: t("cvEditorRestoreVersion"),
|
||||||
}))) return;
|
}))) return;
|
||||||
if (hasUnsavedChanges && !(await retrySave())) {
|
if (hasUnsavedChanges && !(await retrySave())) {
|
||||||
toast("Save the current CV before restoring an older version.", "error");
|
toast(t("cvEditorSaveBeforeRestore"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const updated = await cvBuilderApi.restore(variantId, version);
|
const updated = await cvBuilderApi.restore(variantId, version);
|
||||||
applyVariant(updated);
|
applyVariant(updated);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
toast(`Restored version ${version}.`, "success");
|
toast(t("cvEditorRestoredVersion", { version }), "success");
|
||||||
} catch (err) {
|
} 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 ----------
|
// ---------- Content tab ----------
|
||||||
|
|
||||||
|
type Translate = ReturnType<typeof useI18n>["t"];
|
||||||
|
|
||||||
|
function sectionName(key: string, fallback: string, t: Translate) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
courses: t("cvSectionCourses"), volunteer: t("cvSectionVolunteer"),
|
||||||
|
"additional-experience": t("cvSectionAdditionalExperience"), declaration: t("cvSectionDeclaration"),
|
||||||
|
};
|
||||||
|
return labels[key] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function ContentTab({ settings, update, outline }: {
|
function ContentTab({ settings, update, outline }: {
|
||||||
settings: CvVariantSettings;
|
settings: CvVariantSettings;
|
||||||
update: (p: Partial<CvVariantSettings>) => void;
|
update: (p: Partial<CvVariantSettings>) => void;
|
||||||
outline: CvOutline | null;
|
outline: CvOutline | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { confirmAction } = useDialogActions();
|
const { confirmAction } = useDialogActions();
|
||||||
const [sectionToAdd, setSectionToAdd] = useState("free-text");
|
const [sectionToAdd, setSectionToAdd] = useState("free-text");
|
||||||
// Full section list = configured order (once touched) else default, always including every known key.
|
// Full section list = configured order (once touched) else default, always including every known key.
|
||||||
@@ -601,7 +622,7 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
update({
|
update({
|
||||||
customSections: [...settings.customSections, {
|
customSections: [...settings.customSections, {
|
||||||
key,
|
key,
|
||||||
title: preset?.title ?? "New section",
|
title: preset?.title ?? t("cvEditorNewSection"),
|
||||||
items: [],
|
items: [],
|
||||||
presetKey: preset?.key ?? null,
|
presetKey: preset?.key ?? null,
|
||||||
contentType: preset?.contentType ?? "paragraphs",
|
contentType: preset?.contentType ?? "paragraphs",
|
||||||
@@ -612,9 +633,9 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
|
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
|
||||||
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
|
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
|
||||||
const removeCustom = async (section: CvCustomSectionSetting) => {
|
const removeCustom = async (section: CvCustomSectionSetting) => {
|
||||||
if (!(await confirmAction(`Delete the custom section "${section.title || "Untitled"}" and all of its entries?`, {
|
if (!(await confirmAction(t("cvEditorDeleteCustomMessage", { title: section.title || t("cvEditorUntitled") }), {
|
||||||
title: "Delete custom section",
|
title: t("cvEditorDeleteCustomTitle"),
|
||||||
confirmLabel: "Delete section",
|
confirmLabel: t("cvEditorDeleteSection"),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
}))) return;
|
}))) return;
|
||||||
update({
|
update({
|
||||||
@@ -624,16 +645,16 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">Edit master profile</Button>}>
|
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
|
||||||
Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.
|
{t("cvEditorMasterProfileHelp")}
|
||||||
</Alert>
|
</Alert>
|
||||||
<TextField label="Headline override" size="small" fullWidth value={settings.headline ?? ""}
|
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
|
||||||
onChange={(e) => update({ headline: e.target.value || null })}
|
onChange={(e) => update({ headline: e.target.value || null })}
|
||||||
helperText="Blank uses the headline from your master profile." />
|
helperText={t("cvEditorHeadlineHelp")} />
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>Sections</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Drag to reorder, or use the arrows. Content comes from your master profile.</Typography>
|
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
|
||||||
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
||||||
{sectionRows.map((row, i) => (
|
{sectionRows.map((row, i) => (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
@@ -668,14 +689,14 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
|
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Add a section</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorAddSection")}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Preset sections use the same editable, reorderable card as every other section.</Typography>
|
<Typography variant="caption" color="text.secondary">{t("cvEditorAddSectionHelp")}</Typography>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
||||||
<TextField select size="small" fullWidth label="Section type" value={sectionToAdd} onChange={(event) => setSectionToAdd(event.target.value)}>
|
<TextField select size="small" fullWidth label={t("cvEditorSectionType")} value={sectionToAdd} onChange={(event) => setSectionToAdd(event.target.value)}>
|
||||||
<MenuItem value="free-text">Custom free-text section</MenuItem>
|
<MenuItem value="free-text">{t("cvEditorFreeTextSection")}</MenuItem>
|
||||||
{CUSTOM_SECTION_PRESETS.map((preset) => <MenuItem key={preset.key} value={preset.key} disabled={settings.customSections.some((item) => item.presetKey === preset.key)}>{preset.title}</MenuItem>)}
|
{CUSTOM_SECTION_PRESETS.map((preset) => <MenuItem key={preset.key} value={preset.key} disabled={settings.customSections.some((item) => item.presetKey === preset.key)}>{presetLabel(preset.key, preset.title, t)}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>Add section</Button>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>{t("cvEditorAddSectionButton")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -701,9 +722,10 @@ function SectionRow({
|
|||||||
onRemoveCustom: () => void;
|
onRemoveCustom: () => void;
|
||||||
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { confirmAction } = useDialogActions();
|
const { confirmAction } = useDialogActions();
|
||||||
const [expanded, setExpanded] = useState(false);
|
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 sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
|
||||||
const editableItems = row.items ?? sourceItems;
|
const editableItems = row.items ?? sourceItems;
|
||||||
const customText = customSection?.items.join("\n\n") ?? "";
|
const customText = customSection?.items.join("\n\n") ?? "";
|
||||||
@@ -722,21 +744,21 @@ function SectionRow({
|
|||||||
<Stack direction="row" alignItems="center" gap={0.5} sx={{ px: 0.75, py: 0.5 }}>
|
<Stack direction="row" alignItems="center" gap={0.5} sx={{ px: 0.75, py: 0.5 }}>
|
||||||
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled", cursor: "grab" }} aria-hidden />
|
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled", cursor: "grab" }} aria-hidden />
|
||||||
<Stack>
|
<Stack>
|
||||||
<IconButton size="small" aria-label={`Move ${sectionLabel} section up`} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
<IconButton size="small" aria-label={t("cvEditorMoveSectionUp", { section: sectionLabel })} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||||
<IconButton size="small" aria-label={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
<IconButton size="small" aria-label={t("cvEditorMoveSectionDown", { section: sectionLabel })} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
<TextField variant="standard" fullWidth value={sectionLabel}
|
<TextField variant="standard" fullWidth value={sectionLabel}
|
||||||
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
|
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
|
||||||
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
|
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": t("cvEditorSectionName", { section: row.key }) } }} />
|
||||||
{customSection && <Chip size="small" label="Custom" variant="outlined" sx={{ height: 20 }} />}
|
{customSection && <Chip size="small" label={t("cvEditorCustom")} variant="outlined" sx={{ height: 20 }} />}
|
||||||
<IconButton size="small" aria-label={`${row.hidden ? "Show" : "Hide"} ${sectionLabel} section`} onClick={() => onPatch({ hidden: !row.hidden })}>
|
<IconButton size="small" aria-label={t(row.hidden ? "cvEditorShowSection" : "cvEditorHideSection", { section: sectionLabel })} onClick={() => onPatch({ hidden: !row.hidden })}>
|
||||||
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size="small" aria-label={`Remove ${sectionLabel} section from this CV`} onClick={() => {
|
<IconButton size="small" aria-label={t("cvEditorRemoveSection", { section: sectionLabel })} onClick={() => {
|
||||||
if (customSection) onRemoveCustom();
|
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 }); });
|
||||||
}}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
}}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||||
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel}`} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}
|
<IconButton size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}
|
||||||
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
||||||
<ExpandMoreIcon fontSize="small" />
|
<ExpandMoreIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -745,18 +767,18 @@ function SectionRow({
|
|||||||
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
||||||
{customSection ? (
|
{customSection ? (
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<TextField select size="small" label="Content format" value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
<TextField select size="small" label={t("cvEditorContentFormat")} value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
||||||
<MenuItem value="paragraphs">Paragraphs</MenuItem><MenuItem value="bullets">Bullet list</MenuItem><MenuItem value="entries">Multiple entries</MenuItem>
|
<MenuItem value="paragraphs">{t("cvEditorParagraphs")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="entries">{t("cvEditorMultipleEntries")}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
{customSection.contentType === "entries" ? <Stack spacing={1}>
|
{customSection.contentType === "entries" ? <Stack spacing={1}>
|
||||||
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
||||||
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
|
<TextField fullWidth size="small" multiline minRows={2} label={t("cvEditorEntry", { number: itemIndex + 1 })} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
|
||||||
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
<IconButton size="small" aria-label={t("cvEditorMoveEntryUp", { number: itemIndex + 1 })} disabled={itemIndex === 0} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
||||||
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === customSection.items.length - 1} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
<IconButton size="small" aria-label={t("cvEditorMoveEntryDown", { number: itemIndex + 1 })} disabled={itemIndex === customSection.items.length - 1} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
||||||
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => 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) }); })}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
<IconButton size="small" aria-label={t("cvEditorDeleteEntryNumber", { number: itemIndex + 1 })} onClick={() => void confirmAction(t("cvEditorDeleteEntryMessage"), { title: t("cvEditorDeleteEntryTitle"), confirmLabel: t("cvEditorDeleteEntry"), destructive: true }).then((confirmed) => { if (confirmed) onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); })}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||||
</Stack>)}
|
</Stack>)}
|
||||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>Add entry</Button>
|
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button>
|
||||||
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? "Paragraphs (separate with a blank line)" : "Bullet points (one per line)"} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
||||||
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
||||||
<AiSectionAssistant sectionName={sectionLabel} text={customText} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
<AiSectionAssistant sectionName={sectionLabel} text={customText} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -764,8 +786,8 @@ function SectionRow({
|
|||||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<RichTextField label={outlineSection?.kind === "tags" ? "Items (one per line)" : "Content (one item per line)"} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
||||||
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>Reset to master profile</Button>}
|
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>{t("cvEditorResetMasterProfile")}</Button>}
|
||||||
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -782,6 +804,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|||||||
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
||||||
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Entry order: itemOrder if set, else master order; always covering every entry key.
|
// Entry order: itemOrder if set, else master order; always covering every entry key.
|
||||||
const orderedKeys: string[] = useMemo(() => {
|
const orderedKeys: string[] = useMemo(() => {
|
||||||
const master = section.entries.map((e) => e.key ?? "").filter(Boolean);
|
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;
|
if (!entry) return null;
|
||||||
const ov = settings.overrides[key] ?? {};
|
const ov = settings.overrides[key] ?? {};
|
||||||
const hidden = !!ov.hidden;
|
const hidden = !!ov.hidden;
|
||||||
const entryLabel = ov.title || entry.title || "Untitled";
|
const entryLabel = ov.title || entry.title || t("cvEditorUntitled");
|
||||||
return (
|
return (
|
||||||
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
|
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -820,25 +843,25 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|||||||
}}>
|
}}>
|
||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{ov.title || entry.title || "Untitled"}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{entryLabel}</Typography>
|
||||||
<IconButton size="small" aria-label={`Move ${entryLabel} entry up`} disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryUp", { entry: entryLabel })} disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||||
<IconButton size="small" aria-label={`Move ${entryLabel} entry down`} disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryDown", { entry: entryLabel })} disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||||
<IconButton size="small" aria-label={`${hidden ? "Show" : "Hide"} ${entryLabel} entry`} onClick={() => setOverride(key, { hidden: !hidden })}>
|
<IconButton size="small" aria-label={t(hidden ? "cvEditorShowEntry" : "cvEditorHideEntry", { entry: entryLabel })} onClick={() => setOverride(key, { hidden: !hidden })}>
|
||||||
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
{!hidden && (
|
{!hidden && (
|
||||||
<Stack spacing={0.75} sx={{ mt: 0.75 }}>
|
<Stack spacing={0.75} sx={{ mt: 0.75 }}>
|
||||||
<TextField size="small" fullWidth label="Title" value={ov.title ?? entry.title ?? ""}
|
<TextField size="small" fullWidth label={t("cvEditorEntryTitle")} value={ov.title ?? entry.title ?? ""}
|
||||||
onChange={(e) => setOverride(key, { title: e.target.value })} />
|
onChange={(e) => setOverride(key, { title: e.target.value })} />
|
||||||
<TextField size="small" fullWidth label="Subtitle" value={ov.subtitle ?? entry.subtitle ?? ""}
|
<TextField size="small" fullWidth label={t("cvEditorEntrySubtitle")} value={ov.subtitle ?? entry.subtitle ?? ""}
|
||||||
onChange={(e) => setOverride(key, { subtitle: e.target.value })} />
|
onChange={(e) => setOverride(key, { subtitle: e.target.value })} />
|
||||||
<RichTextField label="Bullet points (one per line)" minRows={2}
|
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
|
||||||
value={(ov.bullets ?? entry.bullets).join("\n")}
|
value={(ov.bullets ?? entry.bullets).join("\n")}
|
||||||
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
||||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
||||||
{ov.bullets && (
|
{ov.bullets && (
|
||||||
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>Reset to master bullets</Button>
|
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -857,6 +880,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
|||||||
update: (p: Partial<CvVariantSettings>) => void;
|
update: (p: Partial<CvVariantSettings>) => void;
|
||||||
themes: CvTheme[];
|
themes: CvTheme[];
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
|
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
|
||||||
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
||||||
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
||||||
@@ -868,24 +892,24 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
|||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{mode === "template" && <Box>
|
{mode === "template" && <Box>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>Templates change presentation only. Your content and hidden-section choices stay intact.</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>{t("cvEditorThemeHelp")}</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
{themes.map((t) => {
|
{themes.map((theme) => {
|
||||||
const active = t.id === settings.themeId;
|
const active = theme.id === settings.themeId;
|
||||||
const locked = t.available === false;
|
const locked = theme.available === false;
|
||||||
return (
|
return (
|
||||||
<Paper key={t.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
||||||
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
onClick={() => { if (!locked) update({ themeId: theme.id }); }}
|
||||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.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 } }}>
|
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 } }}>
|
||||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={t} height={110} /></Box>
|
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={110} /></Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{theme.name}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{t.description}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{theme.description}</Typography>
|
||||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
||||||
{t.atsFriendly && <Chip size="small" label="ATS-friendly" color="success" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||||
{t.requiresPro && <Chip size="small" label={locked ? "Pro" : "Pro unlocked"} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
{theme.requiresPro && <Chip size="small" label={locked ? t("cvEditorPro") : t("cvEditorProUnlocked")} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
@@ -894,55 +918,55 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
|||||||
</Box>}
|
</Box>}
|
||||||
|
|
||||||
{mode === "design" && <>
|
{mode === "design" && <>
|
||||||
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Colour</Typography>
|
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorColour")}</Typography>
|
||||||
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||||
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
||||||
<IconButton key={color} aria-label={`Use accent ${color}`} onClick={() => 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 } }} />
|
<IconButton key={color} aria-label={t("cvEditorUseAccent", { colour: color })} onClick={() => 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 } }} />
|
||||||
))}
|
))}
|
||||||
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", bgcolor: settings.accentColor ?? "#3157d5", border: "2px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden", position: "relative" }}>
|
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", bgcolor: settings.accentColor ?? "#3157d5", border: "2px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden", position: "relative" }}>
|
||||||
<input type="color" aria-label="Custom accent colour" value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
|
<input type="color" aria-label={t("cvEditorCustomAccent")} value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
|
||||||
</Box>
|
</Box>
|
||||||
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Theme default</Button>}
|
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>{t("cvEditorThemeDefault")}</Button>}
|
||||||
</Stack></>}
|
</Stack></>}
|
||||||
|
|
||||||
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>Typography</Typography>
|
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>{t("cvEditorTypography")}</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
<FormControl size="small" fullWidth><InputLabel>Heading font</InputLabel><Select label="Heading font" value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingFont")}</InputLabel><Select label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||||
<FormControl size="small" fullWidth><InputLabel>Body font</InputLabel><Select label="Body font" value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorBodyFont")}</InputLabel><Select label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
<ControlSlider label="Body size" value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
<ControlSlider label={t("cvEditorBodySize")} value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
||||||
<ControlSlider label="Heading size" value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
<ControlSlider label={t("cvEditorHeadingSize")} value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
||||||
<ControlSlider label="Line height" value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
|
<ControlSlider label={t("cvEditorLineHeight")} value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
|
||||||
{supports("heading") && <FormControl size="small" fullWidth><InputLabel>Heading treatment</InputLabel><Select label="Heading treatment" value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="caps-rule">Uppercase divider</MenuItem><MenuItem value="underline">Underline</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="bar">Accent bar</MenuItem></Select></FormControl>}
|
{supports("heading") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingTreatment")}</InputLabel><Select label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
|
||||||
{supports("header") && <FormControl size="small" fullWidth><InputLabel>Header treatment</InputLabel><Select label="Header treatment" value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="band">Colour band</MenuItem><MenuItem value="centered">Centred</MenuItem><MenuItem value="kicker">Editorial</MenuItem></Select></FormControl>}
|
{supports("header") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeaderTreatment")}</InputLabel><Select label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
|
||||||
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>Skills presentation</InputLabel><Select inputProps={{ "aria-label": "Skills presentation" }} label="Skills presentation" value={settings.skillsStyle ?? "tags"} onChange={(e) => update({ skillsStyle: e.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">Tags</MenuItem><MenuItem value="text">Compact text</MenuItem><MenuItem value="bullets">Bullet list</MenuItem><MenuItem value="grouped">Grouped skills</MenuItem></Select></FormControl>
|
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>{t("cvEditorSkillsPresentation")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorSkillsPresentation") }} label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? "tags"} onChange={(e) => update({ skillsStyle: e.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">{t("cvEditorTags")}</MenuItem><MenuItem value="text">{t("cvEditorCompactText")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="grouped">{t("cvEditorGroupedSkills")}</MenuItem></Select></FormControl>
|
||||||
{settings.skillsStyle === "grouped" && <TextField label="Skill groups" helperText="One group per line, for example: Backend: C#, .NET, Python" multiline minRows={3} value={(settings.skillGroups ?? []).map((group) => `${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) }; }) })} />}</>}
|
{settings.skillsStyle === "grouped" && <TextField label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp")} multiline minRows={3} value={(settings.skillGroups ?? []).map((group) => `${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" && <>
|
{mode === "layout" && <>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Document</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorDocument")}</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
{supports("page") && <>
|
{supports("page") && <>
|
||||||
<FormControl size="small" fullWidth><InputLabel>Page size</InputLabel><Select inputProps={{ "aria-label": "Page size" }} label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorPageSize")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorPageSize") }} label={t("cvEditorPageSize")} value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
||||||
<FormControl size="small" fullWidth><InputLabel>Density</InputLabel><Select inputProps={{ "aria-label": "Density" }} label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">Compact</MenuItem><MenuItem value="balanced">Balanced</MenuItem><MenuItem value="roomy">Roomy</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDensity")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDensity") }} label={t("cvEditorDensity")} value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">{t("cvEditorCompact")}</MenuItem><MenuItem value="balanced">{t("cvEditorBalanced")}</MenuItem><MenuItem value="roomy">{t("cvEditorRoomy")}</MenuItem></Select></FormControl>
|
||||||
</>}
|
</>}
|
||||||
<FormControl size="small" fullWidth><InputLabel>Language</InputLabel><Select inputProps={{ "aria-label": "Language" }} label="Language" value={settings.language === "no" ? "nb-NO" : settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="nb-NO">Norwegian Bokmål</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDocumentLanguage")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDocumentLanguage") }} label={t("cvEditorDocumentLanguage")} value={settings.language === "no" ? "nb-NO" : settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="nb-NO">Norsk bokmål</MenuItem></Select></FormControl>
|
||||||
<FormControl size="small" fullWidth><InputLabel>Date format</InputLabel><Select inputProps={{ "aria-label": "Date format" }} label="Date format" value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDateFormat")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDateFormat") }} label={t("cvEditorDateFormat")} value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>}
|
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorColumns")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorColumns") }} label={t("cvEditorColumns")} value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="single">{t("cvEditorOneColumn")}</MenuItem><MenuItem value="header-band">{t("cvEditorHeaderBandColumn")}</MenuItem><MenuItem value="sidebar-left">{t("cvEditorLeftSidebar")}</MenuItem><MenuItem value="sidebar-right">{t("cvEditorRightSidebar")}</MenuItem></Select></FormControl>}
|
||||||
{supports("spacing") && <>
|
{supports("spacing") && <>
|
||||||
<ControlSlider label="Page margins" value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
|
<ControlSlider label={t("cvEditorPageMargins")} value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
|
||||||
<ControlSlider label="Section spacing" value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
|
<ControlSlider label={t("cvEditorSectionSpacing")} value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
|
||||||
<ControlSlider label="Entry spacing" value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
<ControlSlider label={t("cvEditorEntrySpacing")} value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
||||||
</>}
|
</>}
|
||||||
{supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
{supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Sidebar content</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("cvEditorSidebarContent")}</Typography>
|
||||||
<ControlSlider label="Sidebar width" value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
|
<ControlSlider label={t("cvEditorSidebarWidth")} value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
|
||||||
<Stack>{["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={SECTION_LABELS[key] ?? "Contact details"} />)}</Stack>
|
<Stack>{["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={key === "contact" ? t("cvEditorContactDetails") : sectionName(key, SECTION_LABELS[key] ?? key, t)} />)}</Stack>
|
||||||
</Paper>}
|
</Paper>}
|
||||||
<Divider />
|
<Divider />
|
||||||
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />}
|
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label={t("cvEditorShowPhoto")} />}
|
||||||
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons" />}
|
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label={t("cvEditorContactIcons")} />}
|
||||||
</>}
|
</>}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user