feat(i18n): localise CV editor chrome

This commit is contained in:
cesnimda
2026-08-28 20:10:47 +02:00
parent 50f276c6c0
commit 10f348db32
2 changed files with 142 additions and 48 deletions
+90
View File
@@ -184,6 +184,51 @@ export const translations = {
assetsCoverPlaceholder: "Write it yourself, start from the template, or generate a tailored draft.", assetsCoverPlaceholder: "Write it yourself, start from the template, or generate a tailored draft.",
assetsDiscardChanges: "Discard changes", assetsDiscardChanges: "Discard changes",
assetsStartTemplate: "Start from template", assetsStartTemplate: "Start from template",
cvEditorBack: "Back to CVs",
cvEditorName: "CV name",
cvEditorNameRequired: "Enter a name before saving.",
cvEditorUndo: "Undo",
cvEditorRedo: "Redo",
cvEditorEdit: "Edit",
cvEditorPreview: "Preview",
cvEditorDuplicate: "Duplicate",
cvEditorUpdating: "Updating…",
cvEditorPublic: "Public",
cvEditorPrivate: "Private",
cvEditorCopyLink: "Copy link",
cvEditorExporting: "Exporting…",
cvEditorDownloadPdf: "Download PDF",
cvEditorContent: "Content",
cvEditorTemplate: "Template",
cvEditorDesign: "Design",
cvEditorLayout: "Layout",
cvEditorHistory: "History",
cvEditorLivePreview: "Live preview",
cvEditorUpdatingPreview: "updating…",
cvEditorPreviewUnavailable: "Preview unavailable",
cvEditorRetryPreview: "Retry preview",
cvEditorPrevious: "Previous",
cvEditorNext: "Next",
cvEditorPageOf: "Page {page} of {pages}",
cvEditorZoomOut: "Zoom out",
cvEditorZoom: "Zoom",
cvEditorZoomIn: "Zoom in",
cvEditorFit: "Fit",
cvEditorOverflowWarning: "The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.",
cvEditorLongDocument: "This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.",
cvEditorPreviewTitle: "CV preview",
cvEditorPage: "Page {page}",
cvEditorPreviewPage: "CV preview page {page}",
cvEditorUnsaved: "Unsaved",
cvEditorSaving: "Saving…",
cvEditorSaved: "Saved",
cvEditorSaveFailed: "Save failed",
cvEditorSaveNow: "Save now",
cvEditorNoVersions: "No saved versions yet. Edits autosave and appear here.",
cvEditorVersionCount: "{count} saved version(s). Restoring keeps history — it adds a new version.",
cvEditorVersion: "Version {version}",
cvEditorCurrent: "current",
cvEditorRestore: "Restore",
richTextBold: "Bold", richTextBold: "Bold",
richTextBoldPlaceholder: "bold text", richTextBoldPlaceholder: "bold text",
richTextItalic: "Italic", richTextItalic: "Italic",
@@ -1567,6 +1612,51 @@ export const translations = {
assetsCoverPlaceholder: "Skriv selv, start fra malen eller generer et tilpasset utkast.", assetsCoverPlaceholder: "Skriv selv, start fra malen eller generer et tilpasset utkast.",
assetsDiscardChanges: "Forkast endringer", assetsDiscardChanges: "Forkast endringer",
assetsStartTemplate: "Start fra mal", assetsStartTemplate: "Start fra mal",
cvEditorBack: "Tilbake til CV-er",
cvEditorName: "CV-navn",
cvEditorNameRequired: "Skriv inn et navn før du lagrer.",
cvEditorUndo: "Angre",
cvEditorRedo: "Gjør om",
cvEditorEdit: "Rediger",
cvEditorPreview: "Forhåndsvis",
cvEditorDuplicate: "Dupliser",
cvEditorUpdating: "Oppdaterer…",
cvEditorPublic: "Offentlig",
cvEditorPrivate: "Privat",
cvEditorCopyLink: "Kopier lenke",
cvEditorExporting: "Eksporterer…",
cvEditorDownloadPdf: "Last ned PDF",
cvEditorContent: "Innhold",
cvEditorTemplate: "Mal",
cvEditorDesign: "Design",
cvEditorLayout: "Oppsett",
cvEditorHistory: "Historikk",
cvEditorLivePreview: "Direkte forhåndsvisning",
cvEditorUpdatingPreview: "oppdaterer…",
cvEditorPreviewUnavailable: "Forhåndsvisning utilgjengelig",
cvEditorRetryPreview: "Prøv forhåndsvisning på nytt",
cvEditorPrevious: "Forrige",
cvEditorNext: "Neste",
cvEditorPageOf: "Side {page} av {pages}",
cvEditorZoomOut: "Zoom ut",
cvEditorZoom: "Zoom",
cvEditorZoomIn: "Zoom inn",
cvEditorFit: "Tilpass",
cvEditorOverflowWarning: "Forhåndsvisningen rapporterte vannrett overflyt. Forkort en ubrutt verdi, eller prøv igjen etter siste gjengivelse.",
cvEditorLongDocument: "Denne CV-en er på {pages} sider. Innholdet er lesbart, men vurder å skjule mindre relevante oppføringer for en mer fokusert søknad.",
cvEditorPreviewTitle: "CV-forhåndsvisning",
cvEditorPage: "Side {page}",
cvEditorPreviewPage: "CV-forhåndsvisning side {page}",
cvEditorUnsaved: "Ikke lagret",
cvEditorSaving: "Lagrer…",
cvEditorSaved: "Lagret",
cvEditorSaveFailed: "Lagring mislyktes",
cvEditorSaveNow: "Lagre nå",
cvEditorNoVersions: "Ingen lagrede versjoner ennå. Endringer lagres automatisk og vises her.",
cvEditorVersionCount: "{count} lagret(e) versjon(er). Gjenoppretting beholder historikken og legger til en ny versjon.",
cvEditorVersion: "Versjon {version}",
cvEditorCurrent: "gjeldende",
cvEditorRestore: "Gjenopprett",
richTextBold: "Fet", richTextBold: "Fet",
richTextBoldPlaceholder: "fet tekst", richTextBoldPlaceholder: "fet tekst",
richTextItalic: "Kursiv", richTextItalic: "Kursiv",
+52 -48
View File
@@ -35,6 +35,7 @@ import {
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
} from "../cvBuilder"; } from "../cvBuilder";
import { useDialogActions } from "../dialogs"; import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
const FONTS = [ const FONTS = [
"'Segoe UI', Roboto, Arial, sans-serif", "'Segoe UI', Roboto, Arial, sans-serif",
@@ -65,6 +66,7 @@ export default function CvBuilderEditor() {
const variantId = Number(id); const variantId = Number(id);
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
const compactEditor = useMediaQuery("(max-width:899.95px)"); const compactEditor = useMediaQuery("(max-width:899.95px)");
@@ -410,7 +412,7 @@ export default function CvBuilderEditor() {
if (loadError) { if (loadError) {
return ( return (
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/career/builder")}>Back to CVs</Button> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/career/builder")}>{t("cvEditorBack")}</Button>
<Alert severity="error" sx={{ mt: 2 }}>{loadError}</Alert> <Alert severity="error" sx={{ mt: 2 }}>{loadError}</Alert>
</Box> </Box>
); );
@@ -424,26 +426,26 @@ export default function CvBuilderEditor() {
<Stack spacing={1.5} sx={{ minWidth: 0 }}> <Stack spacing={1.5} sx={{ minWidth: 0 }}>
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}> <Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap"> <Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
<Tooltip title="Back to CVs"><IconButton size="small" aria-label="Back to CVs" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip> <Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)} <TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)}
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined} error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined}
sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }} sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }}
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} /> slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": t("cvEditorName") } }} />
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} /> <SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} /> <Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} />
<Tooltip title="Undo"><span><IconButton size="small" aria-label="Undo" disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip> <Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tooltip title="Redo"><span><IconButton size="small" aria-label="Redo" disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip> <Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
{compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}> {compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}>
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>Edit</Button> <Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>Preview</Button> <Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
</Stack>} </Stack>}
<Box sx={{ flex: { sm: 1 } }} /> <Box sx={{ flex: { sm: 1 } }} />
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>Duplicate</Button> <Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>{t("cvEditorDuplicate")}</Button>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}> <Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
{publishing ? "Updating" : isPublic ? "Public" : "Private"} {publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorPublic") : t("cvEditorPrivate")}
</Button> </Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>} {isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>{t("cvEditorCopyLink")}</Button>}
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting" : "Download PDF"}</Button> <Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
</Stack> </Stack>
</Paper> </Paper>
@@ -451,11 +453,11 @@ export default function CvBuilderEditor() {
<Paper sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, border: "1px solid", borderColor: "divider" }}> <Paper sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, border: "1px solid", borderColor: "divider" }}>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}> <Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
<Tab label="Content" /> <Tab label={t("cvEditorContent")} />
<Tab label="Template" /> <Tab label={t("cvEditorTemplate")} />
<Tab label="Design" /> <Tab label={t("cvEditorDesign")} />
<Tab label="Layout" /> <Tab label={t("cvEditorLayout")} />
<Tab label="History" /> <Tab label={t("cvEditorHistory")} />
</Tabs> </Tabs>
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />} {tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
@@ -467,35 +469,35 @@ export default function CvBuilderEditor() {
<Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}> <Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography> <Typography variant="caption" sx={{ fontWeight: 700 }}>{t("cvEditorLivePreview")}</Typography>
{previewing && <Chip size="small" label="updating…" variant="outlined" />} {previewing && <Chip size="small" label={t("cvEditorUpdatingPreview")} variant="outlined" />}
{previewError && <Chip size="small" label="Preview unavailable" color="error" variant="outlined" />} {previewError && <Chip size="small" label={t("cvEditorPreviewUnavailable")} color="error" variant="outlined" />}
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>Retry preview</Button>} {previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}</Button>}
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
{pages >= 3 && <Chip size="small" color="warning" variant="outlined" label={`${pages}-page CV`} />} {pages >= 3 && <Chip size="small" color="warning" variant="outlined" label={`${pages}-page CV`} />}
<Stack direction="row" alignItems="center" spacing={0.5}> <Stack direction="row" alignItems="center" spacing={0.5}>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button> <Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>{t("cvEditorPrevious")}</Button>
<Typography variant="caption">Page {page} of {pages}</Typography> <Typography variant="caption">{t("cvEditorPageOf", { page, pages })}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button> <Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>{t("cvEditorNext")}</Button>
</Stack> </Stack>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} /> <Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<IconButton size="small" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" /> <Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} />
<IconButton size="small" aria-label="Zoom in" onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton>
<Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography> <Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
<Button size="small" onClick={fitPreview}>Fit</Button> <Button size="small" onClick={fitPreview}>{t("cvEditorFit")}</Button>
</Stack> </Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>} {previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>} {pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
<iframe ref={iframeRef} title="CV preview" srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} /> <iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}> <Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}>
<Stack spacing={3} alignItems="center"> <Stack spacing={3} alignItems="center">
{Array.from({ length: pages }).map((_, index) => ( {Array.from({ length: pages }).map((_, index) => (
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}> <Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
<Typography variant="caption" sx={{ display: "block", mb: 0.75, color: "#475569", fontWeight: 700 }}>Page {index + 1}</Typography> <Typography variant="caption" sx={{ display: "block", mb: 0.75, color: "#475569", fontWeight: 700 }}>{t("cvEditorPage", { page: index + 1 })}</Typography>
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}> <Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}>
<iframe <iframe
title={`CV preview page ${index + 1}`} title={t("cvEditorPreviewPage", { page: index + 1 })}
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm, index)} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm, index)}
sandbox="allow-same-origin allow-scripts" sandbox="allow-same-origin allow-scripts"
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }} style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
@@ -528,12 +530,13 @@ function EditorSkeleton() {
} }
function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: boolean; onRetry: () => void }) { function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: boolean; onRetry: () => void }) {
const { t } = useI18n();
const map: Record<SaveState, { label: string; color: "default" | "warning" | "success" | "error" }> = { const map: Record<SaveState, { label: string; color: "default" | "warning" | "success" | "error" }> = {
idle: { label: "", color: "default" }, idle: { label: "", color: "default" },
unsaved: { label: "Unsaved", color: "warning" }, unsaved: { label: t("cvEditorUnsaved"), color: "warning" },
saving: { label: "Saving", color: "warning" }, saving: { label: t("cvEditorSaving"), color: "warning" },
saved: { label: "Saved", color: "success" }, saved: { label: t("cvEditorSaved"), color: "success" },
error: { label: "Save failed", color: "error" }, error: { label: t("cvEditorSaveFailed"), color: "error" },
}; };
const m = map[state]; const m = map[state];
if (!m.label) return null; if (!m.label) return null;
@@ -541,7 +544,7 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
<Stack spacing={0.5} alignItems="flex-end" aria-live="polite"> <Stack spacing={0.5} alignItems="flex-end" aria-live="polite">
<Chip size="small" label={m.label} color={m.color} variant="outlined" /> <Chip size="small" label={m.label} color={m.color} variant="outlined" />
{(state === "unsaved" || state === "error") ? ( {(state === "unsaved" || state === "error") ? (
<Button size="small" disabled={!canRetry} onClick={onRetry}>Save now</Button> <Button size="small" disabled={!canRetry} onClick={onRetry}>{t("cvEditorSaveNow")}</Button>
) : null} ) : null}
</Stack> </Stack>
); );
@@ -950,32 +953,33 @@ function ControlSlider({ label, value, min, max, step, suffix = "", onChange }:
} }
function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) { function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) {
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">No saved versions yet. Edits autosave and appear here.</Typography>; const { language, t } = useI18n();
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">{t("cvEditorNoVersions")}</Typography>;
return ( return (
<Stack spacing={0.5}> <Stack spacing={0.5}>
<Typography variant="caption" color="text.secondary">{versions.length} saved version{versions.length === 1 ? "" : "s"}. Restoring keeps history it adds a new version.</Typography> <Typography variant="caption" color="text.secondary">{t("cvEditorVersionCount", { count: versions.length })}</Typography>
{versions.map((v) => ( {versions.map((v) => (
<Paper key={v.version} variant="outlined" sx={{ p: 1, display: "flex", alignItems: "center", gap: 1 }}> <Paper key={v.version} variant="outlined" sx={{ p: 1, display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
Version {v.version} {v.isCurrent && <Chip size="small" label="current" sx={{ ml: 0.5, height: 18 }} />} {t("cvEditorVersion", { version: v.version })} {v.isCurrent && <Chip size="small" label={t("cvEditorCurrent")} sx={{ ml: 0.5, height: 18 }} />}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc)} · {v.source}</Typography> <Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {v.source}</Typography>
</Box> </Box>
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>Restore</Button>} {!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
</Paper> </Paper>
))} ))}
</Stack> </Stack>
); );
} }
function relTime(iso: string): string { function relTime(iso: string, language: "en" | "nb"): string {
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
const diff = Date.now() - then; const diff = Date.now() - then;
const mins = Math.round(diff / 60000); const mins = Math.round(diff / 60000);
if (mins < 1) return "just now"; if (mins < 1) return language === "nb" ? "akkurat nå" : "just now";
if (mins < 60) return `${mins}m ago`; if (mins < 60) return language === "nb" ? `${mins} min siden` : `${mins}m ago`;
const hrs = Math.round(mins / 60); const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`; if (hrs < 24) return language === "nb" ? `${hrs} t siden` : `${hrs}h ago`;
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB");
} }