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
@@ -74,6 +74,11 @@ function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }
});
}
async function openContentTab() {
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
}
test('deep link loads the exact variant named in the route', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
@@ -118,6 +123,7 @@ test('failed autosave is visible and can be retried with the latest data', async
mockedApi.put.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: variant } as any);
renderAt(3);
await openContentTab();
fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Platform Engineer' } });
expect(screen.getByText('Unsaved')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
@@ -136,6 +142,7 @@ test('session undo and redo restore content edits before autosave', async () =>
mockedApi.put.mockResolvedValue({ data: variant } as any);
renderAt(3);
await openContentTab();
const headline = await screen.findByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Platform Engineer' } });
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
@@ -146,19 +153,23 @@ test('session undo and redo restore content edits before autosave', async () =>
expect(headline).toHaveValue('Platform Engineer');
});
test('professional editor separates template, design and layout controls', async () => {
test('professional editor starts with templates and combines design and layout controls', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
await screen.findByLabelText('Headline override');
fireEvent.click(screen.getByRole('tab', { name: 'Design' }));
await screen.findByDisplayValue('Backend CV');
expect(screen.getByRole('tab', { name: 'Template' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
expect(screen.getByText('Typography')).toBeInTheDocument();
expect(screen.getByLabelText('Body size')).toBeInTheDocument();
expect(screen.getByLabelText('Skills presentation')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Layout' }));
expect(screen.getByLabelText('Page size')).toBeInTheDocument();
expect(screen.getByLabelText('Columns')).toBeInTheDocument();
expect(screen.getByLabelText('Language')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
expect(screen.getByText('Translate resume')).toBeInTheDocument();
expect(screen.getByText('Improve writing')).toBeInTheDocument();
expect(screen.getByText('Check spelling and grammar')).toBeInTheDocument();
});
test('editor sections, design controls and document settings render in Bokmål', async () => {
@@ -166,13 +177,14 @@ test('editor sections, design controls and document settings render in Bokmål',
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'Innhold' }));
expect(await screen.findByLabelText('Egen overskrift')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Utvid Erfaring' })).toBeInTheDocument();
expect(screen.getByLabelText('Seksjonstype')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Design' }));
expect(screen.getByRole('button', { name: 'Legg til innhold' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Tilpass' }));
expect(screen.getByText('Typografi')).toBeInTheDocument();
expect(screen.getByLabelText('Visning av ferdigheter')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Oppsett' }));
expect(screen.getByLabelText('Sidestørrelse')).toBeInTheDocument();
expect(screen.getByLabelText('Dokumentspråk')).toBeInTheDocument();
});
@@ -181,6 +193,7 @@ test('internal navigation warns and can be cancelled before discarding a pending
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
await openContentTab();
fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Unsaved headline' } });
fireEvent.click(screen.getByRole('button', { name: 'Back to CVs' }));
const dialog = await screen.findByRole('dialog', { name: 'Discard unsaved CV changes?' });
@@ -195,10 +208,14 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
mockedApi.put.mockResolvedValue({ data: variant } as any);
renderAt(3);
await openContentTab();
await screen.findByLabelText('Headline override');
fireEvent.mouseDown(screen.getByLabelText('Section type'));
fireEvent.click(await screen.findByRole('option', { name: 'Additional Experience' }));
fireEvent.click(screen.getByRole('button', { name: 'Add section' }));
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
const addContentDialog = await screen.findByRole('dialog', { name: 'Add content' });
const additionalExperienceCard = within(addContentDialog).getByText('Additional Experience').closest('.MuiPaper-root');
expect(additionalExperienceCard).not.toBeNull();
fireEvent.click(within(additionalExperienceCard as HTMLElement).getByRole('button', { name: 'Add' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Add content' })).not.toBeInTheDocument());
const title = screen.getByLabelText(/Section name for custom:/);
fireEvent.change(title, { target: { value: 'Selected projects' } });
fireEvent.click(screen.getByRole('button', { name: 'Expand Selected projects' }));
@@ -253,8 +270,12 @@ test('profile-backed sections expand, reorder, hide and persist variant-only ove
mockedApi.put.mockResolvedValue({ data: variant } as any);
renderAt(3);
await openContentTab();
fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience' }));
expect(screen.getByRole('button', { name: 'Collapse Experience' })).toHaveAttribute('aria-expanded', 'true');
fireEvent.mouseDown(screen.getByLabelText('Presentation'));
fireEvent.click(await screen.findByRole('option', { name: 'Grid' }));
fireEvent.click(within(screen.getByRole('group', { name: 'Columns' })).getByRole('button', { name: '2' }));
fireEvent.click(screen.getByRole('button', { name: 'Move Engineer entry down' }));
fireEvent.click(screen.getByRole('button', { name: 'Hide Lead entry' }));
fireEvent.click(screen.getByRole('button', { name: 'Move Experience section down' }));
@@ -263,7 +284,7 @@ test('profile-backed sections expand, reorder, hide and persist variant-only ove
expect(await screen.findByText('Saved')).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({
sections: expect.arrayContaining([expect.objectContaining({ key: 'experience', itemOrder: ['job-2', 'job-1'] })]),
sections: expect.arrayContaining([expect.objectContaining({ key: 'experience', itemOrder: ['job-2', 'job-1'], presentation: 'grid', columns: 2 })]),
overrides: expect.objectContaining({ 'job-2': expect.objectContaining({ hidden: true }) }),
}),
}));
+9 -1
View File
@@ -1,7 +1,15 @@
import { api } from "./api";
// Mirrors the backend CvVariantSettings (the lens over the master career profile).
export type CvSectionSetting = { key: string; hidden?: boolean; title?: string; itemOrder?: string[]; items?: string[] };
export type CvSectionSetting = {
key: string;
hidden?: boolean;
title?: string;
itemOrder?: string[];
items?: string[];
presentation?: "rows" | "grid" | "compact" | "bubble" | null;
columns?: 1 | 2 | null;
};
export type CvItemOverride = { hidden?: boolean; title?: string; subtitle?: string; bullets?: string[] };
export type CvCustomSectionSetting = {
key: string;
+60
View File
@@ -202,6 +202,8 @@ export const translations = {
cvEditorTemplate: "Template",
cvEditorDesign: "Design",
cvEditorLayout: "Layout",
cvEditorCustomize: "Customize",
cvEditorAiTools: "AI Tools",
cvEditorHistory: "History",
cvEditorLivePreview: "Live preview",
cvEditorUpdatingPreview: "updating…",
@@ -243,6 +245,13 @@ export const translations = {
cvEditorAddSection: "Add a section",
cvEditorAddSectionButton: "Add section",
cvEditorAddSectionHelp: "Preset sections use the same editable, reorderable card as every other section.",
cvEditorAddContent: "Add content",
cvEditorAddContentHelp: "Choose a standard section or create something specific to this CV. Existing career data is never removed.",
cvEditorAddBuiltInHelp: "Add {section} and edit its CV-specific wording and visibility.",
cvEditorAddPresetHelp: "Add a structured {section} section with reorderable entries.",
cvEditorAddCustomHelp: "Create a section with your own title and paragraph, bullet or multi-entry content.",
cvEditorContentAdded: "Added",
cvEditorAdd: "Add",
cvEditorSectionType: "Section type",
cvEditorFreeTextSection: "Custom free-text section",
cvEditorMoveSectionUp: "Move {section} section up",
@@ -258,6 +267,12 @@ export const translations = {
cvEditorCollapseSection: "Collapse {section}",
cvEditorExpandSection: "Expand {section}",
cvEditorContentFormat: "Content format",
cvEditorSectionLayout: "Section layout",
cvEditorSectionLayoutHelp: "Choose how this section is arranged in preview and PDF.",
cvEditorPresentation: "Presentation",
cvEditorRows: "Rows",
cvEditorGrid: "Grid",
cvEditorBubble: "Bubble",
cvEditorParagraphs: "Paragraphs",
cvEditorBulletList: "Bullet list",
cvEditorMultipleEntries: "Multiple entries",
@@ -427,6 +442,21 @@ export const translations = {
cvAiGrammar: "Fix grammar",
cvAiImpact: "Add measurable impact",
cvAiBullets: "Suggest bullet points",
cvDocumentAiTitle: "AI tools for this CV",
cvDocumentAiProTitle: "Refine the complete CV with Pro.",
cvDocumentAiHelp: "Review coordinated suggestions across the complete document. Nothing changes until you approve it.",
cvDocumentAiTranslate: "Translate resume",
cvDocumentAiTranslateHelp: "Translate headings and content while preserving names, dates, employers and technical terms.",
cvDocumentAiImprove: "Improve writing",
cvDocumentAiImproveHelp: "Make the complete CV clearer, more concise and professionally consistent without adding claims.",
cvDocumentAiGrammar: "Check spelling and grammar",
cvDocumentAiGrammarHelp: "Correct spelling, grammar and punctuation without changing the meaning.",
cvDocumentAiReview: "Review suggestion",
cvDocumentAiInvalid: "The AI response could not be matched safely to this CV. Nothing was changed.",
cvDocumentAiReviewTitle: "Review {count} proposed change(s)",
cvDocumentAiReviewHelp: "Compare current wording on the left with the suggestion on the right before applying the complete set.",
cvDocumentAiNoChanges: "No wording changes were suggested.",
cvDocumentAiApplied: "The reviewed CV suggestions were applied.",
careerOverviewTitle: "Career Workspace",
careerOverviewSubtitle: "Choose what you want to work on next.",
careerOverviewOpenBuilder: "Open CV Builder",
@@ -1868,6 +1898,8 @@ export const translations = {
cvEditorTemplate: "Mal",
cvEditorDesign: "Design",
cvEditorLayout: "Oppsett",
cvEditorCustomize: "Tilpass",
cvEditorAiTools: "AI-verktøy",
cvEditorHistory: "Historikk",
cvEditorLivePreview: "Direkte forhåndsvisning",
cvEditorUpdatingPreview: "oppdaterer…",
@@ -1909,6 +1941,13 @@ export const translations = {
cvEditorAddSection: "Legg til seksjon",
cvEditorAddSectionButton: "Legg til seksjon",
cvEditorAddSectionHelp: "Forhåndsdefinerte seksjoner bruker det samme redigerbare kortet som alle andre seksjoner.",
cvEditorAddContent: "Legg til innhold",
cvEditorAddContentHelp: "Velg en standardseksjon eller opprett noe som er spesifikt for denne CV-en. Eksisterende karrieredata blir aldri fjernet.",
cvEditorAddBuiltInHelp: "Legg til {section} og rediger CV-spesifikk tekst og synlighet.",
cvEditorAddPresetHelp: "Legg til en strukturert seksjon for {section} med sorterbare oppføringer.",
cvEditorAddCustomHelp: "Opprett en seksjon med egen tittel og innhold som avsnitt, punkter eller flere oppføringer.",
cvEditorContentAdded: "Lagt til",
cvEditorAdd: "Legg til",
cvEditorSectionType: "Seksjonstype",
cvEditorFreeTextSection: "Egendefinert fritekstseksjon",
cvEditorMoveSectionUp: "Flytt seksjonen {section} opp",
@@ -1924,6 +1963,12 @@ export const translations = {
cvEditorCollapseSection: "Slå sammen {section}",
cvEditorExpandSection: "Utvid {section}",
cvEditorContentFormat: "Innholdsformat",
cvEditorSectionLayout: "Seksjonsoppsett",
cvEditorSectionLayoutHelp: "Velg hvordan denne seksjonen ordnes i forhåndsvisning og PDF.",
cvEditorPresentation: "Visning",
cvEditorRows: "Rader",
cvEditorGrid: "Rutenett",
cvEditorBubble: "Bobler",
cvEditorParagraphs: "Avsnitt",
cvEditorBulletList: "Punktliste",
cvEditorMultipleEntries: "Flere oppføringer",
@@ -2093,6 +2138,21 @@ export const translations = {
cvAiGrammar: "Rett grammatikk",
cvAiImpact: "Legg til målbar effekt",
cvAiBullets: "Foreslå punkter",
cvDocumentAiTitle: "AI-verktøy for denne CV-en",
cvDocumentAiProTitle: "Forbedre hele CV-en med Pro.",
cvDocumentAiHelp: "Se gjennom samordnede forslag for hele dokumentet. Ingenting endres før du godkjenner det.",
cvDocumentAiTranslate: "Oversett CV",
cvDocumentAiTranslateHelp: "Oversett overskrifter og innhold, men bevar navn, datoer, arbeidsgivere og tekniske begreper.",
cvDocumentAiImprove: "Forbedre teksten",
cvDocumentAiImproveHelp: "Gjør hele CV-en tydeligere, mer konsis og profesjonelt konsekvent uten å legge til påstander.",
cvDocumentAiGrammar: "Kontroller staving og grammatikk",
cvDocumentAiGrammarHelp: "Korriger staving, grammatikk og tegnsetting uten å endre meningen.",
cvDocumentAiReview: "Se gjennom forslag",
cvDocumentAiInvalid: "AI-svaret kunne ikke kobles trygt til denne CV-en. Ingenting ble endret.",
cvDocumentAiReviewTitle: "Se gjennom {count} foreslåtte endring(er)",
cvDocumentAiReviewHelp: "Sammenlign gjeldende tekst til venstre med forslaget til høyre før du bruker hele settet.",
cvDocumentAiNoChanges: "Ingen tekstendringer ble foreslått.",
cvDocumentAiApplied: "De gjennomgåtte CV-forslagene ble tatt i bruk.",
careerOverviewTitle: "Karriereområde",
careerOverviewSubtitle: "Velg hva du vil arbeide med videre.",
careerOverviewOpenBuilder: "Åpne CV-bygger",
+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") && <>