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