feat(cv): expand builder studio tools

This commit is contained in:
cesnimda
2026-08-29 01:45:30 +02:00
parent f794265e3e
commit 0f17d95c57
10 changed files with 434 additions and 47 deletions
+267 -27
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
import {
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
MenuItem, Paper, Select, Skeleton, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -22,11 +22,16 @@ import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import TranslateIcon from "@mui/icons-material/Translate";
import SpellcheckIcon from "@mui/icons-material/Spellcheck";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useAccountPlan } from "../accountPlan";
import RichTextField from "../components/RichTextField";
import AiSectionAssistant from "../components/cv/AiSectionAssistant";
import ProFeatureNotice from "../components/ProFeatureNotice";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
import { useDragReorder } from "../hooks/useDragReorder";
import {
@@ -453,17 +458,17 @@ 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={t("cvEditorContent")} />
<Tab label={t("cvEditorTemplate")} />
<Tab label={t("cvEditorDesign")} />
<Tab label={t("cvEditorLayout")} />
<Tab label={t("cvEditorContent")} />
<Tab label={t("cvEditorCustomize")} />
<Tab label={t("cvEditorAiTools")} />
<Tab label={t("cvEditorHistory")} />
</Tabs>
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
{tab === 1 && <CustomizeTab mode="template" settings={settings} update={update} themes={themes} />}
{tab === 2 && <CustomizeTab mode="design" settings={settings} update={update} themes={themes} />}
{tab === 3 && <CustomizeTab mode="layout" settings={settings} update={update} themes={themes} />}
{tab === 0 && <CustomizeTab mode="template" settings={settings} update={update} themes={themes} />}
{tab === 1 && <ContentTab settings={settings} update={update} outline={outline} />}
{tab === 2 && <CustomizeTab mode="customize" settings={settings} update={update} themes={themes} />}
{tab === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
</Paper>
@@ -550,6 +555,194 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
);
}
// ---------- Whole-document AI ----------
type DocumentAiBlock = {
id: string;
title?: string;
subtitle?: string;
items?: string[];
};
function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null): DocumentAiBlock[] {
const blocks: DocumentAiBlock[] = [];
const configured = new Map(settings.sections.map((section) => [section.key, section]));
if ((settings.headline ?? outline?.headline)?.trim()) {
blocks.push({ id: "headline", items: [(settings.headline ?? outline?.headline ?? "").trim()] });
}
for (const section of outline?.sections ?? []) {
const sectionSetting = configured.get(section.key);
if (sectionSetting?.hidden) continue;
const title = sectionSetting?.title ?? section.title;
if (section.kind === "entries") {
const orderedKeys = sectionSetting?.itemOrder?.length
? [...sectionSetting.itemOrder, ...section.entries.map((entry, index) => entry.key ?? `${index}`).filter((key) => !sectionSetting.itemOrder?.includes(key))]
: section.entries.map((entry, index) => entry.key ?? `${index}`);
const byKey = new Map(section.entries.map((entry, index) => [entry.key ?? `${index}`, entry]));
for (const key of orderedKeys) {
const entry = byKey.get(key);
if (!entry) continue;
const override = settings.overrides[key] ?? {};
if (override.hidden) continue;
blocks.push({
id: `entry:${section.key}:${key}`,
title: override.title ?? entry.title,
subtitle: override.subtitle ?? entry.subtitle,
items: override.bullets ?? entry.bullets,
});
}
} else {
blocks.push({ id: `section:${section.key}`, title, items: sectionSetting?.items ?? (section.kind === "tags" ? section.tags : section.bullets) });
}
}
for (const section of settings.customSections ?? []) {
if (!section.hidden) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
}
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
}
function parseDocumentAiBlocks(value: string, originals: DocumentAiBlock[]): DocumentAiBlock[] | null {
try {
const start = value.indexOf("[");
const end = value.lastIndexOf("]");
if (start < 0 || end <= start) return null;
const parsed = JSON.parse(value.slice(start, end + 1));
if (!Array.isArray(parsed)) return null;
const originalIds = new Set(originals.map((block) => block.id));
const result = parsed.filter((block): block is DocumentAiBlock => block && typeof block.id === "string" && originalIds.has(block.id)).map((block) => {
const original = originals.find((item) => item.id === block.id);
return {
id: block.id,
title: typeof block.title === "string" ? block.title : original?.title,
subtitle: typeof block.subtitle === "string" ? block.subtitle : original?.subtitle,
items: Array.isArray(block.items) ? block.items.filter((item): item is string => typeof item === "string") : original?.items,
};
});
return result.length === originals.length && new Set(result.map((block) => block.id)).size === originals.length ? result : null;
} catch {
return null;
}
}
function applyDocumentAiBlocks(settings: CvVariantSettings, blocks: DocumentAiBlock[]): CvVariantSettings {
const byId = new Map(blocks.map((block) => [block.id, block]));
const headline = byId.get("headline")?.items?.[0];
const sections = settings.sections.map((section) => {
const block = byId.get(`section:${section.key}`);
return block ? { ...section, title: block.title, items: block.items } : section;
});
const configuredKeys = new Set(sections.map((section) => section.key));
for (const block of blocks) {
if (!block.id.startsWith("section:")) continue;
const key = block.id.slice("section:".length);
if (!configuredKeys.has(key)) sections.push({ key, title: block.title, items: block.items });
}
const overrides = { ...settings.overrides };
for (const block of blocks) {
if (!block.id.startsWith("entry:")) continue;
const key = block.id.split(":").slice(2).join(":");
overrides[key] = { ...overrides[key], title: block.title, subtitle: block.subtitle, bullets: block.items };
}
const customSections = settings.customSections.map((section) => {
const block = byId.get(`custom:${section.key}`);
return block ? { ...section, title: block.title, items: block.items ?? [] } : section;
});
return { ...settings, ...(headline !== undefined ? { headline } : {}), sections, overrides, customSections };
}
function DocumentAiTab({ settings, outline, update }: {
settings: CvVariantSettings;
outline: CvOutline | null;
update: (patch: Partial<CvVariantSettings>) => void;
}) {
const { t } = useI18n();
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
const blocks = useMemo(() => documentAiBlocks(settings, outline), [settings, outline]);
const [targetLanguage, setTargetLanguage] = useState(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en");
const [busy, setBusy] = useState<string | null>(null);
const [suggestion, setSuggestion] = useState<DocumentAiBlock[] | null>(null);
useEffect(() => setTargetLanguage(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en"), [settings.language]);
const run = async (action: string) => {
if (!blocks.length) return;
setBusy(action);
setSuggestion(null);
try {
const response = await cvBuilderApi.aiAssist({
action,
text: JSON.stringify(blocks),
language: targetLanguage,
context: "This is a complete CV represented as JSON blocks. Return only a JSON array with every original id exactly once and the same title, subtitle and items fields. Preserve all facts, employers, dates, qualifications and technologies. Never add unsupported claims.",
});
const parsed = parseDocumentAiBlocks(response.result, blocks);
if (!parsed) throw new Error(t("cvDocumentAiInvalid"));
setSuggestion(parsed);
} catch (error) {
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
} finally {
setBusy(null);
}
};
const actions = [
{ key: "document-translate", title: t("cvDocumentAiTranslate"), description: t("cvDocumentAiTranslateHelp"), icon: <TranslateIcon /> },
{ key: "document-improve", title: t("cvDocumentAiImprove"), description: t("cvDocumentAiImproveHelp"), icon: <AutoFixHighIcon /> },
{ key: "document-grammar", title: t("cvDocumentAiGrammar"), description: t("cvDocumentAiGrammarHelp"), icon: <SpellcheckIcon /> },
];
const changed = suggestion?.filter((block) => JSON.stringify(block) !== JSON.stringify(blocks.find((original) => original.id === block.id))) ?? [];
return (
<Stack spacing={2}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 850 }}>{t("cvDocumentAiTitle")}</Typography>
<Typography variant="body2" color="text.secondary">{t("cvDocumentAiHelp")}</Typography>
</Box>
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
</TextField>
<Stack spacing={1}>
{actions.map((action) => (
<Paper key={action.key} variant="outlined" sx={{ p: 1.5 }}>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Box sx={{ color: "primary.main", pt: 0.25 }}>{action.icon}</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{action.title}</Typography>
<Typography variant="body2" color="text.secondary">{action.description}</Typography>
</Box>
<Button variant="outlined" size="small" disabled={!canUseAi || !!busy || !blocks.length} onClick={() => void run(action.key)}>
{busy === action.key ? t("cvAiWorking") : t("cvDocumentAiReview")}
</Button>
</Stack>
</Paper>
))}
</Stack>
{suggestion && (
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvDocumentAiReviewTitle", { count: changed.length })}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("cvDocumentAiReviewHelp")}</Typography>
{changed.length === 0 ? <Alert severity="info">{t("cvDocumentAiNoChanges")}</Alert> : (
<Stack spacing={1} sx={{ maxHeight: 360, overflowY: "auto" }}>
{changed.map((block) => {
const original = blocks.find((item) => item.id === block.id);
return <Paper key={block.id} variant="outlined" sx={{ p: 1 }}><Typography variant="caption" sx={{ fontWeight: 800 }}>{block.title ?? original?.title ?? block.id}</Typography><Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1, mt: 0.5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{[original?.subtitle, ...(original?.items ?? [])].filter(Boolean).join("\n")}</Typography><Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{[block.subtitle, ...(block.items ?? [])].filter(Boolean).join("\n")}</Typography></Box></Paper>;
})}
</Stack>
)}
<Alert severity="info" sx={{ mt: 1.5 }}>{t("cvAiCheckFacts")}</Alert>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
<Button variant="contained" disabled={!changed.length} onClick={() => { const next = applyDocumentAiBlocks(settings, suggestion); update(next); setSuggestion(null); toast(t("cvDocumentAiApplied"), "success"); }}>{t("cvAiApply")}</Button>
<Button onClick={() => setSuggestion(null)}>{t("cvAiReject")}</Button>
</Stack>
</Paper>
)}
</Stack>
);
}
// ---------- Content tab ----------
type Translate = ReturnType<typeof useI18n>["t"];
@@ -579,12 +772,13 @@ function ContentTab({ settings, update, outline }: {
}) {
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [sectionToAdd, setSectionToAdd] = useState("free-text");
const [addContentOpen, setAddContentOpen] = useState(false);
// Full section list = configured order (once touched) else default, always including every known key.
const sectionRows: CvSectionSetting[] = useMemo(() => {
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
const populated = new Set((outline?.sections ?? []).map((section) => section.key));
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key, hidden: !populated.has(key) }));
const have = new Set(base.map((s) => s.key));
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key });
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key, hidden: !populated.has(key) });
for (const section of outline?.sections ?? []) if (!have.has(section.key)) {
base.push({ key: section.key, title: section.title });
have.add(section.key);
@@ -615,8 +809,8 @@ function ContentTab({ settings, update, outline }: {
settings.customSections.map((section) => [`custom:${section.key}`, section]),
), [settings.customSections]);
const addCustom = () => {
const preset = CUSTOM_SECTION_PRESETS.find((item) => item.key === sectionToAdd);
const addCustom = (type: string) => {
const preset = CUSTOM_SECTION_PRESETS.find((item) => item.key === type);
if (preset && settings.customSections.some((item) => item.presetKey === preset.key)) return;
const key = `c${Date.now().toString(36)}`;
update({
@@ -629,6 +823,11 @@ function ContentTab({ settings, update, outline }: {
}],
sections: [...sectionRows, { key: `custom:${key}` }],
});
setAddContentOpen(false);
};
const addBuiltIn = (key: string) => {
patchSection(key, { hidden: false });
setAddContentOpen(false);
};
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
@@ -688,21 +887,45 @@ function ContentTab({ settings, update, outline }: {
</Stack>
</Box>
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorAddSection")}</Typography>
<Typography variant="caption" color="text.secondary">{t("cvEditorAddSectionHelp")}</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
<TextField select size="small" fullWidth label={t("cvEditorSectionType")} value={sectionToAdd} onChange={(event) => setSectionToAdd(event.target.value)}>
<MenuItem value="free-text">{t("cvEditorFreeTextSection")}</MenuItem>
{CUSTOM_SECTION_PRESETS.map((preset) => <MenuItem key={preset.key} value={preset.key} disabled={settings.customSections.some((item) => item.presetKey === preset.key)}>{presetLabel(preset.key, preset.title, t)}</MenuItem>)}
</TextField>
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>{t("cvEditorAddSectionButton")}</Button>
</Stack>
</Paper>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddContentOpen(true)} sx={{ alignSelf: "center", minWidth: 190 }}>{t("cvEditorAddContent")}</Button>
<Dialog open={addContentOpen} onClose={() => setAddContentOpen(false)} fullWidth maxWidth="md" aria-labelledby="cv-add-content-title">
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("cvEditorAddContentHelp")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", md: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
{DEFAULT_SECTION_ORDER.map((key) => {
const row = sectionRows.find((section) => section.key === key);
const added = !!row && !row.hidden;
const label = sectionName(key, SECTION_LABELS[key] ?? key, t);
return <AddContentCard key={key} title={label} description={t("cvEditorAddBuiltInHelp", { section: label })} added={added} onAdd={() => addBuiltIn(key)} />;
})}
{CUSTOM_SECTION_PRESETS.map((preset) => {
const added = settings.customSections.some((item) => item.presetKey === preset.key);
const label = presetLabel(preset.key, preset.title, t);
return <AddContentCard key={preset.key} title={label} description={t("cvEditorAddPresetHelp", { section: label })} added={added} onAdd={() => addCustom(preset.key)} />;
})}
<AddContentCard title={t("cvEditorCustom")} description={t("cvEditorAddCustomHelp")} added={false} onAdd={() => addCustom("free-text")} />
</Box>
</DialogContent>
<DialogActions><Button onClick={() => setAddContentOpen(false)}>{t("cancel")}</Button></DialogActions>
</Dialog>
</Stack>
);
}
function AddContentCard({ title, description, added, onAdd }: { title: string; description: string; added: boolean; onAdd: () => void }) {
const { t } = useI18n();
return (
<Paper variant="outlined" sx={{ p: 1.25, minHeight: 112, display: "flex", flexDirection: "column", alignItems: "flex-start", bgcolor: added ? "action.disabledBackground" : "background.paper" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, mt: 0.25 }}>{description}</Typography>
<Button size="small" startIcon={!added ? <AddIcon /> : undefined} disabled={added} onClick={onAdd} sx={{ mt: 0.75, ml: -0.75 }}>
{added ? t("cvEditorContentAdded") : t("cvEditorAdd")}
</Button>
</Paper>
);
}
function SectionRow({
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
}: {
@@ -765,6 +988,23 @@ function SectionRow({
</Stack>
<Collapse in={expanded} unmountOnExit>
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
<Paper variant="outlined" sx={{ p: 1, mb: 1.25, bgcolor: "background.paper" }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 800 }}>{t("cvEditorSectionLayout")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t("cvEditorSectionLayoutHelp")}</Typography>
</Box>
<TextField select size="small" label={t("cvEditorPresentation")} value={row.presentation ?? "rows"} onChange={(event) => onPatch({ presentation: event.target.value as CvSectionSetting["presentation"] })} sx={{ minWidth: 145 }}>
<MenuItem value="rows">{t("cvEditorRows")}</MenuItem>
<MenuItem value="grid">{t("cvEditorGrid")}</MenuItem>
<MenuItem value="compact">{t("cvEditorCompact")}</MenuItem>
{(outlineSection?.kind === "tags" || (!outlineSection && customSection?.contentType !== "entries")) && <MenuItem value="bubble">{t("cvEditorBubble")}</MenuItem>}
</TextField>
{row.presentation === "grid" && <Stack direction="row" spacing={0.5} role="group" aria-label={t("cvEditorColumns")}>
{[1, 2].map((columns) => <Button key={columns} size="small" variant={(row.columns ?? 1) === columns ? "contained" : "outlined"} onClick={() => onPatch({ columns: columns as 1 | 2 })}>{columns}</Button>)}
</Stack>}
</Stack>
</Paper>
{customSection ? (
<Stack spacing={1}>
<TextField select size="small" label={t("cvEditorContentFormat")} value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
@@ -875,7 +1115,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
// ---------- Customize tab ----------
function CustomizeTab({ mode, settings, update, themes }: {
mode: "template" | "design" | "layout";
mode: "template" | "customize";
settings: CvVariantSettings;
update: (p: Partial<CvVariantSettings>) => void;
themes: CvTheme[];
@@ -917,7 +1157,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
</Box>
</Box>}
{mode === "design" && <>
{mode === "customize" && <>
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorColour")}</Typography>
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
@@ -943,7 +1183,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
{settings.skillsStyle === "grouped" && <TextField label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp")} multiline minRows={3} value={(settings.skillGroups ?? []).map((group) => `${group.name}: ${group.items.join(", ")}`).join("\n")} onChange={(event) => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map((line) => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map((item) => item.trim()).filter(Boolean) }; }) })} />}</>}
</>}
{mode === "layout" && <>
{mode === "customize" && <>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorDocument")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
{supports("page") && <>