feat(cv): redesign document editing with revision-safe letters and writing tools

This commit is contained in:
cesnimda
2026-10-25 22:44:14 +01:00
parent 01e10996c5
commit 2bfaf6c14b
57 changed files with 2859 additions and 118 deletions
@@ -1,10 +1,13 @@
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Stack, TextField, Tooltip, Typography,
Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery, useTheme,
} from "@mui/material";
import RichTextField from "./RichTextField";
import Markdown from "./Markdown";
import { CoverLetterDocumentFields, CoverLetterDocumentPreview } from "./CoverLetterDocument";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RestoreIcon from "@mui/icons-material/Restore";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
@@ -13,7 +16,7 @@ import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
import { getApiErrorMessage } from "../api";
import {
ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi,
ApplicationCv, CoverLetter, CoverLetterDocumentSettings, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi,
} from "../applicationWorkspace";
import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
@@ -470,6 +473,7 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
// ---------- Cover letter ----------
const historySources = new Map<string, TranslationKey>([
["legacy", "historySourceLegacy"],
["manual", "historySourceManual"], ["ai", "historySourceAi"],
["template", "historySourceTemplate"], ["restore", "historySourceRestore"],
]);
@@ -509,13 +513,18 @@ Med vennlig hilsen
} as const;
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const [aiToolbarHost, setAiToolbarHost] = useState<HTMLDivElement | null>(null);
const { language, t } = useI18n();
const workspaceId = useId();
const wide = useMediaQuery(useTheme().breakpoints.up("md"));
const [pane, setPane] = useState("write");
const { confirm } = useConfirm();
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
);
const [draft, setDraft] = useState<string | null>(null);
const [settingsDraft, setSettingsDraft] = useState<CoverLetterDocumentSettings | null>(null);
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
@@ -525,27 +534,38 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
// The textarea is only seeded from the server until the user starts typing, so a reload never
// clobbers unsaved edits.
const text = draft ?? data?.text ?? "";
const dirty = draft !== null && draft !== (data?.text ?? "");
const settings = settingsDraft ?? data?.documentSettings;
const settingsDirty = settingsDraft !== null && JSON.stringify(settingsDraft) !== JSON.stringify(data?.documentSettings);
const dirty = (draft !== null && draft !== (data?.text ?? "")) || settingsDirty;
useEffect(() => {
onDirtyChange?.(dirty);
return () => onDirtyChange?.(false);
}, [dirty, onDirtyChange]);
const save = async (value: string, source = "manual", aiAction?: string) => {
const save = async (value: string, source = "manual", aiAction?: string, exportPdf = false) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction);
const next = !exportPdf || dirty ? await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction, settingsDirty ? settings : undefined) : data;
if (!isCurrent()) return;
setData(next);
setDraft(null);
setSettingsDraft(null);
setDraftAiAction(null);
setError(null);
if (exportPdf) {
if (!Number.isInteger(next.revision) || next.revision <= 0) throw new Error("Missing committed job revision");
const blob = await applicationAssetsApi.exportCoverLetter(jobId, next.revision);
if (!isCurrent()) return;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url; anchor.download = "cover-letter.pdf"; anchor.click(); URL.revokeObjectURL(url);
}
} catch (err) {
if (isCurrent()) setError(assetError(err, "assetsCoverSaveFailed"));
if (isCurrent()) setError(assetError(err, exportPdf ? "letterExportFailed" : "assetsCoverSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
@@ -572,6 +592,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
if (!isCurrent()) return;
setData(next);
setDraft(null);
setSettingsDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
@@ -595,22 +616,41 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy || confirming || loading}
onChange={setDraft}
placeholder={t("assetsCoverPlaceholder")}
/>
{!wide && <Tabs value={pane} onChange={(_, value) => setPane(value)} aria-label={t("assetsLetterWorkspace")} variant="fullWidth">
<Tab value="write" label={t("assetsLetterWrite")} id={`${workspaceId}-write`} aria-controls={`${workspaceId}-write-panel`} />
<Tab value="preview" label={t("assetsLetterPreview")} id={`${workspaceId}-preview`} aria-controls={`${workspaceId}-preview-panel`} />
</Tabs>}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
<Box hidden={!wide && pane !== "write"} id={`${workspaceId}-write-panel`} role={wide ? undefined : "tabpanel"} aria-labelledby={wide ? undefined : `${workspaceId}-write`} sx={{ minWidth: 0 }}>
{settings && <CoverLetterDocumentFields settings={settings} disabled={busy || confirming || loading} onChange={next => { if (!busyRef.current) setSettingsDraft(next); }} />}
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy || confirming || loading}
onChange={value => { if (!busyRef.current) setDraft(value); }}
placeholder={t("assetsCoverPlaceholder")}
/>
<div ref={setAiToolbarHost} />
</Box>
<Box hidden={!wide && pane !== "preview"} id={`${workspaceId}-preview-panel`} role={wide ? undefined : "tabpanel"} aria-labelledby={wide ? undefined : `${workspaceId}-preview`} sx={{ minWidth: 0 }}>
{settings && <Box sx={{ mb: 2 }}><CoverLetterDocumentPreview key={jobId} jobId={jobId} text={text} settings={settings} active={wide || pane === "preview"} /></Box>}
{!settings && <Paper variant="outlined" role="region" aria-labelledby={`${workspaceId}-preview-title`} sx={{ p: 2, borderRadius: 2, overflowWrap: "anywhere" }}>
<Typography id={`${workspaceId}-preview-title`} component="h4" variant="subtitle2">{t("assetsLetterTextPreview")}</Typography>
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2 }}>{t("assetsLetterTextPreviewHelp")}</Typography>
{text.trim() ? <Markdown text={text} /> : <Typography variant="body2" color="text.secondary">{t("assetsLetterTextPreviewEmpty")}</Typography>}
</Paper>}
</Box>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || confirming || loading || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
{t("save")}
</Button>
<Button disabled={busy || confirming || loading || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
<Button disabled={busy || confirming || loading || !dirty} onClick={() => { setDraft(null); setSettingsDraft(null); setDraftAiAction(null); }}>
{t("assetsDiscardChanges")}
</Button>
{settings && <Button variant="outlined" startIcon={<DownloadOutlinedIcon />} disabled={busy || confirming || loading} onClick={() => void save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined, true)}>{t("letterExportPdf")}</Button>}
<Button
disabled={busy || confirming || loading || text.trim().length > 0}
onClick={() => setDraft(COVER_LETTER_TEMPLATES[language])}
@@ -626,6 +666,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<CoverLetterAiAssistant
jobId={jobId}
toolbarHost={aiToolbarHost}
currentText={text}
disabled={busy || confirming || loading || !data}
onApply={(value, aiAction) => { if (!busyRef.current && !loading && data) { setDraft(value); setDraftAiAction(aiAction); } }}
@@ -696,7 +737,7 @@ const COVER_LETTER_ACTIONS = [
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void }) {
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
@@ -711,8 +752,18 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const generate = async () => {
const requestSequence = useRef(0);
useEffect(() => {
requestSequence.current += 1;
setSuggestion(null);
busyRef.current = false;
setBusy(false);
return () => { requestSequence.current += 1; };
}, [currentText, jobId, language, cv?.attachedVariantId, cv?.attachedVersion, canUseAi]);
const generate = async (requestedAction = action) => {
if (busyRef.current || disabled || loadingCv || !canUseAi || !cv?.attachedVariantId) return;
const request = ++requestSequence.current;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
@@ -721,23 +772,32 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
const result = await aiWorkspaceApi.generate(jobId, {
module: "cover-letter",
mode,
action,
action: requestedAction,
targetLanguage: language,
currentText: currentText.trim() || undefined,
extraContext: instructions.trim() || undefined,
});
if (!isCurrent()) return;
if (!isCurrent() || request !== requestSequence.current) return;
const text = result.result.text?.trim() ?? "";
setSuggestion(text ? { text, action } : null);
setSuggestion(text ? { text, action: requestedAction } : null);
} catch (err) {
if (isCurrent()) setError(assetError(err, "coverAiGenerateFailed"));
if (isCurrent() && request === requestSequence.current) {
const sourceRequired = (err as { response?: { data?: unknown } })?.response?.data === "cover_letter_source_required";
setError(sourceRequired ? { kind: "error", message: "", messageKey: "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
}
} finally {
busyRef.current = false;
setBusy(false);
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
}
};
const hasCv = !!cv?.attachedVariantId;
const toolbar = <Stack direction="row" gap={0.5} flexWrap="wrap">
{([['improve', 'cvAiImprove'], ['generate', 'cvAiSuggestContent'], ['grammar', 'cvAiGrammarCheck'], ['shorten', 'cvAiShorter']] as const).map(([key, label]) => (
<Button key={key} size="small" disabled={!canUseAi || !hasCv || busy || disabled || loadingCv || (key !== 'generate' && !currentText.trim())}
onClick={() => { setAction(key); void generate(key); }}>{t(label)}</Button>
))}
{busy && <Button size="small" onClick={() => { requestSequence.current += 1; busyRef.current = false; setBusy(false); setSuggestion(null); }}>{t("cancel")}</Button>}
</Stack>;
return (
<Shell
title={t("coverAiTitle")}
@@ -749,6 +809,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
contentRef={contentRef}
>
<Stack spacing={2}>
{toolbarHost ? createPortal(toolbar, toolbarHost) : toolbar}
{!hasCv ? (
<Alert severity="info">{t("coverAiSelectCv")}</Alert>
) : (
@@ -811,9 +872,10 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
</Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion.text}</Typography>
<TextField label={t("coverAiSuggestion")} fullWidth multiline minRows={3} value={suggestion.text} disabled={busy || disabled}
onChange={event => setSuggestion({ ...suggestion, text: event.target.value })} />
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
</Stack>
</Paper>
@@ -0,0 +1,160 @@
import React, { useEffect, useId, useRef, useState } from "react";
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { api } from "../api";
import { applicationAssetsApi, CoverLetterDocumentSettings } from "../applicationWorkspace";
import { cvBuilderApi, CvTheme, CvVariantSummary, getCvPageMetrics, getCvPreviewFitZoom } from "../cvBuilder";
import { previewDocumentHtml } from "../cvPreview";
import { StructuredCvProfile } from "../profileCv";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
const contactFields = ["senderName", "senderTitle", "senderEmail", "senderPhone", "senderLocation"] as const;
const detailFields = [...contactFields, "recipient", "subject", "date"] as const;
const fieldLabels: Record<typeof detailFields[number], TranslationKey> = {
senderName: "letterSenderName", senderTitle: "letterSenderTitle", senderEmail: "letterSenderEmail", senderPhone: "letterSenderPhone", senderLocation: "letterSenderLocation",
recipient: "letterRecipient", subject: "letterSubject", date: "letterDate",
};
type Props = { settings: CoverLetterDocumentSettings; disabled: boolean; onChange: (settings: CoverLetterDocumentSettings) => void };
export function CoverLetterDocumentFields({ settings, disabled, onChange }: Props) {
const { t } = useI18n();
const [copy, setCopy] = useState<"contact" | "design" | null>(null);
return <Stack spacing={1.5} sx={{ mb: 2 }}>
<Typography component="h4" variant="subtitle2">{t("letterDetails")}</Typography>
<Stack direction="row" gap={1} flexWrap="wrap">
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 }}>
{detailFields.map(key => <TextField key={key} label={t(fieldLabels[key])} size="small" fullWidth multiline={key === "recipient"} value={settings[key]} disabled={disabled}
slotProps={{ htmlInput: { maxLength: key === "recipient" ? 2000 : key === "subject" ? 500 : key === "date" || key === "senderPhone" ? 100 : 300 } }}
onChange={event => onChange({ ...settings, [key]: event.target.value })} />)}
</Box>
{copy && <CopyLetterSnapshot kind={copy} settings={settings} onClose={() => setCopy(null)} onApply={next => { if (!disabled) onChange(next); setCopy(null); }} />}
</Stack>;
}
type ThemeDefaults = CvTheme & { bodyFont: string; baseFontSizePt: number; lineHeight: number };
function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "contact" | "design"; settings: CoverLetterDocumentSettings; onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) {
const { t } = useI18n();
const id = useId();
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
const [selected, setSelected] = useState<number | "">("");
const [snapshot, setSnapshot] = useState<CoverLetterDocumentSettings | null>(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
let current = true;
setLoading(true); setFailed(false);
cvBuilderApi.list().then(rows => { if (current) setVariants(rows); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [attempt]);
useEffect(() => {
let current = true;
setSnapshot(null);
if (!selected) return;
setLoading(true); setFailed(false);
const load = async () => {
const variant = await cvBuilderApi.get(selected);
if (!current) return null;
if (kind === "contact") {
const { data } = await api.get<{ profile: StructuredCvProfile }>("/career/profile");
const contact = data.profile.contact;
return { ...settings, senderName: contact.fullName ?? "", senderTitle: variant.settings.headline?.trim() || contact.headline || "", senderEmail: contact.email ?? "", senderPhone: contact.phone ?? "", senderLocation: contact.location ?? "", sourceCvId: selected };
}
const themes = await cvBuilderApi.themes() as ThemeDefaults[];
const theme = themes.find(row => row.id === variant.settings.themeId && row.available);
if (!theme || !theme.bodyFont || !theme.baseFontSizePt || !theme.lineHeight || !theme.pageMarginMm) throw new Error("Missing effective theme defaults");
const source = variant.settings;
const density = source.density === "compact" ? 0.82 : source.density === "roomy" ? 1.18 : 1;
const margin = Math.min(28, Math.max(8, Math.round((source.pageMarginMm ?? theme.pageMarginMm) * density * 100) / 100));
return { ...settings, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont,
baseFontSizePt: source.baseFontSizePt ?? theme.baseFontSizePt, lineHeight: source.lineHeight ?? theme.lineHeight,
pageMarginMm: margin, pageSize: source.pageSize ?? "a4", sourceCvId: selected };
};
load().then(next => { if (current) setSnapshot(next); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
return () => { current = false; };
// The dialog captures the original local draft until the user explicitly applies or cancels.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected, kind, attempt]);
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
<DialogTitle id={`${id}-title`}>{t(kind === "contact" ? "letterCopyContact" : "letterMatchDesign")}</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<Typography id={`${id}-help`} variant="body2">{t("letterCopyHelp")}</Typography>
<TextField select fullWidth size="small" label={t("letterSourceCv")} value={selected} disabled={loading} onChange={event => setSelected(Number(event.target.value))}>
{variants.map(variant => <MenuItem key={variant.id} value={variant.id}>{variant.name}</MenuItem>)}
</TextField>
{!loading && !failed && variants.length === 0 && <Typography variant="body2">{t("assetsNoCvVariants")}</Typography>}
{failed && <Alert severity="error" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterCopyFailed")}</Alert>}
{snapshot && <Box role="region" aria-label={t(kind === "contact" ? "letterContactSnapshot" : "letterDesignSnapshot")} sx={{ overflowWrap: "anywhere" }}>
{kind === "contact" ? contactFields.map(key => <Typography key={key} variant="body2">{t(fieldLabels[key])}: {snapshot[key]}</Typography>) : <>
<Typography variant="body2">{snapshot.themeId} · {snapshot.accentColor} · {snapshot.bodyFont}</Typography>
<Typography variant="body2">{snapshot.baseFontSizePt} pt · {snapshot.lineHeight} · {snapshot.pageMarginMm} mm · {snapshot.pageSize}</Typography>
</>}
</Box>}
</Stack>
</DialogContent>
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={() => snapshot && onApply(snapshot)}>{t("letterCopyApply")}</Button></DialogActions>
</Dialog>;
}
export function CoverLetterDocumentPreview({ jobId, text, settings, active }: { jobId: number; text: string; settings: CoverLetterDocumentSettings; active: boolean }) {
const { t } = useI18n();
const host = useRef<HTMLDivElement>(null);
const frame = useRef<HTMLIFrameElement>(null);
const [html, setHtml] = useState("");
const [pageSize, setPageSize] = useState(settings.pageSize);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(0);
const [zoom, setZoom] = useState(0.5);
const metrics = getCvPageMetrics(pageSize);
useEffect(() => {
let current = true;
if (!active) return;
setLoading(true); setFailed(false);
const timer = window.setTimeout(() => {
applicationAssetsApi.previewCoverLetter(jobId, text, settings).then(result => {
if (!current) return;
setHtml(result.html); setPageSize(settings.pageSize); setPage(0); setPages(1);
}).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
}, 300);
return () => { current = false; window.clearTimeout(timer); };
}, [jobId, text, settings, active, attempt]);
useEffect(() => {
const element = host.current;
if (!element || !active) return;
const fit = () => setZoom(getCvPreviewFitZoom(element.clientWidth || metrics.widthPx, metrics.widthPx, 16, 0.2));
fit();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(fit); observer.observe(element);
return () => observer.disconnect();
}, [metrics.widthPx, active]);
const measure = () => {
const element = frame.current;
const doc = element?.contentDocument;
const ready = () => { if (element === frame.current && doc === element?.contentDocument) setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1); };
if (doc?.documentElement.dataset.cvPreviewReady === "true" || !doc?.querySelector(".page")) ready();
else element?.contentWindow?.addEventListener("cv-preview-ready", ready, { once: true });
};
return <Stack spacing={1}>
<Typography component="h4" variant="subtitle2">{t("letterDocumentPreview")}</Typography>
{loading && <Typography role="status" variant="caption">{t("letterPreviewLoading")}</Typography>}
{failed && <Alert severity="warning" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterPreviewFailed")}</Alert>}
<Box ref={host} aria-busy={loading} sx={{ minWidth: 0, overflow: "auto", p: 1, bgcolor: "background.default" }}>
{html && <Box sx={{ width: metrics.widthPx * zoom, height: metrics.heightPx * zoom, mx: "auto", bgcolor: "common.white", boxShadow: 1, overflow: "hidden" }}>
<iframe ref={frame} title={t("letterDocumentPreview")} srcDoc={previewDocumentHtml(html, metrics.heightPx, page)} sandbox="allow-same-origin allow-scripts" onLoad={measure}
style={{ display: "block", border: 0, width: metrics.widthPx, height: metrics.heightPx, transform: `scale(${zoom})`, transformOrigin: "top left", background: "white" }} />
</Box>}
</Box>
{pages > 1 && <Stack direction="row" justifyContent="space-between" alignItems="center" gap={1}>
<Button disabled={page === 0} onClick={() => setPage(value => value - 1)}>{t("cvEditorPrevious")}</Button>
<Typography variant="caption">{t("letterPage", { page: page + 1, count: pages })}</Typography>
<Button disabled={page >= pages - 1} onClick={() => setPage(value => value + 1)}>{t("cvEditorNext")}</Button>
</Stack>}
</Stack>;
}
@@ -28,6 +28,11 @@ const templateLabels = new Map<string, [TranslationKey, TranslationKey, Translat
["code", ["cvTemplateCodeName", "cvTemplateCategoryTechnical", "cvTemplateCodeDescription"]],
]);
export function getCvTemplateLabels(theme: CvTheme, language: string, t: (key: TranslationKey) => string) {
const labels = language === "nb" ? templateLabels.get(theme.id) : undefined;
return { name: labels ? t(labels[0]) : theme.name, category: labels ? t(labels[1]) : theme.category, description: labels ? t(labels[2]) : theme.description };
}
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
const { t, language } = useI18n();
const id = useId();
@@ -46,8 +51,7 @@ export default function CvTemplateGallery({ themes, selectedId, onSelect, previe
const active = theme.id === selectedId;
const locked = theme.available === false;
const unavailable = locked || disabled;
const labels = language === "nb" ? templateLabels.get(theme.id) : undefined;
const name = labels ? t(labels[0]) : theme.name;
const { name, category, description } = getCvTemplateLabels(theme, language, t);
return (
<Paper key={theme.id} variant="outlined" role="button" tabIndex={unavailable ? -1 : 0} aria-label={name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={unavailable} aria-pressed={active}
onClick={() => { if (!unavailable) onSelect(theme); }}
@@ -56,8 +60,8 @@ export default function CvTemplateGallery({ themes, selectedId, onSelect, previe
{active && <CheckCircleIcon aria-label={t("cvEditorSelectedTemplate")} color="primary" sx={{ position: "absolute", zIndex: 2, top: 6, right: 6, bgcolor: "background.paper", borderRadius: "50%" }} />}
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={previewHeight} /></Box>
<Typography variant="body2" sx={{ fontWeight: 750 }}>{name}</Typography>
<Typography id={`${id}-${theme.id}-category`} variant="caption" color="text.secondary" sx={{ display: "block" }}>{labels ? t(labels[1]) : theme.category}</Typography>
{showDescriptions && <Typography id={`${id}-${theme.id}-description`} variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{labels ? t(labels[2]) : theme.description}</Typography>}
<Typography id={`${id}-${theme.id}-category`} variant="caption" color="text.secondary" sx={{ display: "block" }}>{category}</Typography>
{showDescriptions && <Typography id={`${id}-${theme.id}-description`} variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{description}</Typography>}
<Stack id={`${id}-${theme.id}-features`} direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
{theme.requiresPro && <Chip size="small" label={locked ? t("cvEditorPro") : t("cvEditorProUnlocked")} color="secondary" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
@@ -18,7 +18,7 @@ type Props = CvAiCallbacks & {
targetId: string;
};
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
const SECTION_ACTIONS = new Set(["improve", "suggest", "professional", "shorten", "grammar", "impact", "bullets"]);
export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, targetId, onGenerate, onAccept }: Props) {
const { t } = useI18n();
@@ -42,7 +42,7 @@ export default function AiSectionAssistant({ sectionName, text, context, documen
setBusy(false);
setApplying(false);
return () => { requestId.current += 1; };
}, [text, targetId, normalizedLanguage, canUseAi]);
}, [text, targetId, normalizedLanguage, targetLanguage, canUseAi]);
const discard = () => {
requestId.current += 1;
@@ -52,13 +52,13 @@ export default function AiSectionAssistant({ sectionName, text, context, documen
setApplying(false);
};
const run = async () => {
const run = async (requestedAction = action) => {
if (!text.trim() || !canUseAi || busy) return;
const request = ++requestId.current;
setBusy(true);
try {
const response = await onGenerate({
action,
action: requestedAction,
text,
targetId,
language: targetLanguage,
@@ -94,9 +94,17 @@ export default function AiSectionAssistant({ sectionName, text, context, documen
if (!open) {
return (
<Stack direction="row" gap={0.5} flexWrap="wrap" aria-label={t("cvAiSuggestions")}>
{(["improve", "suggest", "grammar", "shorten"] as const).map(key => (
<Button key={key} size="small" disabled={!canUseAi || (key !== "suggest" && !text.trim())}
onClick={() => { setAction(key); setOpen(true); void run(key); }}>
{key === "suggest" ? t("cvAiSuggestContent") : key === "grammar" ? t("cvAiGrammarCheck") : key === "shorten" ? t("cvAiShorter") : t("cvAiImprove")}
</Button>
))}
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
{t("cvAiSuggestions")}
</Button>
</Stack>
);
}
@@ -114,6 +122,7 @@ export default function AiSectionAssistant({ sectionName, text, context, documen
return (
<Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
<Stack spacing={1}>
{!text.trim() && <Alert severity="info">{t("cvAiNeedSource")}</Alert>}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
<TextField select size="small" disabled={busy} label={t("cvAiAction")} value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{actionLabel(item.key, item.label, t)}</MenuItem>)}
@@ -146,6 +155,7 @@ type Translate = ReturnType<typeof useI18n>["t"];
function actionLabel(key: string, fallback: string, t: Translate) {
const labels: Record<string, string> = {
suggest: t("cvAiSuggestContent"),
improve: t("cvAiImprove"), professional: t("cvAiProfessional"), shorten: t("cvAiShorten"),
grammar: t("cvAiGrammar"), impact: t("cvAiImpact"), bullets: t("cvAiBullets"),
};
@@ -10,6 +10,7 @@ import { applyCvTheme, CvTheme, CvVariantSettings, normalizeCvLanguage, SECTION_
import { useConfirm } from "../../confirm";
import { useI18n } from "../../i18n/I18nProvider";
import CvTemplateGallery from "../CvTemplateGallery";
import CvTemplatePreviewDialog from "./CvTemplatePreviewDialog";
const FONTS = [
"'Segoe UI', Roboto, Arial, sans-serif", "Arial, Helvetica, sans-serif", "Georgia, 'Times New Roman', serif",
@@ -30,11 +31,14 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
const { t } = useI18n();
const { confirm } = useConfirm();
const id = useId();
const [candidate, setCandidate] = useState<CvTheme | null>(null);
const activeTheme = themes.find(theme => theme.id === settings.themeId);
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
const layout = settings.layout ?? activeTheme?.layout;
const selectTemplate = async (theme: CvTheme) => {
if (theme.available === false) return;
setCandidate(null);
const next = applyCvTheme(settings, theme.id);
const resetsOverrides = Object.entries(next).some(([key, value]) => value === null && settings[key as keyof CvVariantSettings] != null);
if (resetsOverrides && !await confirm({ title: t("cvEditorChangeTemplateTitle"), message: t("cvEditorChangeTemplateMessage"), confirmLabel: t("cvEditorApplyTemplate") })) return;
@@ -47,7 +51,8 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
if (mode === "template") return <Stack spacing={1.5}>
<Typography component="h3" variant="subtitle2">{t("cvEditorTemplate")}</Typography>
<Typography variant="body2" color="text.secondary">{t("cvEditorThemeHelp")}</Typography>
<CvTemplateGallery themes={themes} selectedId={settings.themeId} onSelect={selectTemplate} />
<CvTemplateGallery themes={themes} selectedId={settings.themeId} onSelect={setCandidate} />
{candidate && <CvTemplatePreviewDialog key={candidate.id} theme={candidate} onClose={() => setCandidate(null)} onApply={selectTemplate} />}
</Stack>;
return <Stack spacing={2.5}>
@@ -0,0 +1,62 @@
import React, { useEffect, useId, useRef, useState } from "react";
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack, Typography } from "@mui/material";
import { cvBuilderApi, CvRender, CvTheme, getCvPageMetrics, getCvPreviewFitZoom } from "../../cvBuilder";
import { useI18n } from "../../i18n/I18nProvider";
import { getCvTemplateLabels } from "../CvTemplateGallery";
type Props = { theme: CvTheme; onClose: () => void; onApply: (theme: CvTheme) => void };
export default function CvTemplatePreviewDialog({ theme, onClose, onApply }: Props) {
const { t, language } = useI18n();
const labels = getCvTemplateLabels(theme, language, t);
const id = useId();
const host = useRef<HTMLDivElement>(null);
const [sample, setSample] = useState<CvRender | null>(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [zoom, setZoom] = useState(0.5);
const metrics = getCvPageMetrics("a4");
const locked = theme.available === false;
const currentSample = sample?.themeId === theme.id ? sample : null;
useEffect(() => {
let alive = true;
setSample(null);
setFailed(false);
if (!locked) cvBuilderApi.themePreview(theme.id).then(render => {
if (alive) setSample(render);
}).catch(() => { if (alive) setFailed(true); });
return () => { alive = false; };
}, [theme.id, locked, attempt]);
useEffect(() => {
const element = host.current;
if (!element) return;
const fit = () => setZoom(getCvPreviewFitZoom(element.clientWidth, metrics.widthPx, 16, 0.2));
fit();
const observer = new ResizeObserver(fit);
observer.observe(element);
return () => observer.disconnect();
}, [metrics.widthPx, currentSample]);
return <Dialog open onClose={onClose} fullWidth maxWidth="md" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
<DialogTitle id={`${id}-title`}>{t("cvEditorPreviewDesign")} · {labels.name}</DialogTitle>
<DialogContent dividers>
<Stack spacing={1.5}>
<Typography id={`${id}-help`} variant="body2" color="text.secondary">{t("cvEditorTemplateSampleHelp")}</Typography>
<Typography variant="body2">{labels.description}</Typography>
<Typography variant="body2">{t("cvEditorTemplateApplyHelp")}</Typography>
{locked ? <Alert severity="warning">{t("cvEditorPro")}</Alert> : failed ? <Alert severity="error" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("cvEditorRetryPreview")}</Button>}>{t("cvEditorTemplatePreviewFailed")}</Alert> : null}
<Box ref={host} aria-busy={!currentSample && !failed && !locked} sx={{ minWidth: 0, bgcolor: "background.default", p: 1, overflow: "auto" }}>
{currentSample ? <Box sx={{ width: metrics.widthPx * zoom, height: metrics.heightPx * zoom, overflow: "hidden", mx: "auto", bgcolor: "common.white", boxShadow: 1 }}>
<iframe title={t("cvEditorTemplateSampleTitle")} srcDoc={currentSample.html} sandbox="" style={{ display: "block", border: 0, width: metrics.widthPx, height: metrics.heightPx, transform: `scale(${zoom})`, transformOrigin: "top left", background: "white" }} />
</Box> : !failed && !locked ? <Skeleton variant="rectangular" height={400} /> : null}
</Box>
</Stack>
</DialogContent>
<DialogActions sx={{ flexWrap: "wrap", px: 3, py: 2 }}>
<Button autoFocus onClick={onClose}>{t("cancel")}</Button>
<Button variant="contained" disabled={locked || !currentSample || failed} onClick={() => onApply(theme)}>{t("cvEditorApplyTemplate")}</Button>
</DialogActions>
</Dialog>;
}