feat(i18n): localise CV dashboard

This commit is contained in:
cesnimda
2026-08-28 20:27:33 +02:00
parent cf549d6ed1
commit 557121ad31
3 changed files with 123 additions and 35 deletions
@@ -47,9 +47,12 @@ function renderPage() {
} }
beforeEach(() => { beforeEach(() => {
window.localStorage.removeItem('uiLanguage');
jest.clearAllMocks(); jest.clearAllMocks();
}); });
afterEach(() => window.localStorage.removeItem('uiLanguage'));
test('lists existing CVs from the variants API', async () => { test('lists existing CVs from the variants API', async () => {
mockedApi.get.mockResolvedValueOnce({ mockedApi.get.mockResolvedValueOnce({
data: [ data: [
@@ -87,6 +90,19 @@ test('shows the empty state and creates a CV then navigates to the editor', asyn
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42')); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
}); });
test('CV dashboard and create flow render in Bokmål', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
mockedApi.get.mockResolvedValueOnce({ data: [] } as any);
renderPage();
expect(await screen.findByText('Ingen CV-er ennå')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Ny CV' })[0]);
expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument();
expect(screen.getByLabelText('CV-navn')).toHaveValue('CV uten tittel');
expect(screen.getByText('Velg en mal')).toBeInTheDocument();
});
test('CV deletion requires confirmation and supports cancellation', async () => { test('CV deletion requires confirmation and supports cancellation', async () => {
mockedApi.get.mockResolvedValueOnce({ data: [ mockedApi.get.mockResolvedValueOnce({ data: [
{ id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() }, { id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() },
+70
View File
@@ -371,6 +371,41 @@ export const translations = {
cvEditorSaveBeforeRestore: "Save the current CV before restoring an older version.", cvEditorSaveBeforeRestore: "Save the current CV before restoring an older version.",
cvEditorRestoredVersion: "Restored version {version}.", cvEditorRestoredVersion: "Restored version {version}.",
cvEditorRestoreFailed: "Restore failed.", cvEditorRestoreFailed: "Restore failed.",
cvDashboardTitle: "CV Builder",
cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.",
cvDashboardUntitled: "Untitled CV",
cvDashboardLoadFailed: "Could not load your CVs.",
cvDashboardCreateFailed: "Could not create a CV.",
cvDashboardDuplicated: "CV duplicated.",
cvDashboardDuplicateFailed: "Duplicate failed.",
cvDashboardDeleteMessage: "Delete {name} and its saved version history?",
cvDashboardThisCv: "this CV",
cvDashboardDeleteCv: "Delete CV",
cvDashboardDeleted: "CV deleted.",
cvDashboardDeleteFailed: "Delete failed.",
cvDashboardRenameMessage: "Give this CV a clear name.",
cvDashboardRenameCv: "Rename CV",
cvDashboardRename: "Rename",
cvDashboardRenamed: "CV renamed.",
cvDashboardRenameFailed: "Rename failed.",
cvDashboardDownloadFailed: "PDF download failed.",
cvDashboardNewCv: "New CV",
cvDashboardEmptyTitle: "No CVs yet",
cvDashboardEmptyBody: "Create your first CV — it pulls straight from your career profile.",
cvDashboardOpenNamed: "Open {name}",
cvDashboardActionsFor: "Actions for {name}",
cvDashboardUpdatedVersion: "Updated {date} · version {version}",
cvDashboardAt: " at ",
cvDashboardJobNumber: "job #{id}",
cvDashboardOpen: "Open",
cvDashboardDelete: "Delete",
cvDashboardCreateTitle: "Create a CV",
cvDashboardCreateHelp: "Start with your saved career profile, choose a visual direction, then tailor what appears.",
cvDashboardEnterName: "Enter a name.",
cvDashboardNameExample: "For example: Backend Engineer — Acme",
cvDashboardChooseTemplate: "Choose a template",
cvDashboardCreating: "Creating…",
cvDashboardCreateCv: "Create CV",
richTextBold: "Bold", richTextBold: "Bold",
richTextBoldPlaceholder: "bold text", richTextBoldPlaceholder: "bold text",
richTextItalic: "Italic", richTextItalic: "Italic",
@@ -1941,6 +1976,41 @@ export const translations = {
cvEditorSaveBeforeRestore: "Lagre CV-en før du gjenoppretter en eldre versjon.", cvEditorSaveBeforeRestore: "Lagre CV-en før du gjenoppretter en eldre versjon.",
cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.", cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
cvEditorRestoreFailed: "Gjenoppretting mislyktes.", cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
cvDashboardTitle: "CV-bygger",
cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.",
cvDashboardUntitled: "CV uten tittel",
cvDashboardLoadFailed: "Kunne ikke laste CV-ene dine.",
cvDashboardCreateFailed: "Kunne ikke opprette en CV.",
cvDashboardDuplicated: "CV-en er duplisert.",
cvDashboardDuplicateFailed: "Duplisering mislyktes.",
cvDashboardDeleteMessage: "Slette «{name}» og den lagrede versjonshistorikken?",
cvDashboardThisCv: "denne CV-en",
cvDashboardDeleteCv: "Slett CV",
cvDashboardDeleted: "CV-en er slettet.",
cvDashboardDeleteFailed: "Sletting mislyktes.",
cvDashboardRenameMessage: "Gi denne CV-en et tydelig navn.",
cvDashboardRenameCv: "Gi CV-en nytt navn",
cvDashboardRename: "Gi nytt navn",
cvDashboardRenamed: "CV-en har fått nytt navn.",
cvDashboardRenameFailed: "Navneendring mislyktes.",
cvDashboardDownloadFailed: "Nedlasting av PDF mislyktes.",
cvDashboardNewCv: "Ny CV",
cvDashboardEmptyTitle: "Ingen CV-er ennå",
cvDashboardEmptyBody: "Opprett din første CV — den henter innhold direkte fra karriereprofilen din.",
cvDashboardOpenNamed: "Åpne {name}",
cvDashboardActionsFor: "Handlinger for {name}",
cvDashboardUpdatedVersion: "Oppdatert {date} · versjon {version}",
cvDashboardAt: " hos ",
cvDashboardJobNumber: "jobb #{id}",
cvDashboardOpen: "Åpne",
cvDashboardDelete: "Slett",
cvDashboardCreateTitle: "Opprett en CV",
cvDashboardCreateHelp: "Start med den lagrede karriereprofilen, velg en visuell retning og tilpass deretter hva som skal vises.",
cvDashboardEnterName: "Skriv inn et navn.",
cvDashboardNameExample: "For eksempel: Backendutvikler — Acme",
cvDashboardChooseTemplate: "Velg en mal",
cvDashboardCreating: "Oppretter…",
cvDashboardCreateCv: "Opprett CV",
richTextBold: "Fet", richTextBold: "Fet",
richTextBoldPlaceholder: "fet tekst", richTextBoldPlaceholder: "fet tekst",
richTextItalic: "Kursiv", richTextItalic: "Kursiv",
+37 -35
View File
@@ -14,10 +14,12 @@ import { useToast } from "../toast";
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder"; import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { useDialogActions } from "../dialogs"; import { useDialogActions } from "../dialogs";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail"; import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
import { useI18n } from "../i18n/I18nProvider";
export default function CvBuilderPage() { export default function CvBuilderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { language, t } = useI18n();
const { confirmAction, promptForValue } = useDialogActions(); const { confirmAction, promptForValue } = useDialogActions();
const [variants, setVariants] = useState<CvVariantSummary[]>([]); const [variants, setVariants] = useState<CvVariantSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -25,7 +27,7 @@ export default function CvBuilderPage() {
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null); const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
const [themes, setThemes] = useState<CvTheme[]>([]); const [themes, setThemes] = useState<CvTheme[]>([]);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("Untitled CV"); const [newName, setNewName] = useState(t("cvDashboardUntitled"));
const [newTheme, setNewTheme] = useState("modern"); const [newTheme, setNewTheme] = useState("modern");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -34,7 +36,7 @@ export default function CvBuilderPage() {
setVariants(await cvBuilderApi.list()); setVariants(await cvBuilderApi.list());
try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); } try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
} catch (err) { } catch (err) {
setError(getApiErrorMessage(err, "Could not load your CVs.")); setError(getApiErrorMessage(err, t("cvDashboardLoadFailed")));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -55,7 +57,7 @@ export default function CvBuilderPage() {
setCreateOpen(false); setCreateOpen(false);
navigate(`/career/builder/${variant.id}`); navigate(`/career/builder/${variant.id}`);
} catch (err) { } catch (err) {
toast(getApiErrorMessage(err, "Could not create a CV."), "error"); toast(getApiErrorMessage(err, t("cvDashboardCreateFailed")), "error");
} finally { setCreating(false); } } finally { setCreating(false); }
}; };
@@ -63,9 +65,9 @@ export default function CvBuilderPage() {
try { try {
await cvBuilderApi.duplicate(id); await cvBuilderApi.duplicate(id);
await load(); await load();
toast("CV duplicated.", "success"); toast(t("cvDashboardDuplicated"), "success");
} catch (err) { } catch (err) {
toast(getApiErrorMessage(err, "Duplicate failed."), "error"); toast(getApiErrorMessage(err, t("cvDashboardDuplicateFailed")), "error");
} finally { } finally {
setMenu(null); setMenu(null);
} }
@@ -74,17 +76,17 @@ export default function CvBuilderPage() {
const remove = async (id: number) => { const remove = async (id: number) => {
const variant = variants.find((item) => item.id === id); const variant = variants.find((item) => item.id === id);
setMenu(null); setMenu(null);
if (!(await confirmAction(`Delete "${variant?.name ?? "this CV"}" and its saved version history?`, { if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
title: "Delete CV", title: t("cvDashboardDeleteCv"),
confirmLabel: "Delete CV", confirmLabel: t("cvDashboardDeleteCv"),
destructive: true, destructive: true,
}))) return; }))) return;
try { try {
await cvBuilderApi.remove(id); await cvBuilderApi.remove(id);
setVariants((v) => v.filter((x) => x.id !== id)); setVariants((v) => v.filter((x) => x.id !== id));
toast("CV deleted.", "success"); toast(t("cvDashboardDeleted"), "success");
} catch (err) { } catch (err) {
toast(getApiErrorMessage(err, "Delete failed."), "error"); toast(getApiErrorMessage(err, t("cvDashboardDeleteFailed")), "error");
} finally { } finally {
setMenu(null); setMenu(null);
} }
@@ -93,14 +95,14 @@ export default function CvBuilderPage() {
const rename = async (id: number) => { const rename = async (id: number) => {
const current = variants.find((item) => item.id === id); const current = variants.find((item) => item.id === id);
setMenu(null); setMenu(null);
const nextName = await promptForValue("Give this CV a clear name.", current?.name ?? "", { title: "Rename CV", confirmLabel: "Rename" }); const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
if (!nextName?.trim() || nextName.trim() === current?.name) return; if (!nextName?.trim() || nextName.trim() === current?.name) return;
try { try {
const variant = await cvBuilderApi.get(id); const variant = await cvBuilderApi.get(id);
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" }); const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" });
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item)); setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
toast("CV renamed.", "success"); toast(t("cvDashboardRenamed"), "success");
} catch (err) { toast(getApiErrorMessage(err, "Rename failed."), "error"); } } catch (err) { toast(getApiErrorMessage(err, t("cvDashboardRenameFailed")), "error"); }
}; };
const download = async (id: number, cvName: string) => { const download = async (id: number, cvName: string) => {
@@ -113,17 +115,17 @@ export default function CvBuilderPage() {
link.download = `${cvName || "cv"}.pdf`; link.download = `${cvName || "cv"}.pdf`;
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (err) { toast(getApiErrorMessage(err, "PDF download failed."), "error"); } } catch (err) { toast(getApiErrorMessage(err, t("cvDashboardDownloadFailed")), "error"); }
}; };
return ( return (
<Box sx={{ display: "grid", gap: 2 }}> <Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}> <Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>CV Builder</Typography> <Typography variant="h5" sx={{ fontWeight: 900 }}>{t("cvDashboardTitle")}</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 720 }}>Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.</Typography> <Typography color="text.secondary" sx={{ maxWidth: 720 }}>{t("cvDashboardSubtitle")}</Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button> <Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
</Paper> </Paper>
{error && <Alert severity="error">{error}</Alert>} {error && <Alert severity="error">{error}</Alert>}
@@ -131,9 +133,9 @@ export default function CvBuilderPage() {
{!loading && variants.length === 0 && !error && ( {!loading && variants.length === 0 && !error && (
<Paper sx={{ p: 4, borderRadius: 4, textAlign: "center" }}> <Paper sx={{ p: 4, borderRadius: 4, textAlign: "center" }}>
<DescriptionOutlinedIcon sx={{ fontSize: 48, color: "text.disabled" }} /> <DescriptionOutlinedIcon sx={{ fontSize: 48, color: "text.disabled" }} />
<Typography sx={{ mt: 1, fontWeight: 700 }}>No CVs yet</Typography> <Typography sx={{ mt: 1, fontWeight: 700 }}>{t("cvDashboardEmptyTitle")}</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>Create your first CV it pulls straight from your career profile.</Typography> <Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardEmptyBody")}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button> <Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
</Paper> </Paper>
)} )}
@@ -143,7 +145,7 @@ export default function CvBuilderPage() {
key={v.id} key={v.id}
role="link" role="link"
tabIndex={0} tabIndex={0}
aria-label={`Open ${v.name}`} aria-label={t("cvDashboardOpenNamed", { name: v.name })}
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }} sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
onClick={() => navigate(`/career/builder/${v.id}`)} onClick={() => navigate(`/career/builder/${v.id}`)}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -156,42 +158,42 @@ export default function CvBuilderPage() {
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} /> <CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
<Stack direction="row" alignItems="flex-start" justifyContent="space-between"> <Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography> <Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography>
<IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}> <IconButton size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
<MoreVertIcon fontSize="small" /> <MoreVertIcon fontSize="small" />
</IconButton> </IconButton>
</Stack> </Stack>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}> <Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Chip size="small" label={v.themeId} /> <Chip size="small" label={v.themeId} />
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} /> <Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label="Public" />} {v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label={t("cvEditorPublic")} />}
</Stack> </Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}> <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
Updated {new Date(v.updatedAtUtc).toLocaleDateString()} · version {v.version} {t("cvDashboardUpdatedVersion", { date: new Date(v.updatedAtUtc).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB"), version: v.version })}
{v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(" at ") || `job #${v.jobApplicationId}`}` : ""} {v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(t("cvDashboardAt")) || t("cvDashboardJobNumber", { id: v.jobApplicationId })}` : ""}
</Typography> </Typography>
</Paper> </Paper>
))} ))}
</Box> </Box>
<Menu anchorEl={menu?.anchor} open={!!menu} onClose={() => setMenu(null)}> <Menu anchorEl={menu?.anchor} open={!!menu} onClose={() => setMenu(null)}>
<MenuItem onClick={() => menu && navigate(`/career/builder/${menu.id}`)}>Open</MenuItem> <MenuItem onClick={() => menu && navigate(`/career/builder/${menu.id}`)}>{t("cvDashboardOpen")}</MenuItem>
<MenuItem onClick={() => menu && duplicate(menu.id)}>Duplicate</MenuItem> <MenuItem onClick={() => menu && duplicate(menu.id)}>{t("cvEditorDuplicate")}</MenuItem>
<MenuItem onClick={() => menu && void rename(menu.id)}>Rename</MenuItem> <MenuItem onClick={() => menu && void rename(menu.id)}>{t("cvDashboardRename")}</MenuItem>
<MenuItem onClick={() => { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>Download PDF</MenuItem> <MenuItem onClick={() => { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>{t("cvEditorDownloadPdf")}</MenuItem>
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>Delete</MenuItem> <MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>{t("cvDashboardDelete")}</MenuItem>
</Menu> </Menu>
<Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title"> <Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title">
<DialogTitle id="create-cv-title">Create a CV</DialogTitle> <DialogTitle id="create-cv-title">{t("cvDashboardCreateTitle")}</DialogTitle>
<DialogContent> <DialogContent>
<Typography color="text.secondary" sx={{ mb: 2 }}>Start with your saved career profile, choose a visual direction, then tailor what appears.</Typography> <Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
<TextField autoFocus fullWidth label="CV name" value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? "Enter a name." : "For example: Backend Engineer — Acme"} sx={{ mb: 2 }} /> <TextField autoFocus fullWidth label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Choose a template</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" }, gap: 1 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" }, gap: 1 }}>
{(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => <Paper key={theme.id} role="button" tabIndex={0} aria-pressed={newTheme === theme.id} onClick={() => setNewTheme(theme.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setNewTheme(theme.id); } }} variant="outlined" sx={{ p: 0.75, cursor: "pointer", borderWidth: newTheme === theme.id ? 2 : 1, borderColor: newTheme === theme.id ? "primary.main" : "divider", "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main" } }}><CvTemplateThumbnail theme={theme} height={105} /><Typography variant="body2" sx={{ fontWeight: 700, mt: 0.75 }}>{theme.name}</Typography><Typography variant="caption" color="text.secondary">{theme.category}</Typography></Paper>)} {(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => <Paper key={theme.id} role="button" tabIndex={0} aria-pressed={newTheme === theme.id} onClick={() => setNewTheme(theme.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setNewTheme(theme.id); } }} variant="outlined" sx={{ p: 0.75, cursor: "pointer", borderWidth: newTheme === theme.id ? 2 : 1, borderColor: newTheme === theme.id ? "primary.main" : "divider", "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main" } }}><CvTemplateThumbnail theme={theme} height={105} /><Typography variant="body2" sx={{ fontWeight: 700, mt: 0.75 }}>{theme.name}</Typography><Typography variant="caption" color="text.secondary">{theme.category}</Typography></Paper>)}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>Cancel</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? "Creating" : "Create CV"}</Button></DialogActions> <DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
</Dialog> </Dialog>
</Box> </Box>
); );