feat(i18n): localise CV builder workflow
This commit is contained in:
@@ -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<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 }: {
|
||||
settings: CvVariantSettings;
|
||||
update: (p: Partial<CvVariantSettings>) => 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<CvCustomSectionSetting>) =>
|
||||
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 (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">Edit master profile</Button>}>
|
||||
Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.
|
||||
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
|
||||
{t("cvEditorMasterProfileHelp")}
|
||||
</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 })}
|
||||
helperText="Blank uses the headline from your master profile." />
|
||||
helperText={t("cvEditorHeadlineHelp")} />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>Sections</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Drag to reorder, or use the arrows. Content comes from your master profile.</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
|
||||
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
||||
{sectionRows.map((row, i) => (
|
||||
<SectionRow
|
||||
@@ -668,14 +689,14 @@ function ContentTab({ settings, update, outline }: {
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Add a section</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Preset sections use the same editable, reorderable card as every other section.</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorAddSection")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorAddSectionHelp")}</Typography>
|
||||
<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)}>
|
||||
<MenuItem value="free-text">Custom free-text section</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>)}
|
||||
<TextField select size="small" fullWidth label={t("cvEditorSectionType")} value={sectionToAdd} onChange={(event) => setSectionToAdd(event.target.value)}>
|
||||
<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)}>{presetLabel(preset.key, preset.title, t)}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>Add section</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>{t("cvEditorAddSectionButton")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
@@ -701,9 +722,10 @@ function SectionRow({
|
||||
onRemoveCustom: () => void;
|
||||
onUpdateSettings: (p: Partial<CvVariantSettings>) => 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({
|
||||
<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 />
|
||||
<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={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon 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={t("cvEditorMoveSectionDown", { section: sectionLabel })} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||
</Stack>
|
||||
<TextField variant="standard" fullWidth value={sectionLabel}
|
||||
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
|
||||
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
|
||||
{customSection && <Chip size="small" label="Custom" variant="outlined" sx={{ height: 20 }} />}
|
||||
<IconButton size="small" aria-label={`${row.hidden ? "Show" : "Hide"} ${sectionLabel} section`} onClick={() => onPatch({ hidden: !row.hidden })}>
|
||||
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": t("cvEditorSectionName", { section: row.key }) } }} />
|
||||
{customSection && <Chip size="small" label={t("cvEditorCustom")} variant="outlined" sx={{ height: 20 }} />}
|
||||
<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" />}
|
||||
</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();
|
||||
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>
|
||||
<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" }}>
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
@@ -745,18 +767,18 @@ function SectionRow({
|
||||
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
||||
{customSection ? (
|
||||
<Stack spacing={1}>
|
||||
<TextField select size="small" label="Content format" 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>
|
||||
<TextField select size="small" label={t("cvEditorContentFormat")} value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
||||
<MenuItem value="paragraphs">{t("cvEditorParagraphs")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="entries">{t("cvEditorMultipleEntries")}</MenuItem>
|
||||
</TextField>
|
||||
{customSection.contentType === "entries" ? <Stack spacing={1}>
|
||||
{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) })} />
|
||||
<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={`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={`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>
|
||||
<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={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={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={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>)}
|
||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>Add entry</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")}
|
||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button>
|
||||
</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") })} />}
|
||||
<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>
|
||||
@@ -764,8 +786,8 @@ function SectionRow({
|
||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||
) : (
|
||||
<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") })} />
|
||||
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>Reset to master profile</Button>}
|
||||
<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 })}>{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") })} />
|
||||
</Stack>
|
||||
)}
|
||||
@@ -782,6 +804,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
||||
onUpdateSettings: (p: Partial<CvVariantSettings>) => 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 (
|
||||
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
|
||||
sx={{
|
||||
@@ -820,25 +843,25 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
}}>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{ov.title || entry.title || "Untitled"}</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={`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={`${hidden ? "Show" : "Hide"} ${entryLabel} entry`} onClick={() => setOverride(key, { hidden: !hidden })}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{entryLabel}</Typography>
|
||||
<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={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={t(hidden ? "cvEditorShowEntry" : "cvEditorHideEntry", { entry: entryLabel })} onClick={() => setOverride(key, { hidden: !hidden })}>
|
||||
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
{!hidden && (
|
||||
<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 })} />
|
||||
<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 })} />
|
||||
<RichTextField label="Bullet points (one per line)" minRows={2}
|
||||
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
|
||||
value={(ov.bullets ?? entry.bullets).join("\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") })} />
|
||||
{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>
|
||||
)}
|
||||
@@ -857,6 +880,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
update: (p: Partial<CvVariantSettings>) => 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 (
|
||||
<Stack spacing={2}>
|
||||
{mode === "template" && <Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</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="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</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 }}>
|
||||
{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 (
|
||||
<Paper key={t.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
||||
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }}
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
||||
onClick={() => { 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 } }}>
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={t} height={110} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{t.description}</Typography>
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={110} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{theme.name}</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 }}>{theme.description}</Typography>
|
||||
<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 }} />}
|
||||
{t.requiresPro && <Chip size="small" label={locked ? "Pro" : "Pro unlocked"} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" 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>
|
||||
</Paper>
|
||||
);
|
||||
@@ -894,55 +918,55 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
</Box>}
|
||||
|
||||
{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>
|
||||
{["#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" }}>
|
||||
<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>
|
||||
{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></>}
|
||||
|
||||
{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 }}>
|
||||
<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>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("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>{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>
|
||||
<ControlSlider label="Body size" 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="Line height" 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("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("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>
|
||||
{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) }; }) })} />}</>}
|
||||
<ControlSlider label={t("cvEditorBodySize")} value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: 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={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>{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>{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>{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={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" && <>
|
||||
<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 }}>
|
||||
{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>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("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>{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>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("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>{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>
|
||||
{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") && <>
|
||||
<ControlSlider label="Page margins" 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="Entry spacing" value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
||||
<ControlSlider label={t("cvEditorPageMargins")} value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: 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={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 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Sidebar content</Typography>
|
||||
<ControlSlider label="Sidebar width" 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>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("cvEditorSidebarContent")}</Typography>
|
||||
<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={key === "contact" ? t("cvEditorContactDetails") : sectionName(key, SECTION_LABELS[key] ?? key, t)} />)}</Stack>
|
||||
</Paper>}
|
||||
<Divider />
|
||||
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />}
|
||||
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons" />}
|
||||
{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={t("cvEditorContactIcons")} />}
|
||||
</>}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user