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
+22
View File
@@ -296,6 +296,28 @@ public sealed class CvBuilderTests
Assert.Contains(".page{width:auto;min-height:0;margin:0;}", html);
}
[Fact]
public void Section_presentation_is_normalized_and_shared_by_preview_and_pdf_renderer()
{
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings
{
ThemeId = "modern",
Sections =
{
new CvSectionSetting { Key = "experience", Presentation = "grid", Columns = 2 },
new CvSectionSetting { Key = "skills", Presentation = "bubble", Columns = 99 },
},
});
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html;
Assert.Equal(2, settings.Sections[0].Columns);
Assert.Null(settings.Sections[1].Columns);
Assert.Contains("section-experience section-presentation-grid", html);
Assert.Contains("--cv-section-columns:2", html);
Assert.Contains("section-skills section-presentation-bubble", html);
}
[Fact]
public void Norwegian_variant_localizes_default_labels_dates_and_keeps_custom_links_clickable()
{
@@ -224,9 +224,11 @@ public sealed class CvVariantController : ControllerBase
if (user is null) return Unauthorized();
var text = (request.Text ?? string.Empty).Trim();
if (text.Length == 0) return BadRequest("Provide text to work on.");
if (text.Length > 60_000) return BadRequest("The requested CV content is too large to process in one operation.");
var instruction = BuildAiInstruction(request.Action, request.Role, request.Language, request.Context);
var result = await _ai.SummarizeSectionAsync(instruction, text, 1200, 300);
var isDocumentAction = (request.Action ?? string.Empty).StartsWith("document-", StringComparison.OrdinalIgnoreCase);
var result = await _ai.SummarizeSectionAsync(instruction, text, isDocumentAction ? 16_000 : 1200, isDocumentAction ? 50 : 300);
if (string.IsNullOrWhiteSpace(result))
{
var metrics = await _ai.GetMetricsAsync(ct);
@@ -239,11 +241,15 @@ public sealed class CvVariantController : ControllerBase
private static string BuildAiInstruction(string? action, string? role, string? language, string? context)
{
var normalizedAction = (action ?? "improve").Trim().ToLowerInvariant();
var target = string.IsNullOrWhiteSpace(role) ? null : role.Trim();
var lang = string.IsNullOrWhiteSpace(language) ? "the same language as the input" : language.Trim();
var extra = string.IsNullOrWhiteSpace(context) ? string.Empty : $" Context: {context.Trim()}.";
var task = (action ?? "improve").Trim().ToLowerInvariant() switch
var task = normalizedAction switch
{
"document-translate" => "Translate every user-facing CV value into the requested language. Return only valid JSON with the exact same array structure, ids and fields. Preserve names, employers, dates, qualifications, URLs and technical terms unless they have a standard translation.",
"document-improve" => "Improve clarity, concision and professional impact across the CV. Return only valid JSON with the exact same array structure, ids and fields. Preserve every fact and never invent achievements, metrics, skills or experience.",
"document-grammar" => "Correct spelling, grammar and punctuation across the CV without changing meaning. Return only valid JSON with the exact same array structure, ids and fields.",
"professional" => "Rewrite the text in a more professional, confident tone.",
"shorten" => "Make the text more concise without losing meaning.",
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
@@ -256,7 +262,10 @@ public sealed class CvVariantController : ControllerBase
"tailor" => $"Rewrite the text to align with the target role{(target is null ? string.Empty : $" '{target}'")}, emphasising the most relevant experience. Do not invent facts.",
_ => "Improve the clarity, impact and phrasing of the text while preserving all facts.",
};
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. Return only the rewritten text with no preamble.{extra}";
var outputInstruction = normalizedAction.StartsWith("document-", StringComparison.Ordinal)
? "Return only the JSON array with no Markdown fence or preamble."
: "Return only the rewritten text with no preamble.";
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. {outputInstruction}{extra}";
}
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
@@ -66,6 +66,10 @@ public sealed class CvSectionSetting
// Optional CV-specific content for bullet/tag sections. Null uses master data; an empty list is
// an intentional empty override. Entry sections continue to use stable per-item overrides.
public List<string>? Items { get; set; }
// Optional presentation hint consumed by the shared renderer, so preview and PDF always agree.
// Unsupported combinations safely fall back to the normal row layout.
public string? Presentation { get; set; } // rows | grid | compact | bubble
public int? Columns { get; set; } // 1 | 2
}
public sealed class CvItemOverride
@@ -167,6 +171,11 @@ public static class CvVariantSettingsJson
Items = group.Items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => item.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Take(100).ToList(),
})
.ToList();
foreach (var section in s.Sections)
{
section.Presentation = NormalizeChoice(section.Presentation, "rows", "grid", "compact", "bubble");
section.Columns = section.Columns is 1 or 2 ? section.Columns : null;
}
foreach (var section in s.CustomSections)
{
section.ContentType = NormalizeChoice(section.ContentType, "paragraphs", "bullets", "entries") ?? "bullets";
+4
View File
@@ -31,6 +31,8 @@ public sealed class CvRenderSection
public List<string> Tags { get; set; } = new();
public List<CvRenderEntry> Entries { get; set; } = new();
public List<CvRenderSkillGroup> SkillGroups { get; set; } = new();
public string? Presentation { get; set; }
public int Columns { get; set; } = 1;
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0 && SkillGroups.Count == 0;
}
@@ -158,6 +160,8 @@ public static class CvVariantResolver
{
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
}
section.Presentation = cfg.Presentation;
section.Columns = cfg.Columns is 2 ? 2 : 1;
}
else if (customHiddenByKey.TryGetValue(key, out var customHidden) && customHidden)
{
+12 -1
View File
@@ -141,7 +141,9 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
_ => string.Join("", section.Entries.Select(RenderEntry)),
};
var flowClass = IsFlowingSection(section) ? " section-flow" : string.Empty;
return $@"<section class=""section section-{Attr(section.Key)}{flowClass}""><h2 class=""section-title""><span class=""section-title-text"">{Enc(section.Title)}</span></h2>{inner}</section>";
var presentation = section.Presentation is "grid" or "compact" or "bubble" ? section.Presentation : "rows";
var columns = presentation == "grid" && section.Columns == 2 ? 2 : 1;
return $@"<section class=""section section-{Attr(section.Key)}{flowClass} section-presentation-{presentation}"" style=""--cv-section-columns:{columns}""><h2 class=""section-title""><span class=""section-title-text"">{Enc(section.Title)}</span></h2>{inner}</section>";
}
private static string RenderSkillGroup(CvRenderSkillGroup group)
@@ -219,6 +221,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density);
var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density);
var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density);
var compactEntryGap = F(Math.Max(1.5, (settings.EntryGapMm ?? t.EntryGapMm) * density * .55));
var bodySize = settings.BaseFontSizePt ?? t.BodySizePt;
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
var lineHeight = settings.LineHeight ?? t.LineHeight;
@@ -283,6 +286,14 @@ h1,h2{{font-family:{headingFont};}}
.section:first-child{{margin-top:0;}}
.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(headingSize)}pt;font-weight:700;color:{headingColor};}}
{headingCss}
.section-presentation-grid{{display:grid;grid-template-columns:repeat(var(--cv-section-columns,1),minmax(0,1fr));column-gap:4mm;align-items:start;}}
.section-presentation-grid>.section-title{{grid-column:1/-1;}}
.section-presentation-grid>.entry{{min-width:0;}}
.section-presentation-compact>.entry{{margin-bottom:{compactEntryGap}mm;}}
.section-presentation-compact>.bullets li{{margin-bottom:{F(.75 * density)}mm;}}
.section-presentation-bubble>.tags,.section-presentation-bubble>.bullets{{list-style:none;padding:0;display:flex;flex-wrap:wrap;gap:1.4mm;}}
.section-presentation-bubble>.bullets li,.section-presentation-bubble>.tags li{{margin:0;border:1px solid var(--cv-accent-soft);border-radius:999px;padding:.7mm 2.2mm;}}
.section-presentation-bubble>.bullets li::marker{{content:"";}}
.bullets{{margin:0;padding-left:4.5mm;}}
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
.bullets li::marker{{color:var(--cv-accent-color);}}
+7 -4
View File
@@ -2,19 +2,22 @@
## Current
- [ ] Rework the CV Builder navigation and studio layout: make Template the first tab, Content second, combine Design + Layout into one Customize tab, add a separate whole-document AI tab, and retain the stronger side-by-side editor/real-page preview relationship shown in the supplied reference while keeping the design original and responsive.
- [ ] Deploy the verified CV Builder studio batch through the protected `main` workflow and confirm the live health/version transition.
- [ ] Complete authenticated production CV Builder editing/PDF smoke when a safe signed-in test session is available.
## Next
- [ ] Build the whole-document AI tab with clearly described, explicit-review actions for Translate resume, Improve writing, and Check spelling and grammar. Preserve facts, use selectable output language, show current versus suggested content, and never overwrite the CV without acceptance.
- [ ] Replace the compact custom-section selector with a polished “Add content” catalogue/modal containing built-in optional sections, preset custom sections, and a free-form Custom option. Include concise descriptions, disabled/already-added states, keyboard navigation, focus management, and English/Bokmål labels.
- [ ] Add per-section presentation controls where supported (for example grid/rows/compact/bubble and column/width choices), declared through template/section capabilities rather than template-specific conditionals. Ensure preview and PDF share the same settings and preserve backward-compatible defaults.
- [ ] Visually verify the revised builder at 375 px, 768 px, and 1440 px in light/dark mode, including keyboard navigation, multiple expanded sections, add-content focus return, whole-document AI review, responsive editor/preview switching, PDF parity, and long multi-page CVs.
- [ ] Resume from authenticated production smoke findings if they identify a regression.
## Completed
- [x] Reordered the CV Builder studio to Template, Content, Customize, AI Tools and History; merged Design and Layout into one capability-aware Customize surface while retaining the responsive editor/live-page preview split.
- [x] Added whole-document Translate, Improve writing and Spelling/Grammar AI actions with target-language selection, strict structured-response matching, fact-preservation instructions, Pro entitlement enforcement and an explicit current-versus-suggested review gate.
- [x] Replaced the compact custom-section selector with a responsive, keyboard-accessible Add Content catalogue for built-in, preset and free-form sections, including disabled/already-added states and English/Bokmål UI.
- [x] Added per-section Rows, Grid, Compact and Bubble presentation plus one/two-column controls; normalized them in the persisted settings model and rendered them through the shared preview/PDF engine.
- [x] Added regression coverage for the revised studio workflow and shared section presentation; full verification passes with 62 frontend suites/253 tests, 709 backend tests, TypeScript and the optimized frontend build.
- [x] Diagnosed release 271 as self-hosted-runner instability (test-host crash, then checksum-invalid NuGet cache entries) and added bounded clean-cache restore recovery without weakening signature, build or test gates.
- [x] Rechecked branch/status, recent CV work, existing tests/build tooling, and current builder architecture.
- [x] Traced builder persistence, shared preview/PDF rendering, AI endpoint, extraction pipeline, confidence/diff review, and reference CV assets.
- [x] Rendered and inspected the supplied two-page reference PDF and reviewed its HTML typography/theme tokens without modifying the originals.
@@ -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",
+266 -26
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,18 +887,42 @@ 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>
<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>
</Stack>
);
}
@@ -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") && <>