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