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
+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");
}