feat(career): CV Builder UI — 3-tab builder, live preview, variants, public CV
CI and Deploy / test (push) Failing after 1m57s
CI and Deploy / deploy (push) Has been skipped

Frontend for Phase 4. /career/builder lists CV variants; the editor has the
three spec tabs (Content / Customize / AI Tools, plus History) beside an
always-on live preview that re-renders through the server theme engine on a
debounce. Content: reorder/hide/rename sections, headline override, custom
sections. Customize: 8-theme picker, accent colour, fonts, density, page size,
photo/icons/page-number toggles. AI Tools: suggestion-only assistance (never
auto-applied). Autosave with version history + restore, public on/off with a
copyable /cv/{slug} link, PDF export. Public read-only page at /cv/:slug.

- cvBuilder.ts (types + API), CvBuilderPage, CvBuilderEditor, PublicCvPage
- routes + nav wired in App.tsx; "Open CV Builder" entry on Career Workspace
- 2 component tests; tsc + production build clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 10:05:21 +02:00
parent a3e18e4b44
commit 158dd02b00
7 changed files with 883 additions and 6 deletions
+7
View File
@@ -49,6 +49,9 @@ const RemindersView = lazy(() => import("./components/RemindersView"));
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage"));
const CvBuilderPage = lazy(() => import("./views/CvBuilderPage"));
const CvBuilderEditor = lazy(() => import("./views/CvBuilderEditor"));
const PublicCvPage = lazy(() => import("./views/PublicCvPage"));
const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage"));
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
@@ -242,6 +245,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/correspondence", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence/review", label: "Gmail review", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/career", label: "Career Workspace", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
{ to: "/career/builder", label: "CV Builder", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
];
@@ -323,6 +327,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/correspondence/review" element={<GmailReviewPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/career" element={<CareerWorkspacePage />} />
<Route path="/career/builder" element={<CvBuilderPage />} />
<Route path="/career/builder/:id" element={<CvBuilderEditor />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
@@ -378,6 +384,7 @@ export default function App() {
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/verify-email", element: <VerifyEmailPage />, errorElement: <RouteErrorPage /> },
{ path: "/cv/:slug", element: <PublicCvPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
@@ -0,0 +1,73 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import CvBuilderPage from './views/CvBuilderPage';
import { I18nProvider } from './i18n/I18nProvider';
import { ToastProvider } from './toast';
import { api } from './api';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_error: any, fallback?: string) => fallback || 'Request failed.',
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ToastProvider>
<I18nProvider>
<CvBuilderPage />
</I18nProvider>
</ToastProvider>
</MemoryRouter>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('lists existing CVs from the variants API', async () => {
mockedApi.get.mockResolvedValueOnce({
data: [
{ id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: true, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() },
],
} as any);
renderPage();
expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants');
});
test('shows the empty state and creates a CV then navigates to the editor', async () => {
mockedApi.get.mockResolvedValueOnce({ data: [] } as any);
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
renderPage();
expect(await screen.findByText('No CVs yet')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: /New CV/i })[0]);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' })));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
});
+121
View File
@@ -0,0 +1,121 @@
import { api } from "./api";
// Mirrors the backend CvVariantSettings (the lens over the master career profile).
export type CvSectionSetting = { key: string; hidden?: boolean; title?: 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 CvVariantSettings = {
themeId: string;
accentColor?: string | null;
headingFont?: string | null;
bodyFont?: string | null;
density?: string | null;
pageSize?: string | null;
dateFormat?: string | null;
language?: string | null;
headline?: string | null;
showPhoto: boolean;
showPageNumbers: boolean;
showIcons: boolean;
sections: CvSectionSetting[];
overrides: Record<string, CvItemOverride>;
customSections: CvCustomSectionSetting[];
};
export type CvTheme = {
id: string;
name: string;
category: string;
description: string;
layout: string;
accent: string;
photoShape: string;
supportsIcons: boolean;
swatches: string[];
};
export type CvVariantSummary = {
id: number;
name: string;
themeId: string;
publicSlug: string;
isPublic: boolean;
version: number;
jobApplicationId: number | null;
updatedAtUtc: string;
};
export type CvVariant = {
id: number;
name: string;
settings: CvVariantSettings;
isPublic: boolean;
publicSlug: string;
version: number;
jobApplicationId: number | null;
updatedAtUtc: string;
};
export type CvVariantVersionInfo = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
export type CvRender = { themeId: string; html: string; suggestedFileName: string };
export const AI_ACTIONS: { key: string; label: string }[] = [
{ key: "improve", label: "Improve writing" },
{ key: "professional", label: "Professional tone" },
{ key: "shorten", label: "Shorten" },
{ key: "expand", label: "Expand" },
{ key: "grammar", label: "Fix grammar" },
{ key: "ats", label: "ATS optimise" },
{ key: "bullets", label: "Generate bullets" },
{ key: "summary", label: "Generate summary" },
{ key: "rewrite", label: "Rewrite" },
{ key: "tailor", label: "Tailor to role" },
];
export const DEFAULT_SECTION_ORDER = [
"summary", "experience", "education", "projects", "skills", "certifications", "languages", "interests",
];
export const SECTION_LABELS: Record<string, string> = {
summary: "Professional Summary",
experience: "Experience",
education: "Education",
projects: "Projects",
skills: "Skills",
certifications: "Certifications",
languages: "Languages",
interests: "Interests",
};
export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
return {
themeId,
showPhoto: false,
showPageNumbers: false,
showIcons: true,
sections: [],
overrides: {},
customSections: [],
};
}
// --- API ---
export const cvBuilderApi = {
themes: () => api.get<CvTheme[]>("/cv/themes").then((r) => r.data),
list: () => api.get<CvVariantSummary[]>("/cv/variants").then((r) => r.data),
create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
api.post<CvVariant>("/cv/variants", body).then((r) => r.data),
get: (id: number) => api.get<CvVariant>(`/cv/variants/${id}`).then((r) => r.data),
save: (id: number, body: { name?: string; settings: CvVariantSettings; source?: string }) =>
api.put<CvVariant>(`/cv/variants/${id}`, body).then((r) => r.data),
remove: (id: number) => api.delete(`/cv/variants/${id}`),
duplicate: (id: number, name?: string) => api.post<CvVariant>(`/cv/variants/${id}/duplicate`, { name }).then((r) => r.data),
setPublic: (id: number, isPublic: boolean) => api.put<CvVariant>(`/cv/variants/${id}/public`, { isPublic }).then((r) => r.data),
versions: (id: number) => api.get<CvVariantVersionInfo[]>(`/cv/variants/${id}/versions`).then((r) => r.data),
restore: (id: number, version: number) => api.post<CvVariant>(`/cv/variants/${id}/versions/${version}/restore`).then((r) => r.data),
previewSettings: (settings: CvVariantSettings) => api.post<CvRender>("/cv/preview", { settings }).then((r) => r.data),
aiAssist: (body: { action: string; text: string; role?: string; language?: string; context?: string }) =>
api.post<{ original: string; result: string }>("/cv/ai/assist", body).then((r) => r.data),
exportPdfUrl: (id: number) => `/cv/variants/${id}/export-pdf`,
};
@@ -1,6 +1,8 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Box, Paper, Typography } from "@mui/material";
import { Alert, Box, Button, Paper, Typography } from "@mui/material";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import CareerProfilePage from "./CareerProfilePage";
@@ -8,13 +10,17 @@ import CareerProfilePage from "./CareerProfilePage";
// truth for all generated documents. It is a dedicated component (CareerProfilePage), no longer a
// careerOnly fork of ProfilePage. The CV Builder is deliberately NOT here yet (Phase 4).
export default function CareerWorkspacePage() {
const navigate = useNavigate();
return (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Career Workspace</Typography>
<Typography sx={{ color: "text.secondary" }}>
Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs.
</Typography>
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Career Workspace</Typography>
<Typography sx={{ color: "text.secondary" }}>
Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs.
</Typography>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button>
</Paper>
<Alert severity="info" sx={{ borderRadius: 3 }}>
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
@@ -0,0 +1,497 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert, Box, Button, Chip, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
MenuItem, Paper, Select, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
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 { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import {
AI_ACTIONS, CvCustomSectionSetting, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings,
CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, cvBuilderApi,
} from "../cvBuilder";
const FONTS = [
"'Segoe UI', Roboto, Arial, sans-serif",
"Arial, Helvetica, sans-serif",
"Georgia, 'Times New Roman', serif",
"'Helvetica Neue', Arial, sans-serif",
"'Roboto', Arial, sans-serif",
"'Poppins', 'Segoe UI', Arial, sans-serif",
];
const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"];
export default function CvBuilderEditor() {
const { id } = useParams();
const variantId = Number(id);
const navigate = useNavigate();
const { toast } = useToast();
const [name, setName] = useState("");
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
const [themes, setThemes] = useState<CvTheme[]>([]);
const [isPublic, setIsPublic] = useState(false);
const [publicSlug, setPublicSlug] = useState("");
const [tab, setTab] = useState(0);
const [html, setHtml] = useState("");
const [zoom, setZoom] = useState(0.62);
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dirty = useRef(false);
// Load variant + theme catalog once.
useEffect(() => {
let alive = true;
(async () => {
try {
const [variant, themeList] = await Promise.all([cvBuilderApi.get(variantId), cvBuilderApi.themes()]);
if (!alive) return;
applyVariant(variant);
setThemes(themeList);
} catch (err) {
if (alive) setLoadError(getApiErrorMessage(err, "Could not open this CV."));
}
})();
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variantId]);
const applyVariant = (variant: CvVariant) => {
setName(variant.name);
setSettings(variant.settings);
setIsPublic(variant.isPublic);
setPublicSlug(variant.publicSlug);
};
// Debounced live preview whenever settings change.
useEffect(() => {
if (!settings) return;
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(async () => {
try {
const render = await cvBuilderApi.previewSettings(settings);
setHtml(render.html);
} catch {
/* preview is best-effort; keep the last good render */
}
}, 350);
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
};
}, [settings]);
// Debounced autosave.
const scheduleSave = useCallback(
(next: CvVariantSettings, nextName?: string) => {
dirty.current = true;
setSaveState("saving");
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
try {
await cvBuilderApi.save(variantId, { name: nextName ?? name, settings: next, source: "autosave" });
dirty.current = false;
setSaveState("saved");
} catch {
setSaveState("error");
}
}, 800);
},
[variantId, name],
);
const update = (patch: Partial<CvVariantSettings>) => {
setSettings((prev) => {
if (!prev) return prev;
const next = { ...prev, ...patch };
scheduleSave(next);
return next;
});
};
const renameVariant = (value: string) => {
setName(value);
if (settings) scheduleSave(settings, value);
};
// Section rows: settings.sections is authoritative once touched; otherwise the default order,
// always ensuring every known section is present so it can be reordered/hidden.
const sectionRows: CvSectionSetting[] = useMemo(() => {
if (!settings) return [];
const base = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
const have = new Set(base.map((s) => s.key));
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key });
return base;
}, [settings]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const moveSection = (index: number, delta: number) => {
const rows = [...sectionRows];
const target = index + delta;
if (target < 0 || target >= rows.length) return;
[rows[index], rows[target]] = [rows[target], rows[index]];
writeSections(rows);
};
const toggleSection = (index: number) => {
const rows = sectionRows.map((r, i) => (i === index ? { ...r, hidden: !r.hidden } : r));
writeSections(rows);
};
const renameSection = (index: number, title: string) => {
const rows = sectionRows.map((r, i) => (i === index ? { ...r, title: title || undefined } : r));
writeSections(rows);
};
const togglePublic = async () => {
try {
const updated = await cvBuilderApi.setPublic(variantId, !isPublic);
setIsPublic(updated.isPublic);
setPublicSlug(updated.publicSlug);
toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success");
} catch (err) {
toast(getApiErrorMessage(err, "Could not change visibility."), "error");
}
};
const copyPublicLink = () => {
const url = `${window.location.origin}/cv/${publicSlug}`;
navigator.clipboard?.writeText(url);
toast("Public link copied.", "success");
};
const exportPdf = async () => {
try {
const res = await api.post(cvBuilderApi.exportPdfUrl(variantId), {}, { responseType: "blob" });
const url = URL.createObjectURL(res.data as Blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name || "cv"}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
toast(getApiErrorMessage(err, "PDF export failed."), "error");
}
};
const loadVersions = async () => {
try {
setVersions(await cvBuilderApi.versions(variantId));
} catch (err) {
toast(getApiErrorMessage(err, "Could not load history."), "error");
}
};
const restore = async (version: number) => {
try {
const updated = await cvBuilderApi.restore(variantId, version);
applyVariant(updated);
await loadVersions();
toast(`Restored version ${version}.`, "success");
} catch (err) {
toast(getApiErrorMessage(err, "Restore failed."), "error");
}
};
if (loadError) {
return (
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/career/builder")}>Back to CVs</Button>
<Alert severity="error" sx={{ mt: 2 }}>{loadError}</Alert>
</Box>
);
}
if (!settings) return <Box sx={{ p: 3 }}>Loading</Box>;
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
{/* Left: controls */}
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton>
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } } }} />
<SaveBadge state={saveState} />
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} onClick={exportPdf}>PDF</Button>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} onClick={togglePublic}>
{isPublic ? "Public" : "Private"}
</Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
</Stack>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="fullWidth" sx={{ mb: 1.5 }}>
<Tab label="Content" />
<Tab label="Customize" />
<Tab label="AI Tools" />
<Tab label="History" />
</Tabs>
{tab === 0 && (
<ContentTab settings={settings} update={update} sectionRows={sectionRows}
moveSection={moveSection} toggleSection={toggleSection} renameSection={renameSection} />
)}
{tab === 1 && <CustomizeTab settings={settings} update={update} themes={themes} />}
{tab === 2 && <AiToolsTab />}
{tab === 3 && <HistoryTab versions={versions} onRestore={restore} />}
</Paper>
{/* Right: live preview */}
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "#e9edf2" }}>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 1, px: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="caption">Zoom</Typography>
<Slider size="small" value={zoom} min={0.4} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 120 }} />
</Stack>
<Box sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
<Box sx={{ width: `calc(210mm * ${zoom})`, flex: "0 0 auto" }}>
<iframe
title="CV preview"
srcDoc={html}
style={{
width: "210mm", height: "297mm", border: "none",
transform: `scale(${zoom})`, transformOrigin: "top left",
boxShadow: "0 8px 30px rgba(0,0,0,0.18)", background: "#fff", display: "block",
}}
/>
</Box>
</Box>
</Paper>
</Box>
);
}
function SaveBadge({ state }: { state: "idle" | "saving" | "saved" | "error" }) {
const map = {
idle: { label: "", color: "default" as const },
saving: { label: "Saving…", color: "warning" as const },
saved: { label: "Saved", color: "success" as const },
error: { label: "Save failed", color: "error" as const },
};
const m = map[state];
if (!m.label) return null;
return <Chip size="small" label={m.label} color={m.color} variant="outlined" />;
}
function ContentTab({ settings, update, sectionRows, moveSection, toggleSection, renameSection }: {
settings: CvVariantSettings;
update: (p: Partial<CvVariantSettings>) => void;
sectionRows: CvSectionSetting[];
moveSection: (i: number, d: number) => void;
toggleSection: (i: number) => void;
renameSection: (i: number, t: string) => void;
}) {
const addCustom = () => {
const key = `c${Date.now().toString(36)}`;
update({ customSections: [...settings.customSections, { key, title: "New section", items: [] }] });
};
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) => {
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
};
const removeCustom = (key: string) => update({ customSections: settings.customSections.filter((c) => c.key !== key) });
return (
<Stack spacing={2}>
<TextField label="Headline override" size="small" fullWidth value={settings.headline ?? ""}
onChange={(e) => update({ headline: e.target.value || null })}
helperText="Blank uses the headline from your master profile." />
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>Sections</Typography>
<Typography variant="caption" color="text.secondary">Reorder, hide, or rename. Content comes from your master profile.</Typography>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{sectionRows.map((row, i) => (
<Paper key={row.key} variant="outlined" sx={{ p: 0.5, display: "flex", alignItems: "center", gap: 0.5, opacity: row.hidden ? 0.5 : 1 }}>
<Stack>
<IconButton size="small" disabled={i === 0} onClick={() => moveSection(i, -1)}><ArrowUpwardIcon sx={{ fontSize: 16 }} /></IconButton>
<IconButton size="small" disabled={i === sectionRows.length - 1} onClick={() => moveSection(i, 1)}><ArrowDownwardIcon sx={{ fontSize: 16 }} /></IconButton>
</Stack>
<TextField variant="standard" fullWidth value={row.title ?? SECTION_LABELS[row.key] ?? row.key}
onChange={(e) => renameSection(i, e.target.value)} slotProps={{ input: { disableUnderline: true } }} />
<IconButton size="small" onClick={() => toggleSection(i)}>
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</Paper>
))}
</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>
<Stack spacing={1} sx={{ mt: 1 }}>
{settings.customSections.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"
onChange={(e) => updateCustom(c.key, { title: e.target.value })} />
<IconButton size="small" onClick={() => removeCustom(c.key)}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack>
<TextField multiline minRows={2} fullWidth size="small" sx={{ mt: 1 }} placeholder="One item per line"
value={c.items.join("\n")} onChange={(e) => updateCustom(c.key, { items: e.target.value.split("\n") })} />
</Paper>
))}
</Stack>
</Box>
</Stack>
);
}
function CustomizeTab({ settings, update, themes }: {
settings: CvVariantSettings;
update: (p: Partial<CvVariantSettings>) => void;
themes: CvTheme[];
}) {
return (
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
{themes.map((t) => {
const active = t.id === settings.themeId;
return (
<Paper key={t.id} variant="outlined"
onClick={() => update({ themeId: t.id })}
sx={{ p: 1, cursor: "pointer", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1 }}>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.5 }}>
{t.swatches.map((s, i) => <Box key={i} sx={{ width: 14, height: 14, borderRadius: "3px", bgcolor: s, border: "1px solid rgba(0,0,0,0.1)" }} />)}
</Stack>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
<Typography variant="caption" color="text.secondary">{t.category}</Typography>
</Paper>
);
})}
</Box>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" sx={{ flex: 1 }}>Accent colour</Typography>
<input type="color" value={settings.accentColor ?? "#2563eb"} onChange={(e) => update({ accentColor: e.target.value })} />
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Reset</Button>}
</Stack>
<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>
<FormControl size="small" fullWidth>
<InputLabel>Density</InputLabel>
<Select 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>Page size</InputLabel>
<Select label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}>
<MenuItem value="a4">A4</MenuItem>
<MenuItem value="letter">Letter</MenuItem>
</Select>
</FormControl>
<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 themes)" />
<FormControlLabel control={<Switch checked={settings.showPageNumbers} onChange={(e) => update({ showPageNumbers: e.target.checked })} />} label="Page numbers" />
</Stack>
);
}
function AiToolsTab() {
const { toast } = useToast();
const [text, setText] = useState("");
const [role, setRole] = useState("");
const [result, setResult] = useState("");
const [busy, setBusy] = useState(false);
const run = async (action: string) => {
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}>
<Alert severity="info" sx={{ py: 0.5 }}>AI suggestions never change your profile automatically. Copy what you like back into your master profile.</Alert>
<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} startIcon={<AutoFixHighIcon />} onClick={() => run(a.key)}>{a.label}</Button>
))}
</Box>
{result && (
<Paper variant="outlined" sx={{ p: 1.5 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Suggestion</Typography>
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
</Stack>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{result}</Typography>
</Paper>
)}
</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.</Typography>;
return (
<Stack spacing={0.5}>
{versions.map((v) => (
<Paper key={v.version} variant="outlined" sx={{ p: 1, display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version {v.version} {v.isCurrent && <Chip size="small" label="current" sx={{ ml: 0.5 }} />}</Typography>
<Typography variant="caption" color="text.secondary">{v.source} · {new Date(v.createdAtUtc).toLocaleString()}</Typography>
</Box>
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>Restore</Button>}
</Paper>
))}
</Stack>
);
}
+118
View File
@@ -0,0 +1,118 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert, Box, Button, Chip, IconButton, Menu, MenuItem, Paper, Stack, Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import PublicIcon from "@mui/icons-material/Public";
import { getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
export default function CvBuilderPage() {
const navigate = useNavigate();
const { toast } = useToast();
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
const load = async () => {
try {
setVariants(await cvBuilderApi.list());
} catch (err) {
setError(getApiErrorMessage(err, "Could not load your CVs."));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const createNew = async () => {
try {
const variant = await cvBuilderApi.create({ name: "Untitled CV", settings: emptyCvVariantSettings() });
navigate(`/career/builder/${variant.id}`);
} catch (err) {
toast(getApiErrorMessage(err, "Could not create a CV."), "error");
}
};
const duplicate = async (id: number) => {
try {
await cvBuilderApi.duplicate(id);
await load();
toast("CV duplicated.", "success");
} catch (err) {
toast(getApiErrorMessage(err, "Duplicate failed."), "error");
} finally {
setMenu(null);
}
};
const remove = async (id: number) => {
try {
await cvBuilderApi.remove(id);
setVariants((v) => v.filter((x) => x.id !== id));
toast("CV deleted.", "success");
} catch (err) {
toast(getApiErrorMessage(err, "Delete failed."), "error");
} finally {
setMenu(null);
}
};
return (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2.5, borderRadius: 4, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>CV Builder</Typography>
<Typography color="text.secondary">Build tailored CVs from your master profile. Content stays in your profile each CV is a theme + a selection.</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button>
</Paper>
{error && <Alert severity="error">{error}</Alert>}
{!loading && variants.length === 0 && !error && (
<Paper sx={{ p: 4, borderRadius: 4, textAlign: "center" }}>
<DescriptionOutlinedIcon sx={{ fontSize: 48, color: "text.disabled" }} />
<Typography sx={{ mt: 1, fontWeight: 700 }}>No CVs yet</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>Create your first CV it pulls straight from your career profile.</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button>
</Paper>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
{variants.map((v) => (
<Paper key={v.id} sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 } }} onClick={() => navigate(`/career/builder/${v.id}`)}>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
<MoreVertIcon fontSize="small" />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Chip size="small" label={v.themeId} />
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label="Public" />}
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
Updated {new Date(v.updatedAtUtc).toLocaleDateString()}
</Typography>
</Paper>
))}
</Box>
<Menu anchorEl={menu?.anchor} open={!!menu} onClose={() => setMenu(null)}>
<MenuItem onClick={() => menu && navigate(`/career/builder/${menu.id}`)}>Open</MenuItem>
<MenuItem onClick={() => menu && duplicate(menu.id)}>Duplicate</MenuItem>
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>Delete</MenuItem>
</Menu>
</Box>
);
}
+55
View File
@@ -0,0 +1,55 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
export default function PublicCvPage() {
const { slug } = useParams();
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const meta = document.createElement("meta");
meta.name = "robots";
meta.content = "noindex, nofollow";
document.head.appendChild(meta);
let alive = true;
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
.then((r) => { if (alive) setHtml(r.data.html); })
.catch((err) => { if (alive) setError(getApiErrorMessage(err, "This CV is not available.")); });
return () => {
alive = false;
document.head.removeChild(meta);
};
}, [slug]);
if (error) {
return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
{error}
</div>
);
}
if (html === null) {
return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#9ca3af" }}>
Loading
</div>
);
}
return (
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 0", display: "flex", justifyContent: "center" }}>
<iframe
title="Public CV"
srcDoc={html}
sandbox="allow-same-origin"
style={{ width: "210mm", height: "297mm", border: "none", background: "#fff", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
/>
</div>
);
}