diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx
index 81ea16c..3019615 100644
--- a/job-tracker-ui/src/App.tsx
+++ b/job-tracker-ui/src/App.tsx
@@ -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: , section: t("manage") },
{ to: "/correspondence/review", label: "Gmail review", icon: , section: t("manage") },
{ to: "/career", label: "Career Workspace", icon: , section: t("manage") },
+ { to: "/career/builder", label: "CV Builder", icon: , section: t("manage") },
{ to: "/trash", label: t("trash"), icon: , section: t("manage") },
];
@@ -323,6 +327,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
} />
} />
} />
+ } />
+ } />
} />
} />
} />
@@ -378,6 +384,7 @@ export default function App() {
{ path: "/forgot-password", element: , errorElement: },
{ path: "/reset-password", element: , errorElement: },
{ path: "/verify-email", element: , errorElement: },
+ { path: "/cv/:slug", element: , errorElement: },
{ path: "/*", element: , errorElement: },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
diff --git a/job-tracker-ui/src/cv-builder-page.test.tsx b/job-tracker-ui/src/cv-builder-page.test.tsx
new file mode 100644
index 0000000..84769ac
--- /dev/null
+++ b/job-tracker-ui/src/cv-builder-page.test.tsx
@@ -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;
+
+function renderPage() {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+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'));
+});
diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts
new file mode 100644
index 0000000..4040db3
--- /dev/null
+++ b/job-tracker-ui/src/cvBuilder.ts
@@ -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;
+ 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 = {
+ 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("/cv/themes").then((r) => r.data),
+ list: () => api.get("/cv/variants").then((r) => r.data),
+ create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
+ api.post("/cv/variants", body).then((r) => r.data),
+ get: (id: number) => api.get(`/cv/variants/${id}`).then((r) => r.data),
+ save: (id: number, body: { name?: string; settings: CvVariantSettings; source?: string }) =>
+ api.put(`/cv/variants/${id}`, body).then((r) => r.data),
+ remove: (id: number) => api.delete(`/cv/variants/${id}`),
+ duplicate: (id: number, name?: string) => api.post(`/cv/variants/${id}/duplicate`, { name }).then((r) => r.data),
+ setPublic: (id: number, isPublic: boolean) => api.put(`/cv/variants/${id}/public`, { isPublic }).then((r) => r.data),
+ versions: (id: number) => api.get(`/cv/variants/${id}/versions`).then((r) => r.data),
+ restore: (id: number, version: number) => api.post(`/cv/variants/${id}/versions/${version}/restore`).then((r) => r.data),
+ previewSettings: (settings: CvVariantSettings) => api.post("/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`,
+};
diff --git a/job-tracker-ui/src/views/CareerWorkspacePage.tsx b/job-tracker-ui/src/views/CareerWorkspacePage.tsx
index 4bae354..de69577 100644
--- a/job-tracker-ui/src/views/CareerWorkspacePage.tsx
+++ b/job-tracker-ui/src/views/CareerWorkspacePage.tsx
@@ -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 (
-
- Career Workspace
-
- Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs.
-
+
+
+ Career Workspace
+
+ Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs.
+
+
+ } onClick={() => navigate("/career/builder")}>Open CV Builder
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx
new file mode 100644
index 0000000..a016129
--- /dev/null
+++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx
@@ -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(null);
+ const [themes, setThemes] = useState([]);
+ 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([]);
+ const [loadError, setLoadError] = useState(null);
+
+ const saveTimer = useRef | null>(null);
+ const previewTimer = useRef | 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) => {
+ 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 (
+
+ } onClick={() => navigate("/career/builder")}>Back to CVs
+ {loadError}
+
+ );
+ }
+ if (!settings) return Loading…;
+
+ return (
+
+ {/* Left: controls */}
+
+
+ navigate("/career/builder")}>
+ renameVariant(e.target.value)}
+ slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } } }} />
+
+
+
+ } onClick={exportPdf}>PDF
+ } onClick={togglePublic}>
+ {isPublic ? "Public" : "Private"}
+
+ {isPublic && } onClick={copyPublicLink}>Copy link}
+
+
+ { setTab(v); if (v === 3) loadVersions(); }} variant="fullWidth" sx={{ mb: 1.5 }}>
+
+
+
+
+
+
+ {tab === 0 && (
+
+ )}
+ {tab === 1 && }
+ {tab === 2 && }
+ {tab === 3 && }
+
+
+ {/* Right: live preview */}
+
+
+ Live preview
+
+ Zoom
+ setZoom(v as number)} sx={{ width: 120 }} />
+
+
+
+
+
+
+
+
+ );
+}
+
+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 ;
+}
+
+function ContentTab({ settings, update, sectionRows, moveSection, toggleSection, renameSection }: {
+ settings: CvVariantSettings;
+ update: (p: Partial) => 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) => {
+ 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 (
+
+ update({ headline: e.target.value || null })}
+ helperText="Blank uses the headline from your master profile." />
+
+
+ Sections
+ Reorder, hide, or rename. Content comes from your master profile.
+
+ {sectionRows.map((row, i) => (
+
+
+ moveSection(i, -1)}>
+ moveSection(i, 1)}>
+
+ renameSection(i, e.target.value)} slotProps={{ input: { disableUnderline: true } }} />
+ toggleSection(i)}>
+ {row.hidden ? : }
+
+
+ ))}
+
+
+
+
+
+ Custom sections
+ } onClick={addCustom}>Add
+
+
+ {settings.customSections.map((c) => (
+
+
+ updateCustom(c.key, { title: e.target.value })} />
+ removeCustom(c.key)}>
+
+ updateCustom(c.key, { items: e.target.value.split("\n") })} />
+
+ ))}
+
+
+
+ );
+}
+
+function CustomizeTab({ settings, update, themes }: {
+ settings: CvVariantSettings;
+ update: (p: Partial) => void;
+ themes: CvTheme[];
+}) {
+ return (
+
+
+ Theme
+
+ {themes.map((t) => {
+ const active = t.id === settings.themeId;
+ return (
+ update({ themeId: t.id })}
+ sx={{ p: 1, cursor: "pointer", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1 }}>
+
+ {t.swatches.map((s, i) => )}
+
+ {t.name}
+ {t.category}
+
+ );
+ })}
+
+
+
+
+ Accent colour
+ update({ accentColor: e.target.value })} />
+ {settings.accentColor && }
+
+
+
+ Heading font
+
+
+
+ Body font
+
+
+
+
+ Density
+
+
+
+ Page size
+
+
+
+
+ update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
+ update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" />
+ update({ showPageNumbers: e.target.checked })} />} label="Page numbers" />
+
+ );
+}
+
+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 (
+
+ AI suggestions never change your profile automatically. Copy what you like back into your master profile.
+ setText(e.target.value)}
+ placeholder="Paste a summary, a bullet, or a whole section…" />
+ setRole(e.target.value)} />
+
+ {AI_ACTIONS.map((a) => (
+ } onClick={() => run(a.key)}>{a.label}
+ ))}
+
+ {result && (
+
+
+ Suggestion
+ } onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy
+
+ {result}
+
+ )}
+
+ );
+}
+
+function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) {
+ if (versions.length === 0) return No saved versions yet.;
+ return (
+
+ {versions.map((v) => (
+
+
+ Version {v.version} {v.isCurrent && }
+ {v.source} · {new Date(v.createdAtUtc).toLocaleString()}
+
+ {!v.isCurrent && }
+
+ ))}
+
+ );
+}
diff --git a/job-tracker-ui/src/views/CvBuilderPage.tsx b/job-tracker-ui/src/views/CvBuilderPage.tsx
new file mode 100644
index 0000000..655ea1e
--- /dev/null
+++ b/job-tracker-ui/src/views/CvBuilderPage.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+
+
+ CV Builder
+ Build tailored CVs from your master profile. Content stays in your profile — each CV is a theme + a selection.
+
+ } onClick={createNew}>New CV
+
+
+ {error && {error}}
+
+ {!loading && variants.length === 0 && !error && (
+
+
+ No CVs yet
+ Create your first CV — it pulls straight from your career profile.
+ } onClick={createNew}>New CV
+
+ )}
+
+
+ {variants.map((v) => (
+ navigate(`/career/builder/${v.id}`)}>
+
+ {v.name}
+ { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
+
+
+
+
+
+ {v.isPublic && } label="Public" />}
+
+
+ Updated {new Date(v.updatedAtUtc).toLocaleDateString()}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/job-tracker-ui/src/views/PublicCvPage.tsx b/job-tracker-ui/src/views/PublicCvPage.tsx
new file mode 100644
index 0000000..26284a4
--- /dev/null
+++ b/job-tracker-ui/src/views/PublicCvPage.tsx
@@ -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(null);
+ const [error, setError] = useState(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 (
+
+ {error}
+
+ );
+ }
+ if (html === null) {
+ return (
+
+ Loading…
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}