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.",
assetsDiscardChanges: "Discard changes",
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",
richTextBoldPlaceholder: "bold text",
richTextItalic: "Italic",
@@ -1567,6 +1612,51 @@ export const translations = {
assetsCoverPlaceholder: "Skriv selv, start fra malen eller generer et tilpasset utkast.",
assetsDiscardChanges: "Forkast endringer",
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",
richTextBoldPlaceholder: "fet tekst",
richTextItalic: "Kursiv",
+52 -48
View File
@@ -35,6 +35,7 @@ import {
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
} from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
const FONTS = [
"'Segoe UI', Roboto, Arial, sans-serif",
@@ -65,6 +66,7 @@ export default function CvBuilderEditor() {
const variantId = Number(id);
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const compactEditor = useMediaQuery("(max-width:899.95px)");
@@ -410,7 +412,7 @@ export default function CvBuilderEditor() {
if (loadError) {
return (
<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>
</Box>
);
@@ -424,26 +426,26 @@ export default function CvBuilderEditor() {
<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" }}>
<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)}
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 }}
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()} />
<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="Redo"><span><IconButton size="small" aria-label="Redo" disabled={!canRedo} onClick={redo}><RedoIcon 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={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 }}>
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>Edit</Button>
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>Preview</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")}>{t("cvEditorPreview")}</Button>
</Stack>}
<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}>
{publishing ? "Updating" : isPublic ? "Public" : "Private"}
{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorPublic") : t("cvEditorPrivate")}
</Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting" : "Download PDF"}</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 ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
</Stack>
</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" }}>
<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="Template" />
<Tab label="Design" />
<Tab label="Layout" />
<Tab label="History" />
<Tab label={t("cvEditorContent")} />
<Tab label={t("cvEditorTemplate")} />
<Tab label={t("cvEditorDesign")} />
<Tab label={t("cvEditorLayout")} />
<Tab label={t("cvEditorHistory")} />
</Tabs>
{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 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
{previewError && <Chip size="small" label="Preview unavailable" color="error" variant="outlined" />}
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>Retry preview</Button>}
<Typography variant="caption" sx={{ fontWeight: 700 }}>{t("cvEditorLivePreview")}</Typography>
{previewing && <Chip size="small" label={t("cvEditorUpdatingPreview")} variant="outlined" />}
{previewError && <Chip size="small" label={t("cvEditorPreviewUnavailable")} color="error" variant="outlined" />}
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}</Button>}
<Box sx={{ flex: 1 }} />
{pages >= 3 && <Chip size="small" color="warning" variant="outlined" label={`${pages}-page CV`} />}
<Stack direction="row" alignItems="center" spacing={0.5}>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button>
<Typography variant="caption">Page {page} of {pages}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>{t("cvEditorPrevious")}</Button>
<Typography variant="caption">{t("cvEditorPageOf", { page, pages })}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>{t("cvEditorNext")}</Button>
</Stack>
<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>
<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" />
<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("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={t("cvEditorZoom")} />
<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>
<Button size="small" onClick={fitPreview}>Fit</Button>
<Button size="small" onClick={fitPreview}>{t("cvEditorFit")}</Button>
</Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</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>}
<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" }} />
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
<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 }}>
<Stack spacing={3} alignItems="center">
{Array.from({ length: pages }).map((_, index) => (
<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" }}>
<iframe
title={`CV preview page ${index + 1}`}
title={t("cvEditorPreviewPage", { page: index + 1 })}
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm, index)}
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" }}
@@ -528,12 +530,13 @@ function EditorSkeleton() {
}
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" }> = {
idle: { label: "", color: "default" },
unsaved: { label: "Unsaved", color: "warning" },
saving: { label: "Saving", color: "warning" },
saved: { label: "Saved", color: "success" },
error: { label: "Save failed", color: "error" },
unsaved: { label: t("cvEditorUnsaved"), color: "warning" },
saving: { label: t("cvEditorSaving"), color: "warning" },
saved: { label: t("cvEditorSaved"), color: "success" },
error: { label: t("cvEditorSaveFailed"), color: "error" },
};
const m = map[state];
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">
<Chip size="small" label={m.label} color={m.color} variant="outlined" />
{(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}
</Stack>
);
@@ -950,32 +953,33 @@ function ControlSlider({ label, value, min, max, step, suffix = "", onChange }:
}
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 (
<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) => (
<Paper key={v.version} variant="outlined" sx={{ p: 1, display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ flex: 1 }}>
<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 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>
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>Restore</Button>}
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
</Paper>
))}
</Stack>
);
}
function relTime(iso: string): string {
function relTime(iso: string, language: "en" | "nb"): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const mins = Math.round(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
if (mins < 1) return language === "nb" ? "akkurat nå" : "just now";
if (mins < 60) return language === "nb" ? `${mins} min siden` : `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return new Date(iso).toLocaleDateString();
if (hrs < 24) return language === "nb" ? `${hrs} t siden` : `${hrs}h ago`;
return new Date(iso).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB");
}