feat(i18n): localise CV editor chrome
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user