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
@@ -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>;
}