feat(cv): rebuild professional resume studio

This commit is contained in:
cesnimda
2026-08-24 20:21:23 +02:00
parent dca5daa1a2
commit 367b70681a
27 changed files with 827 additions and 187 deletions
+71 -13
View File
@@ -2,30 +2,37 @@ import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert, Box, Button, Chip, IconButton, Menu, MenuItem, Paper, Stack, Typography,
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, 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 { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
export default function CvBuilderPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const { confirmAction, promptForValue } = useDialogActions();
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 [themes, setThemes] = useState<CvTheme[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("Untitled CV");
const [newTheme, setNewTheme] = useState("modern");
const [creating, setCreating] = useState(false);
const load = async () => {
try {
setVariants(await cvBuilderApi.list());
try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
} catch (err) {
setError(getApiErrorMessage(err, "Could not load your CVs."));
} finally {
@@ -37,12 +44,19 @@ export default function CvBuilderPage() {
}, []);
const createNew = async () => {
setCreateOpen(true);
};
const confirmCreate = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
const variant = await cvBuilderApi.create({ name: "Untitled CV", settings: emptyCvVariantSettings() });
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
setCreateOpen(false);
navigate(`/career/builder/${variant.id}`);
} catch (err) {
toast(getApiErrorMessage(err, "Could not create a CV."), "error");
}
} finally { setCreating(false); }
};
const duplicate = async (id: number) => {
@@ -76,12 +90,38 @@ export default function CvBuilderPage() {
}
};
const rename = async (id: number) => {
const current = variants.find((item) => item.id === id);
setMenu(null);
const nextName = await promptForValue("Give this CV a clear name.", current?.name ?? "", { title: "Rename CV", confirmLabel: "Rename" });
if (!nextName?.trim() || nextName.trim() === current?.name) return;
try {
const variant = await cvBuilderApi.get(id);
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" });
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
toast("CV renamed.", "success");
} catch (err) { toast(getApiErrorMessage(err, "Rename failed."), "error"); }
};
const download = async (id: number, cvName: string) => {
setMenu(null);
try {
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
const url = URL.createObjectURL(response.data as Blob);
const link = document.createElement("a");
link.href = url;
link.download = `${cvName || "cv"}.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (err) { toast(getApiErrorMessage(err, "PDF download failed."), "error"); }
};
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 }}>
<Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}>
<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>
<Typography color="text.secondary" sx={{ maxWidth: 720 }}>Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button>
</Paper>
@@ -97,14 +137,14 @@ export default function CvBuilderPage() {
</Paper>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{variants.map((v) => (
<Paper
key={v.id}
role="link"
tabIndex={0}
aria-label={`Open ${v.name}`}
sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
onClick={() => navigate(`/career/builder/${v.id}`)}
onKeyDown={(event) => {
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
@@ -112,19 +152,22 @@ export default function CvBuilderPage() {
navigate(`/career/builder/${v.id}`);
}
}}
>
>
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
<Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography>
<IconButton size="small" aria-label={`Actions for ${v.name}`} 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} />
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
{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()}
Updated {new Date(v.updatedAtUtc).toLocaleDateString()} · version {v.version}
{v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(" at ") || `job #${v.jobApplicationId}`}` : ""}
</Typography>
</Paper>
))}
@@ -133,8 +176,23 @@ export default function CvBuilderPage() {
<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 && void rename(menu.id)}>Rename</MenuItem>
<MenuItem onClick={() => { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>Download PDF</MenuItem>
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>Delete</MenuItem>
</Menu>
<Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title">
<DialogTitle id="create-cv-title">Create a CV</DialogTitle>
<DialogContent>
<Typography color="text.secondary" sx={{ mb: 2 }}>Start with your saved career profile, choose a visual direction, then tailor what appears.</Typography>
<TextField autoFocus fullWidth label="CV name" value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? "Enter a name." : "For example: Backend Engineer — Acme"} sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Choose a template</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" }, gap: 1 }}>
{(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => <Paper key={theme.id} role="button" tabIndex={0} aria-pressed={newTheme === theme.id} onClick={() => setNewTheme(theme.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setNewTheme(theme.id); } }} variant="outlined" sx={{ p: 0.75, cursor: "pointer", borderWidth: newTheme === theme.id ? 2 : 1, borderColor: newTheme === theme.id ? "primary.main" : "divider", "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main" } }}><CvTemplateThumbnail theme={theme} height={105} /><Typography variant="body2" sx={{ fontWeight: 700, mt: 0.75 }}>{theme.name}</Typography><Typography variant="caption" color="text.secondary">{theme.category}</Typography></Paper>)}
</Box>
</DialogContent>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>Cancel</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? "Creating…" : "Create CV"}</Button></DialogActions>
</Dialog>
</Box>
);
}