feat(cv): rebuild section editing workflow
This commit is contained in:
@@ -20,9 +20,10 @@ which is retained only for the legacy tailored-draft flow and is not used by the
|
||||
| Identity | `Id`, `Name`, `Category`, `Description` |
|
||||
| Layout | `Layout` (`single` \| `sidebar-left` \| `sidebar-right` \| `header-band`), `SidebarWidthMm`, `SidebarSections` |
|
||||
| Palette | `Accent`, `Ink`, `Muted`, `Line`, `Paper`, `SidebarBg`, `SidebarInk`, `HeadingColor` |
|
||||
| Typography | `HeadingFont`, `BodyFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` |
|
||||
| Typography | `HeadingFont`, `BodyFont`, `UtilityFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` |
|
||||
| Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` |
|
||||
| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons` |
|
||||
| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons`, `NumberSections` |
|
||||
| Capabilities | `SupportedSettings` controls which editor panels the template exposes |
|
||||
|
||||
The renderer computes CSS from these plus normalized variant overrides: printable palette, curated
|
||||
fonts, body/heading size, line height, spacing, page size, layout, sidebar width/content, header and
|
||||
@@ -33,19 +34,21 @@ the structured career content.
|
||||
## Adding a theme
|
||||
|
||||
1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`JobTrackerApi/Models/CvTheme.cs`). Only override the
|
||||
fields that differ from the defaults.
|
||||
2. Nothing else. It appears in `GET /api/cv/themes`, the Template tab and dashboard create flow.
|
||||
fields that differ from the defaults and declare its `SupportedSettings` when the defaults do not apply.
|
||||
2. Nothing else is wired manually. It appears in `GET /api/cv/themes`, the Template tab and dashboard create flow.
|
||||
`GET /api/cv/themes/{id}/preview` renders isolated in-memory sample data for a real visual thumbnail;
|
||||
preview data is never persisted.
|
||||
3. Add a focused visual/PDF regression only when the template introduces a genuinely new layout behaviour.
|
||||
|
||||
Add a `CvBuilderTests.Every_catalog_theme_renders_valid_html` already loops the whole catalog, so a new
|
||||
theme is smoke-tested automatically.
|
||||
|
||||
## Shipped themes (8)
|
||||
## Shipped themes (9)
|
||||
|
||||
`modern` (header-band), `minimal` (single), `executive` (single, serif), `technical` (sidebar-left,
|
||||
dense), `ats-classic` (single, no graphics), `nordic` (sidebar-right), `elegant` (single, editorial),
|
||||
`creative` (sidebar-left, bold).
|
||||
`creative` (sidebar-left, bold), `code` (single-column developer/editorial layout with numbered
|
||||
sections and monospaced utility details).
|
||||
|
||||
## Rich text, ATS, and print (2026-07-18)
|
||||
|
||||
@@ -60,6 +63,10 @@ dense), `ats-classic` (single, no graphics), `nordic` (sidebar-right), `elegant`
|
||||
block. Section headings avoid a following break, list items carry widow/orphan rules, and long
|
||||
names/titles/contact values/URLs/tags wrap within `minmax(0, …)` columns. Typography is not scaled
|
||||
down to mask overflow.
|
||||
- **Builder pagination**: the screen preview runs the same keep-together policy over the rendered
|
||||
section/entry/list classes before cropping real A4 or Letter frames. It uses deterministic internal
|
||||
padding for unused page space (avoiding collapsing margins), while PDF export remains native
|
||||
Chromium paged media. A long structured Code-theme browser/PDF test guards both paths.
|
||||
|
||||
## Deliberately not here (yet)
|
||||
|
||||
|
||||
+43
-1
@@ -1,4 +1,28 @@
|
||||
# Active implementation checklist — 2026-08-24
|
||||
# Career / CV improvement work — 2026-08-26
|
||||
|
||||
## Current
|
||||
|
||||
- [ ] Run the complete backend, frontend, browser, migration, PDF, Compose and diff verification matrix; fix any regressions.
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] Audit remaining TODOs/dead CV code after the full regression run and document any genuine environment-only blockers.
|
||||
|
||||
## Completed
|
||||
|
||||
- [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.
|
||||
- [x] Extended the backward-compatible CV settings/document model for item visibility/order, custom section types, grouped skills and compact/bullet/grouped skill rendering.
|
||||
- [x] Rebuilt content editing as independently expandable section cards with keyboard reorder controls, hide/show, guarded deletion, preset/free-text sections and reusable per-section AI review.
|
||||
- [x] Centralised template capabilities and accent tokens, added the Code theme through the registry, and made actual rendered sample thumbnails drive selection.
|
||||
- [x] Added shared A4/Letter page grouping for browser preview and Chromium PDF, including regression coverage for long multi-page Code CVs and searchable linked PDF output.
|
||||
- [x] Improved DOCX structural extraction, Norwegian headings/languages/dates, contact and website parsing, summary/skills/education fallbacks, and confidence-preserving import output.
|
||||
- [x] Benchmarked both supplied reference DOCX variants without committing personal data: 100% structural coverage and consistency, with zero suspicious locations.
|
||||
|
||||
---
|
||||
|
||||
# Previous implementation checklist — 2026-08-24
|
||||
|
||||
## Current
|
||||
|
||||
@@ -959,3 +983,21 @@ When all possible work is complete, provide:
|
||||
12. Recommended next work package
|
||||
|
||||
Do not claim a workflow works unless it was genuinely browser-tested, mock-tested or otherwise clearly identified by verification type.
|
||||
# Career / CV improvement work (2026-08-26)
|
||||
|
||||
## Current
|
||||
|
||||
- [ ] Refactor the CV document/settings model and shared renderer for section content, skill formats, template capabilities, accent tokens, and safer page breaks.
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] Rebuild content editing as independently expandable section cards, including preset/free-text custom sections and per-section AI review.
|
||||
- [ ] Add the Code template through the central template registry and redesign existing template metadata/styles.
|
||||
- [ ] Strengthen extraction normalization, confidence, custom-section handling, and sanitized regression tests.
|
||||
- [ ] Run browser, PDF, responsive, build, lint, unit, integration, and migration verification; fix regressions.
|
||||
|
||||
## Completed
|
||||
|
||||
- [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.
|
||||
|
||||
@@ -315,6 +315,26 @@ test("Career Workspace loads from the authenticated application shell", async ({
|
||||
await expect(page).toHaveURL(/\/career\/builder\/\d+$/);
|
||||
await expect(page.getByLabel("CV name")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Expand Professional Summary" }).click();
|
||||
await page.getByRole("button", { name: "Expand Experience" }).click();
|
||||
await expect(page.getByRole("button", { name: "Collapse Professional Summary" })).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByRole("button", { name: "Collapse Experience" })).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await page.getByLabel("Section type").click();
|
||||
await page.getByRole("option", { name: "Additional Experience" }).click();
|
||||
await page.getByRole("button", { name: "Add section" }).click();
|
||||
await page.getByRole("button", { name: "Expand Additional Experience" }).click();
|
||||
await page.getByRole("button", { name: "Add entry" }).click();
|
||||
await page.getByRole("textbox", { name: "Entry 1", exact: true }).fill("Maintained a community software project.");
|
||||
|
||||
await page.getByRole("tab", { name: "Template" }).click();
|
||||
await page.getByRole("button").filter({ hasText: /^CodeTechnical/ }).click();
|
||||
await page.getByRole("tab", { name: "Design" }).click();
|
||||
await page.getByLabel("Skills presentation").click();
|
||||
await page.getByRole("option", { name: "Bullet list" }).click();
|
||||
await page.locator('input[type="color"]').fill("#126b55");
|
||||
await expect(page.getByRole("tab", { name: "AI" })).toHaveCount(0);
|
||||
|
||||
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark"));
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark");
|
||||
@@ -331,7 +351,7 @@ test("Career Workspace loads from the authenticated application shell", async ({
|
||||
}
|
||||
});
|
||||
|
||||
test("a public CV renders anonymously and downloads as PDF", async ({ page }) => {
|
||||
test("a public CV renders anonymously and downloads as PDF", async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
const headers = await csrfHeader(page);
|
||||
const create = await page.request.post(`${apiUrl}/cv/variants`, {
|
||||
@@ -368,5 +388,128 @@ test("a public CV renders anonymously and downloads as PDF", async ({ page }) =>
|
||||
const pdf = await page.request.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`);
|
||||
expect(pdf.ok()).toBeTruthy();
|
||||
expect(pdf.headers()["content-type"]).toContain("application/pdf");
|
||||
expect((await pdf.body()).subarray(0, 5).toString()).toBe("%PDF-");
|
||||
const pdfBody = await pdf.body();
|
||||
expect(pdfBody.length).toBeGreaterThan(1_000);
|
||||
expect(pdfBody.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
await testInfo.attach("public-cv.pdf", { body: pdfBody, contentType: "application/pdf" });
|
||||
});
|
||||
|
||||
test("the Code template exports a long structured CV as searchable multi-page PDF", async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
const headers = await csrfHeader(page);
|
||||
const longBullets = [
|
||||
"Designed resilient .NET services that processed high-volume workloads while preserving clear operational ownership and measurable service health.",
|
||||
"Improved deployment safety through automated checks, progressive delivery, actionable telemetry, and documented incident-response procedures.",
|
||||
"Collaborated with product, security, and support teams across Norway and the United Kingdom to turn ambiguous requirements into maintainable releases.",
|
||||
];
|
||||
const profile = {
|
||||
version: "1",
|
||||
contact: {
|
||||
fullName: "Alex Eksempel",
|
||||
headline: "Principal Software Engineer",
|
||||
email: "alex.eksempel+career-verification@example.test",
|
||||
phone: "+47 412 34 567",
|
||||
location: "Oslo, Norge",
|
||||
website: "https://example.test/portfolio/with/a/deliberately/long/path",
|
||||
linkedIn: "https://linkedin.com/in/alex-eksempel",
|
||||
gitHub: "https://github.com/alex-eksempel",
|
||||
},
|
||||
summary: ["Teknisk leder med erfaring fra robuste plattformer, tilgjengelige tjenester og tverrfaglig produktutvikling."],
|
||||
jobs: Array.from({ length: 9 }, (_, index) => ({
|
||||
title: `Senior Platform Engineer ${index + 1} With A Deliberately Long Role Name`,
|
||||
company: `Example Technology Organisation ${index + 1} With A Long Employer Name`,
|
||||
location: index % 2 === 0 ? "Oslo, Norge" : "Warwickshire, United Kingdom",
|
||||
start: `${2015 + index}`,
|
||||
end: index === 8 ? "Present" : `${2016 + index}`,
|
||||
isCurrent: index === 8,
|
||||
bullets: longBullets,
|
||||
skills: ["C#", ".NET", "PostgreSQL", "Docker", "Azure DevOps"],
|
||||
})),
|
||||
education: [{ qualification: "BSc Software Engineering", institution: "Example University", start: "2011", end: "2015", details: ["Distributed systems and human-computer interaction"] }],
|
||||
certifications: [{ name: "Cloud Architecture Professional", issuer: "Example Institute", date: "2025" }],
|
||||
projects: [{ name: "Open Source Reliability Toolkit", role: "Maintainer", start: "2022", end: "Present", bullets: longBullets.slice(0, 2), skills: ["TypeScript", "Playwright"] }],
|
||||
skills: ["C# / .NET", "Python", "SQL", "Docker", "Azure DevOps", "Linux", "Observability", "Incident response"],
|
||||
languages: [{ name: "English", level: "Native" }, { name: "Norsk", level: "Profesjonelt arbeidsnivå" }],
|
||||
interests: ["Tilgjengelighet", "Open source"],
|
||||
otherSections: [{ title: "Additional Experience", items: ["Mentored early-career engineers", "Organised community technology workshops"] }],
|
||||
};
|
||||
|
||||
const profileResponse = await page.request.put(`${apiUrl}/career/profile`, {
|
||||
headers,
|
||||
data: { profile, cvText: "Long sanitized CV fixture for PDF verification." },
|
||||
});
|
||||
expect(profileResponse.ok()).toBeTruthy();
|
||||
|
||||
const create = await page.request.post(`${apiUrl}/cv/variants`, {
|
||||
headers,
|
||||
data: {
|
||||
name: "Code PDF Verification",
|
||||
settings: {
|
||||
themeId: "code",
|
||||
accent: "#0b7a63",
|
||||
pageSize: "a4",
|
||||
skillsStyle: "bullets",
|
||||
language: "nb",
|
||||
},
|
||||
},
|
||||
});
|
||||
const createBody = await create.text();
|
||||
expect(create.ok(), `CV create failed: ${create.status()} ${createBody}`).toBeTruthy();
|
||||
const variant = JSON.parse(createBody);
|
||||
|
||||
const preview = await page.request.get(`${apiUrl}/cv/variants/${variant.id}/preview`);
|
||||
expect(preview.ok()).toBeTruthy();
|
||||
const previewBody = await preview.json();
|
||||
expect(previewBody.themeId).toBe("code");
|
||||
expect(previewBody.html).toContain("Alex Eksempel");
|
||||
expect(previewBody.html).toContain("--cv-accent-color:#0b7a63");
|
||||
|
||||
await page.goto(`/career/builder/${variant.id}`);
|
||||
await expect(page.getByLabel("CV name")).toHaveValue("Code PDF Verification");
|
||||
await expect(page.getByTitle("CV preview page 2")).toHaveCount(1, { timeout: 10_000 });
|
||||
const pagination = await page.getByTitle("CV preview", { exact: true }).evaluate((element) => {
|
||||
const frame = element as HTMLIFrameElement;
|
||||
const doc = frame.contentDocument;
|
||||
const height = 297 * 96 / 25.4;
|
||||
if (!doc || height <= 0) return { scriptRan: false, crossings: ["document unavailable"] };
|
||||
const topOf = (node: Element) => {
|
||||
let top = 0;
|
||||
let current = node as HTMLElement | null;
|
||||
while (current && current !== doc.body) {
|
||||
top += current.offsetTop || 0;
|
||||
current = current.offsetParent as HTMLElement | null;
|
||||
}
|
||||
return top;
|
||||
};
|
||||
const candidates = Array.from(doc.querySelectorAll<HTMLElement>(
|
||||
".section:not(.section-flow),.section-flow > .entry:not(.entry-flow),.section-flow > .skill-groups > .skill-group,.section-flow > .bullets > li:not(.item-flow),.entry-flow .bullets > li:not(.item-flow)",
|
||||
));
|
||||
const crossings = candidates.filter((candidate) => {
|
||||
const paddingTop = Number.parseFloat(getComputedStyle(candidate).paddingTop) || 0;
|
||||
const top = topOf(candidate) + paddingTop;
|
||||
const contentHeight = candidate.offsetHeight - paddingTop;
|
||||
const bottom = top + contentHeight;
|
||||
return contentHeight < height - 2
|
||||
&& Math.floor((top + 1) / height) !== Math.floor((bottom - 1) / height);
|
||||
}).map((candidate) => ({
|
||||
className: candidate.className || candidate.tagName,
|
||||
top: topOf(candidate) + (Number.parseFloat(getComputedStyle(candidate).paddingTop) || 0),
|
||||
height: candidate.offsetHeight,
|
||||
paddingTop: getComputedStyle(candidate).paddingTop,
|
||||
}));
|
||||
return {
|
||||
scriptRan: !!doc.querySelector("script[data-cv-preview-pagination]"),
|
||||
crossings,
|
||||
};
|
||||
});
|
||||
expect(pagination.scriptRan).toBeTruthy();
|
||||
expect(pagination.crossings).toEqual([]);
|
||||
|
||||
const pdf = await page.request.post(`${apiUrl}/cv/variants/${variant.id}/export-pdf`, { headers });
|
||||
expect(pdf.ok()).toBeTruthy();
|
||||
expect(pdf.headers()["content-type"]).toContain("application/pdf");
|
||||
const pdfBody = await pdf.body();
|
||||
expect(pdfBody.length).toBeGreaterThan(20_000);
|
||||
expect(pdfBody.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
await testInfo.attach("code-long-cv.pdf", { body: pdfBody, contentType: "application/pdf" });
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"start": "next dev",
|
||||
"serve:export": "node ./scripts/serve-export.mjs",
|
||||
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
|
||||
"test": "jest",
|
||||
"test:e2e": "dotnet build ../JobTrackerApi/JobTrackerApi.csproj --configuration Release && playwright test"
|
||||
|
||||
@@ -5,6 +5,8 @@ import path from "node:path";
|
||||
const apiUrl = "http://localhost:5302";
|
||||
const appUrl = "http://localhost:3300";
|
||||
const dataRoot = path.join(os.tmpdir(), `jobtracker-e2e-${process.pid}-${Date.now().toString(36)}`);
|
||||
const reuseServers = process.env.PLAYWRIGHT_REUSE_SERVERS === "true";
|
||||
const useStaticExport = process.env.PLAYWRIGHT_STATIC_EXPORT === "true";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -23,7 +25,7 @@ export default defineConfig({
|
||||
command: "dotnet run --no-build --configuration Release --project ../JobTrackerApi/JobTrackerApi.csproj --urls http://localhost:5302",
|
||||
url: `${apiUrl}/health`,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
reuseExistingServer: reuseServers,
|
||||
env: {
|
||||
ASPNETCORE_ENVIRONMENT: "Development",
|
||||
Auth__Require: "true",
|
||||
@@ -40,10 +42,12 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "npm run dev -- --hostname localhost --port 3300",
|
||||
command: useStaticExport
|
||||
? "npm run build && npm run serve:export"
|
||||
: "npm run dev -- --hostname localhost --port 3300",
|
||||
url: appUrl,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
reuseExistingServer: reuseServers,
|
||||
env: { NEXT_PUBLIC_API_BASE_URL: `${apiUrl}/api` },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? "3300", 10);
|
||||
const root = resolve(process.cwd(), "out");
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webmanifest": "application/manifest+json",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
if (!existsSync(join(root, "index.html"))) {
|
||||
throw new Error("Static export not found. Run `npm run build` first.");
|
||||
}
|
||||
|
||||
createServer((request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
|
||||
const relativePath = normalize(pathname).replace(/^([/\\])+/, "");
|
||||
let filePath = resolve(root, relativePath || "index.html");
|
||||
|
||||
if (filePath !== root && !filePath.startsWith(`${root}${sep}`)) {
|
||||
response.writeHead(400).end("Bad request");
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
|
||||
filePath = join(filePath, "index.html");
|
||||
}
|
||||
|
||||
// React Router owns application routes. Assets and exported Next files are still
|
||||
// served directly, while deep links fall back to the application shell.
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||
filePath = join(root, "index.html");
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": contentTypes[extname(filePath)] ?? "application/octet-stream",
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}).listen(port, "localhost", () => {
|
||||
process.stdout.write(`Static export listening on http://localhost:${port}\n`);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { getApiErrorMessage } from "../../api";
|
||||
import { useAccountPlan } from "../../accountPlan";
|
||||
import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder";
|
||||
import { useToast } from "../../toast";
|
||||
import ProFeatureNotice from "../ProFeatureNotice";
|
||||
|
||||
type Props = {
|
||||
sectionName: string;
|
||||
text: string;
|
||||
context?: string;
|
||||
onApply: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
|
||||
|
||||
export default function AiSectionAssistant({ sectionName, text, context, onApply }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [action, setAction] = useState("improve");
|
||||
const [suggestion, setSuggestion] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
if (!text.trim() || !canUseAi) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await cvBuilderApi.aiAssist({
|
||||
action,
|
||||
text,
|
||||
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
|
||||
});
|
||||
setSuggestion(response.result);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "AI suggestion failed."), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
|
||||
AI suggestions
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUseAi) {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine this section with Pro.">
|
||||
AI only proposes wording; it never changes CV content without your approval.
|
||||
</ProFeatureNotice>
|
||||
<Button size="small" onClick={() => setOpen(false)}>Close</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
||||
<TextField select size="small" label="AI action" value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
|
||||
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? "Working…" : "Suggest"}</Button>
|
||||
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>Close</Button>
|
||||
</Stack>
|
||||
{suggestion && <>
|
||||
<Box>
|
||||
<Typography variant="overline" color="text.secondary">Current</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
||||
</Box>
|
||||
<TextField label="Suggested — edit before applying" multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
|
||||
<Alert severity="info" sx={{ py: 0 }}>Check every fact before applying this suggestion.</Alert>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(`Suggestion applied to ${sectionName}.`, "success"); }}>Apply</Button>
|
||||
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button>
|
||||
</Stack>
|
||||
</>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -177,8 +177,12 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
|
||||
renderAt(3);
|
||||
|
||||
await screen.findByLabelText('Headline override');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
fireEvent.change(screen.getByLabelText('Custom section title'), { target: { value: 'Selected projects' } });
|
||||
fireEvent.mouseDown(screen.getByLabelText('Section type'));
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Additional Experience' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add section' }));
|
||||
const title = screen.getByLabelText(/Section name for custom:/);
|
||||
fireEvent.change(title, { target: { value: 'Selected projects' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Selected projects' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
|
||||
fireEvent.change(screen.getByLabelText('Entry 1'), { target: { value: 'First project' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
|
||||
@@ -207,15 +211,15 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
|
||||
}),
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' }));
|
||||
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText('Custom section title')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' }));
|
||||
expect(screen.getByLabelText(/Section name for custom:/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' }));
|
||||
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete section' }));
|
||||
await waitFor(() => expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByLabelText(/Section name for custom:/)).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => {
|
||||
@@ -230,8 +234,8 @@ test('profile-backed sections expand, reorder, hide and persist variant-only ove
|
||||
mockedApi.put.mockResolvedValue({ data: variant } as any);
|
||||
renderAt(3);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience entries' }));
|
||||
expect(screen.getByRole('button', { name: 'Collapse Experience entries' })).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience' }));
|
||||
expect(screen.getByRole('button', { name: 'Collapse Experience' })).toHaveAttribute('aria-expanded', 'true');
|
||||
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' }));
|
||||
@@ -253,7 +257,7 @@ test('preview failure is visible and retryable without leaving the editor', asyn
|
||||
|
||||
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry preview' }));
|
||||
await waitFor(() => expect(screen.getByTitle('CV preview')).toHaveAttribute('srcdoc', '<p>retry</p>'));
|
||||
expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin');
|
||||
await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('<p>retry</p>'));
|
||||
expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin allow-scripts');
|
||||
await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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[] };
|
||||
export type CvSectionSetting = { key: string; hidden?: boolean; title?: string; itemOrder?: string[]; items?: string[] };
|
||||
export type CvItemOverride = { hidden?: boolean; title?: string; subtitle?: string; bullets?: string[] };
|
||||
export type CvCustomSectionSetting = { key: string; title?: string; items: string[]; hidden?: boolean };
|
||||
export type CvCustomSectionSetting = {
|
||||
key: string;
|
||||
title?: string;
|
||||
items: string[];
|
||||
hidden?: boolean;
|
||||
presetKey?: string | null;
|
||||
contentType?: "paragraphs" | "bullets" | "entries";
|
||||
};
|
||||
export type CvSkillGroupSetting = { name: string; items: string[] };
|
||||
|
||||
export type CvVariantSettings = {
|
||||
themeId: string;
|
||||
@@ -27,7 +35,8 @@ export type CvVariantSettings = {
|
||||
entryGapMm?: number | null;
|
||||
headingStyle?: "caps-rule" | "underline" | "plain" | "bar" | null;
|
||||
headerStyle?: "plain" | "band" | "centered" | "kicker" | null;
|
||||
skillsStyle?: "tags" | "text" | null;
|
||||
skillsStyle?: "tags" | "text" | "bullets" | "grouped" | null;
|
||||
skillGroups?: CvSkillGroupSetting[] | null;
|
||||
layout?: "single" | "sidebar-left" | "sidebar-right" | "header-band" | null;
|
||||
sidebarWidthMm?: number | null;
|
||||
sidebarSections?: string[] | null;
|
||||
@@ -52,6 +61,7 @@ export type CvTheme = {
|
||||
requiresPro: boolean;
|
||||
available: boolean;
|
||||
swatches: string[];
|
||||
supportedSettings?: string[];
|
||||
};
|
||||
|
||||
// Master profile resolved to sections+entries (GET /api/cv/outline) — what the Content tab edits.
|
||||
@@ -121,6 +131,13 @@ export const SECTION_LABELS: Record<string, string> = {
|
||||
references: "References",
|
||||
};
|
||||
|
||||
export const CUSTOM_SECTION_PRESETS = [
|
||||
{ key: "courses", title: "Courses", contentType: "entries" as const },
|
||||
{ key: "volunteer", title: "Volunteer Experience", contentType: "entries" as const },
|
||||
{ key: "additional-experience", title: "Additional Experience", contentType: "entries" as const },
|
||||
{ key: "declaration", title: "Declaration", contentType: "paragraphs" as const },
|
||||
] as const;
|
||||
|
||||
const CSS_PIXELS_PER_MM = 96 / 25.4;
|
||||
|
||||
export type CvPageMetrics = {
|
||||
|
||||
@@ -15,7 +15,6 @@ import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
@@ -27,14 +26,14 @@ import RedoIcon from "@mui/icons-material/Redo";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import RichTextField from "../components/RichTextField";
|
||||
import AiSectionAssistant from "../components/cv/AiSectionAssistant";
|
||||
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
||||
import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
||||
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
|
||||
} from "../cvBuilder";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
|
||||
const FONTS = [
|
||||
@@ -49,10 +48,15 @@ const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "
|
||||
const MIN_PREVIEW_ZOOM = 0.32;
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
||||
|
||||
function previewPageHtml(html: string, pageIndex: number, pageHeightPx: number): string {
|
||||
const offset = pageIndex * pageHeightPx;
|
||||
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:#fff!important;}body{transform:translateY(-${offset}px);transform-origin:top left;}</style>`;
|
||||
return html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
|
||||
function previewDocumentHtml(html: string, pageHeightPx: number, pageIndex = 0): string {
|
||||
const height = Math.max(1, Math.round(pageHeightPx * 100) / 100);
|
||||
const offset = Math.max(0, pageIndex) * height;
|
||||
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:#fff!important;}.page{transform-origin:top left;}</style>`;
|
||||
// Screen media does not apply Chromium's paged-media fragmentation. Reproduce the important
|
||||
// keep-together decisions before each preview frame crops the selected physical page.
|
||||
const previewScript = `<script data-cv-preview-pagination>(()=>{const H=${height};const OFFSET=${offset};const page=document.querySelector('.page');if(!page)return;const topOf=(el)=>{let top=0,node=el;while(node&&node!==document.body){top+=node.offsetTop||0;node=node.offsetParent;}return top;};const paginate=()=>{document.querySelectorAll('[data-cv-preview-pad]').forEach((el)=>{el.style.paddingTop='';el.removeAttribute('data-cv-preview-pad');});const pushToNextPage=(el,heightOverride)=>{const current=parseFloat(getComputedStyle(el).paddingTop)||0;const top=topOf(el)+current;const blockHeight=heightOverride||Math.max(0,el.offsetHeight-current);if(blockHeight<=0||blockHeight>H-2)return;const remaining=H-(top%H);if(blockHeight>remaining+1){el.style.paddingTop=(current+remaining)+'px';el.setAttribute('data-cv-preview-pad','true');}};document.querySelectorAll('.section').forEach((section)=>{if(!section.classList.contains('section-flow')){pushToNextPage(section);return;}const title=section.querySelector(':scope > .section-title');const first=title&&title.nextElementSibling;if(title&&first){const groupedBottom=topOf(first)+first.offsetHeight;pushToNextPage(section,groupedBottom-topOf(section));}section.querySelectorAll(':scope > .entry:not(.entry-flow),:scope > .skill-groups > .skill-group,:scope > .bullets > li:not(.item-flow),:scope > .entry-flow .bullets > li:not(.item-flow)').forEach((block)=>pushToNextPage(block));});page.style.transform='translateY(-'+OFFSET+'px)';};paginate();requestAnimationFrame(()=>requestAnimationFrame(paginate));})();</script>`;
|
||||
const withCss = html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
|
||||
return withCss.includes("</body>") ? withCss.replace("</body>", `${previewScript}</body>`) : `${withCss}${previewScript}`;
|
||||
}
|
||||
|
||||
export default function CvBuilderEditor() {
|
||||
@@ -362,6 +366,8 @@ export default function CvBuilderEditor() {
|
||||
|
||||
// Measure rendered content to drive page count + break indicators.
|
||||
const onIframeLoad = () => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
const measure = () => {
|
||||
try {
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
const h = Math.max(
|
||||
@@ -380,6 +386,9 @@ export default function CvBuilderEditor() {
|
||||
setPreviewOverflow(false);
|
||||
}
|
||||
};
|
||||
if (win) win.requestAnimationFrame(() => win.requestAnimationFrame(measure));
|
||||
else measure();
|
||||
};
|
||||
|
||||
const goToPage = (p: number) => {
|
||||
const clamped = Math.min(Math.max(1, p), pages);
|
||||
@@ -436,12 +445,11 @@ export default function CvBuilderEditor() {
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||
<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 === 5) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||
<Tab label="Content" />
|
||||
<Tab label="Template" />
|
||||
<Tab label="Design" />
|
||||
<Tab label="Layout" />
|
||||
<Tab label="AI" />
|
||||
<Tab label="History" />
|
||||
</Tabs>
|
||||
|
||||
@@ -449,8 +457,7 @@ export default function CvBuilderEditor() {
|
||||
{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 === 4 && <AiToolsTab />}
|
||||
{tab === 5 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
||||
@@ -475,7 +482,7 @@ export default function CvBuilderEditor() {
|
||||
</Stack>
|
||||
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
|
||||
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
|
||||
<iframe ref={iframeRef} title="CV preview" srcDoc={html} sandbox="allow-same-origin" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
|
||||
<iframe ref={iframeRef} title="CV preview" srcDoc={previewDocumentHtml(html, pageMetrics.heightPx)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
|
||||
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
{Array.from({ length: pages }).map((_, index) => (
|
||||
@@ -484,8 +491,8 @@ export default function CvBuilderEditor() {
|
||||
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}>
|
||||
<iframe
|
||||
title={`CV preview page ${index + 1}`}
|
||||
srcDoc={previewPageHtml(html, index, pageMetrics.heightPx)}
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, index)}
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
|
||||
/>
|
||||
</Box>
|
||||
@@ -543,6 +550,7 @@ function ContentTab({ settings, update, outline }: {
|
||||
outline: CvOutline | null;
|
||||
}) {
|
||||
const { confirmAction } = useDialogActions();
|
||||
const [sectionToAdd, setSectionToAdd] = useState("free-text");
|
||||
// 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 }));
|
||||
@@ -578,16 +586,18 @@ function ContentTab({ settings, update, outline }: {
|
||||
settings.customSections.map((section) => [`custom:${section.key}`, section]),
|
||||
), [settings.customSections]);
|
||||
|
||||
const orderedCustomSections = useMemo(() => {
|
||||
const rank = new Map(sectionRows.map((section, index) => [section.key, index]));
|
||||
return [...settings.customSections].sort((a, b) =>
|
||||
(rank.get(`custom:${a.key}`) ?? Number.MAX_SAFE_INTEGER) - (rank.get(`custom:${b.key}`) ?? Number.MAX_SAFE_INTEGER));
|
||||
}, [sectionRows, settings.customSections]);
|
||||
|
||||
const addCustom = () => {
|
||||
const preset = CUSTOM_SECTION_PRESETS.find((item) => item.key === sectionToAdd);
|
||||
if (preset && settings.customSections.some((item) => item.presetKey === preset.key)) return;
|
||||
const key = `c${Date.now().toString(36)}`;
|
||||
update({
|
||||
customSections: [...settings.customSections, { key, title: "New section", items: [] }],
|
||||
customSections: [...settings.customSections, {
|
||||
key,
|
||||
title: preset?.title ?? "New section",
|
||||
items: [],
|
||||
presetKey: preset?.key ?? null,
|
||||
contentType: preset?.contentType ?? "paragraphs",
|
||||
}],
|
||||
sections: [...sectionRows, { key: `custom:${key}` }],
|
||||
});
|
||||
};
|
||||
@@ -604,21 +614,6 @@ function ContentTab({ settings, update, outline }: {
|
||||
sections: sectionRows.filter((row) => row.key !== `custom:${section.key}`),
|
||||
});
|
||||
};
|
||||
const updateCustomItem = (key: string, index: number, value: string) => {
|
||||
const section = settings.customSections.find((item) => item.key === key);
|
||||
if (!section) return;
|
||||
updateCustom(key, { items: section.items.map((item, itemIndex) => itemIndex === index ? value : item) });
|
||||
};
|
||||
const removeCustomItem = async (section: CvCustomSectionSetting, index: number) => {
|
||||
const value = section.items[index];
|
||||
if (value.trim() && !(await confirmAction("Delete this custom section entry?", {
|
||||
title: "Delete custom entry",
|
||||
confirmLabel: "Delete entry",
|
||||
destructive: true,
|
||||
}))) return;
|
||||
updateCustom(section.key, { items: section.items.filter((_, itemIndex) => itemIndex !== index) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">Edit master profile</Button>}>
|
||||
@@ -650,61 +645,37 @@ function ContentTab({ settings, update, outline }: {
|
||||
const custom = customBySectionKey[row.key];
|
||||
if (custom) updateCustom(custom.key, { title });
|
||||
}}
|
||||
onUpdateCustom={(patch) => {
|
||||
const custom = customBySectionKey[row.key];
|
||||
if (custom) updateCustom(custom.key, patch);
|
||||
}}
|
||||
onRemoveCustom={() => {
|
||||
const custom = customBySectionKey[row.key];
|
||||
if (custom) void removeCustom(custom);
|
||||
}}
|
||||
onUpdateSettings={update}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Custom sections</Typography>
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addCustom}>Add</Button>
|
||||
</Stack>
|
||||
{settings.customSections.length === 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
|
||||
Add sections unique to this CV (e.g. a portfolio note) without changing your master profile.
|
||||
</Typography>
|
||||
)}
|
||||
{settings.customSections.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
|
||||
Reorder and show or hide custom sections in the section list above.
|
||||
</Typography>
|
||||
)}
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
{orderedCustomSections.map((c) => (
|
||||
<Paper key={c.key} variant="outlined" sx={{ p: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<TextField variant="standard" fullWidth value={c.title ?? ""} placeholder="Section title"
|
||||
error={!c.title?.trim()} helperText={!c.title?.trim() ? "Enter a section title." : undefined}
|
||||
onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} />
|
||||
<IconButton size="small" aria-label="Remove custom section" onClick={() => void removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
{c.items.map((item, itemIndex) => (
|
||||
<Stack key={`${c.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
||||
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item}
|
||||
error={!item.trim()} helperText={!item.trim() ? "Enter content or delete this entry." : undefined}
|
||||
onChange={(event) => updateCustomItem(c.key, itemIndex, event.target.value)} />
|
||||
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0}
|
||||
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1}
|
||||
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Add a section</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Preset sections use the same editable, reorderable card as every other section.</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
||||
<TextField select size="small" fullWidth label="Section type" value={sectionToAdd} onChange={(event) => setSectionToAdd(event.target.value)}>
|
||||
<MenuItem value="free-text">Custom free-text section</MenuItem>
|
||||
{CUSTOM_SECTION_PRESETS.map((preset) => <MenuItem key={preset.key} value={preset.key} disabled={settings.customSections.some((item) => item.presetKey === preset.key)}>{preset.title}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>Add section</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({
|
||||
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateSettings,
|
||||
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
|
||||
}: {
|
||||
row: CvSectionSetting;
|
||||
index: number;
|
||||
@@ -718,24 +689,30 @@ function SectionRow({
|
||||
onMove: (delta: number) => void;
|
||||
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
||||
onRenameCustom: (title: string) => void;
|
||||
onUpdateCustom: (patch: Partial<CvCustomSectionSetting>) => void;
|
||||
onRemoveCustom: () => void;
|
||||
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
||||
}) {
|
||||
const { confirmAction } = useDialogActions();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0;
|
||||
const sectionLabel = customSection?.title ?? row.title ?? SECTION_LABELS[row.key] ?? row.key;
|
||||
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
|
||||
const editableItems = row.items ?? sourceItems;
|
||||
const customText = customSection?.items.join("\n\n") ?? "";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
{...dragProps}
|
||||
sx={{
|
||||
p: 0.5, opacity: dragging ? 0.4 : row.hidden ? 0.5 : 1,
|
||||
opacity: dragging ? 0.4 : row.hidden ? 0.58 : 1,
|
||||
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
|
||||
transition: "opacity 150ms ease, border-color 120ms ease", cursor: "grab",
|
||||
transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease",
|
||||
overflow: "hidden", "&:hover": { boxShadow: 1 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled" }} aria-hidden />
|
||||
<Stack direction="row" alignItems="center" gap={0.5} sx={{ px: 0.75, py: 0.5 }}>
|
||||
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled", cursor: "grab" }} aria-hidden />
|
||||
<Stack>
|
||||
<IconButton size="small" aria-label={`Move ${sectionLabel} section up`} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||
<IconButton size="small" aria-label={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||
@@ -744,23 +721,48 @@ function SectionRow({
|
||||
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
|
||||
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
|
||||
{customSection && <Chip size="small" label="Custom" variant="outlined" sx={{ height: 20 }} />}
|
||||
{editable && (
|
||||
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel} entries`} aria-expanded={expanded} onClick={() => setExpanded((e) => !e)}
|
||||
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton size="small" aria-label={`${row.hidden ? "Show" : "Hide"} ${sectionLabel} section`} onClick={() => onPatch({ hidden: !row.hidden })}>
|
||||
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<IconButton size="small" aria-label={`Remove ${sectionLabel} section from this CV`} onClick={() => {
|
||||
if (customSection) onRemoveCustom();
|
||||
else void confirmAction(`Remove ${sectionLabel} from this CV? Your master career data will be preserved.`, { title: "Remove section", confirmLabel: "Remove from CV", destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
|
||||
}}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel}`} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}
|
||||
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
{editable && (
|
||||
<Collapse in={expanded} unmountOnExit>
|
||||
<Box sx={{ pl: 3, pt: 1 }}>
|
||||
<EntryEditor section={outlineSection!} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
||||
{customSection ? (
|
||||
<Stack spacing={1}>
|
||||
<TextField select size="small" label="Content format" value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
||||
<MenuItem value="paragraphs">Paragraphs</MenuItem><MenuItem value="bullets">Bullet list</MenuItem><MenuItem value="entries">Multiple entries</MenuItem>
|
||||
</TextField>
|
||||
{customSection.contentType === "entries" ? <Stack spacing={1}>
|
||||
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
||||
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
|
||||
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === customSection.items.length - 1} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void confirmAction("Delete this custom section entry?", { title: "Delete custom entry", confirmLabel: "Delete entry", destructive: true }).then((confirmed) => { if (confirmed) onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); })}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||
</Stack>)}
|
||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>Add entry</Button>
|
||||
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? "Paragraphs (separate with a blank line)" : "Bullet points (one per line)"} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
||||
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={customText} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
||||
</Stack>
|
||||
) : outlineSection?.kind === "entries" ? (
|
||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
<RichTextField label={outlineSection?.kind === "tags" ? "Items (one per line)" : "Content (one item per line)"} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
||||
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>Reset to master profile</Button>}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -826,6 +828,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
<RichTextField label="Bullet points (one per line)" minRows={2}
|
||||
value={(ov.bullets ?? entry.bullets).join("\n")}
|
||||
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
||||
{ov.bullets && (
|
||||
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>Reset to master bullets</Button>
|
||||
)}
|
||||
@@ -846,6 +849,8 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
update: (p: Partial<CvVariantSettings>) => void;
|
||||
themes: CvTheme[];
|
||||
}) {
|
||||
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
|
||||
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
||||
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
||||
const toggleSidebarSection = (key: string) => update({
|
||||
sidebarSections: sidebarSections.includes(key)
|
||||
@@ -866,13 +871,10 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }}
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
|
||||
<Box sx={{ height: 86, mb: 1, bgcolor: t.swatches[2] || "#fff", border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden", display: "grid", gridTemplateColumns: t.layout.startsWith("sidebar") ? (t.layout === "sidebar-right" ? "1fr 30%" : "30% 1fr") : "1fr" }}>
|
||||
{t.layout.startsWith("sidebar") && t.layout !== "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}><Box sx={{ width: 18, height: 18, borderRadius: t.photoShape === "circle" ? "50%" : 0.5, bgcolor: "rgba(255,255,255,.75)", mb: 0.75 }} />{[50, 72, 58, 68].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(255,255,255,.6)", mb: 0.5 }} />)}</Box> : null}
|
||||
<Box sx={{ p: 0.9 }}><Box sx={{ width: "55%", height: 5, bgcolor: t.swatches[0], mb: 0.75 }} />{[92, 74, 84, 64, 88, 78].map((w, index) => <Box key={index} sx={{ width: `${w}%`, height: index % 3 === 0 ? 3 : 2, bgcolor: index % 3 === 0 ? t.swatches[0] : "rgba(71,85,105,.28)", mb: 0.65 }} />)}</Box>
|
||||
{t.layout === "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}>{[64, 78, 52, 70, 58].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(30,41,59,.34)", mb: 0.6 }} />)}</Box> : null}
|
||||
</Box>
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={t} height={110} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{t.description}</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
||||
{t.atsFriendly && <Chip size="small" label="ATS-friendly" color="success" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||
{t.requiresPro && <Chip size="small" label={locked ? "Pro" : "Pro unlocked"} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||
@@ -884,50 +886,55 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
</Box>}
|
||||
|
||||
{mode === "design" && <>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Colour</Typography>
|
||||
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Colour</Typography>
|
||||
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
||||
<IconButton key={color} aria-label={`Use accent ${color}`} onClick={() => update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
|
||||
))}
|
||||
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", border: "1px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden" }}>
|
||||
<input type="color" aria-label="Custom accent colour" value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ width: 48, height: 48, border: 0, padding: 0, cursor: "pointer" }} />
|
||||
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", bgcolor: settings.accentColor ?? "#3157d5", border: "2px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden", position: "relative" }}>
|
||||
<input type="color" aria-label="Custom accent colour" value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
|
||||
</Box>
|
||||
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Theme default</Button>}
|
||||
</Stack>
|
||||
</Stack></>}
|
||||
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>Typography</Typography>
|
||||
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>Typography</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<FormControl size="small" fullWidth><InputLabel>Heading font</InputLabel><Select label="Heading font" value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Body font</InputLabel><Select label="Body font" value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||
</Box>
|
||||
<ControlSlider label="Body size" value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
||||
<ControlSlider label="Heading size" value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
||||
<ControlSlider label="Line height" value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} />
|
||||
<FormControl size="small" fullWidth><InputLabel>Heading treatment</InputLabel><Select label="Heading treatment" value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="caps-rule">Uppercase divider</MenuItem><MenuItem value="underline">Underline</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="bar">Accent bar</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Header treatment</InputLabel><Select label="Header treatment" value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="band">Colour band</MenuItem><MenuItem value="centered">Centred</MenuItem><MenuItem value="kicker">Editorial</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Skills presentation</InputLabel><Select inputProps={{ "aria-label": "Skills presentation" }} label="Skills presentation" value={settings.skillsStyle ?? "tags"} onChange={(e) => update({ skillsStyle: e.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">Tags</MenuItem><MenuItem value="text">Simple text</MenuItem></Select></FormControl>
|
||||
<ControlSlider label="Line height" value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
|
||||
{supports("heading") && <FormControl size="small" fullWidth><InputLabel>Heading treatment</InputLabel><Select label="Heading treatment" value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="caps-rule">Uppercase divider</MenuItem><MenuItem value="underline">Underline</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="bar">Accent bar</MenuItem></Select></FormControl>}
|
||||
{supports("header") && <FormControl size="small" fullWidth><InputLabel>Header treatment</InputLabel><Select label="Header treatment" value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="band">Colour band</MenuItem><MenuItem value="centered">Centred</MenuItem><MenuItem value="kicker">Editorial</MenuItem></Select></FormControl>}
|
||||
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>Skills presentation</InputLabel><Select inputProps={{ "aria-label": "Skills presentation" }} label="Skills presentation" value={settings.skillsStyle ?? "tags"} onChange={(e) => update({ skillsStyle: e.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">Tags</MenuItem><MenuItem value="text">Compact text</MenuItem><MenuItem value="bullets">Bullet list</MenuItem><MenuItem value="grouped">Grouped skills</MenuItem></Select></FormControl>
|
||||
{settings.skillsStyle === "grouped" && <TextField label="Skill groups" helperText="One group per line, for example: Backend: C#, .NET, Python" 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" && <>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Document</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
{supports("page") && <>
|
||||
<FormControl size="small" fullWidth><InputLabel>Page size</InputLabel><Select inputProps={{ "aria-label": "Page size" }} label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Density</InputLabel><Select inputProps={{ "aria-label": "Density" }} label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">Compact</MenuItem><MenuItem value="balanced">Balanced</MenuItem><MenuItem value="roomy">Roomy</MenuItem></Select></FormControl>
|
||||
</>}
|
||||
<FormControl size="small" fullWidth><InputLabel>Language</InputLabel><Select inputProps={{ "aria-label": "Language" }} label="Language" value={settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="no">Norwegian</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Date format</InputLabel><Select inputProps={{ "aria-label": "Date format" }} label="Date format" value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
||||
</Box>
|
||||
<FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>
|
||||
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>}
|
||||
{supports("spacing") && <>
|
||||
<ControlSlider label="Page margins" value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
|
||||
<ControlSlider label="Section spacing" value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
|
||||
<ControlSlider label="Entry spacing" value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
||||
{(settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
</>}
|
||||
{supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Sidebar content</Typography>
|
||||
<ControlSlider label="Sidebar width" value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
|
||||
<Stack>{["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={SECTION_LABELS[key] ?? "Contact details"} />)}</Stack>
|
||||
</Paper>}
|
||||
<Divider />
|
||||
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
|
||||
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported templates)" />
|
||||
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />}
|
||||
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons" />}
|
||||
</>}
|
||||
</Stack>
|
||||
);
|
||||
@@ -937,69 +944,6 @@ function ControlSlider({ label, value, min, max, step, suffix = "", onChange }:
|
||||
return <Box><Stack direction="row" justifyContent="space-between" alignItems="baseline"><Typography variant="body2">{label}</Typography><Typography variant="caption" color="text.secondary">{Number(value.toFixed(2))}{suffix}</Typography></Stack><Slider size="small" aria-label={label} value={value} min={min} max={max} step={step} onChange={(_, next) => onChange(next as number)} /></Box>;
|
||||
}
|
||||
|
||||
function AiToolsTab() {
|
||||
const { toast } = useToast();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const [text, setText] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [result, setResult] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (action: string) => {
|
||||
if (!canUseAi) return;
|
||||
if (!text.trim()) {
|
||||
toast("Paste some text to work on first.", "info");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await cvBuilderApi.aiAssist({ action, text, role: role || undefined });
|
||||
setResult(res.result);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, "AI request failed."), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
{canUseAi ? (
|
||||
<Alert severity="info" sx={{ py: 0.5 }}>AI suggestions never change your profile automatically. Copy what you like back into your CV.</Alert>
|
||||
) : (
|
||||
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine CV wording with Pro.">
|
||||
Generate optional writing suggestions while keeping all CV content editable on Free.
|
||||
</ProFeatureNotice>
|
||||
)}
|
||||
<TextField label="Text to improve" multiline minRows={4} fullWidth size="small" value={text} onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Paste a summary, a bullet, or a whole section…" />
|
||||
<TextField label="Target role (optional)" size="small" fullWidth value={role} onChange={(e) => setRole(e.target.value)} />
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{AI_ACTIONS.map((a) => (
|
||||
<Button key={a.key} size="small" variant="outlined" disabled={busy || !canUseAi} startIcon={<AutoFixHighIcon />} onClick={() => run(a.key)}>{canUseAi ? a.label : "Pro required"}</Button>
|
||||
))}
|
||||
</Box>
|
||||
{result && (
|
||||
<Stack spacing={1}>
|
||||
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="overline" color="text.secondary">Original</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
||||
</Paper>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
|
||||
<Typography variant="overline" color="primary.main">Suggested — review before using</Typography>
|
||||
<TextField aria-label="Editable AI suggestion" multiline minRows={4} fullWidth variant="standard" value={result} onChange={(event) => setResult(event.target.value)} />
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Button size="small" variant="contained" onClick={() => { setText(result); setResult(""); toast("Suggestion accepted into the working text.", "success"); }}>Accept</Button>
|
||||
<Button size="small" onClick={() => setResult("")}>Reject</Button>
|
||||
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) {
|
||||
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">No saved versions yet. Edits autosave and appear here.</Typography>;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user