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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user