feat(career): CV Builder UI — 3-tab builder, live preview, variants, public CV
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:
@@ -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'));
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user