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 QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./views/ProfilePage")); const ProfilePage = lazy(() => import("./views/ProfilePage"));
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage")); 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 ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage"));
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage")); const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage")); 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", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence/review", label: "Gmail review", 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", 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") }, { 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="/correspondence/review" element={<GmailReviewPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/career" element={<CareerWorkspacePage />} /> <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/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} /> <Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} /> <Route path="/admin/system" element={<AdminSystemPage />} />
@@ -378,6 +384,7 @@ export default function App() {
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> }, { path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> }, { path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/verify-email", element: <VerifyEmailPage />, 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 /> }, { path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]); ], { 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 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"; 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 // 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). // careerOnly fork of ProfilePage. The CV Builder is deliberately NOT here yet (Phase 4).
export default function CareerWorkspacePage() { export default function CareerWorkspacePage() {
const navigate = useNavigate();
return ( return (
<Box sx={{ display: "grid", gap: 2 }}> <Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}> <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 variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Career Workspace</Typography>
<Typography sx={{ color: "text.secondary" }}> <Typography sx={{ color: "text.secondary" }}>
Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs. Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs.
</Typography> </Typography>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button>
</Paper> </Paper>
<Alert severity="info" sx={{ borderRadius: 3 }}> <Alert severity="info" sx={{ borderRadius: 3 }}>
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it. 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>
);
}