feat(career): builder UX overhaul — DnD, rich text, per-item editing, richer preview
CI and Deploy / test (push) Failing after 1m56s
CI and Deploy / deploy (push) Has been skipped

Phase 4.5 (priorities 1, 2, 5, 6).
- Content tab now reads GET /api/cv/outline: each entry-based section expands
  to its entries with per-item hide, title/subtitle override, and rich-text
  bullet editing (RichTextField markdown toolbar).
- Native HTML5 drag-and-drop reorder for sections AND entries (useDragReorder,
  zero deps), with drop-line + dim animations; arrow buttons remain for keyboard.
- Preview: zoom presets (+/- , slider, Fit), measured page count with page
  navigation and dashed page-break indicators, an "updating…" chip, 300ms debounce.
- Save indicator now distinguishes Unsaved / Saving / Saved / failed (aria-live).
- Loading skeletons, better empty states, ATS-friendly theme badge, relative
  times in History; a11y: ARIA labels, keyboard-selectable theme cards, focus rings.
- Pure helpers moveItem/wrapSelection extracted + unit-tested; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 14:46:37 +02:00
parent e3b255f226
commit 582c4e07e9
5 changed files with 498 additions and 99 deletions
@@ -0,0 +1,73 @@
import React, { useRef } from "react";
import { Box, IconButton, TextField, Tooltip } from "@mui/material";
import FormatBoldIcon from "@mui/icons-material/FormatBold";
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
import FormatUnderlinedIcon from "@mui/icons-material/FormatUnderlined";
import LinkIcon from "@mui/icons-material/Link";
import { wrapSelection } from "../cvBuilder";
// Lightweight rich text: a plain textarea plus a markdown toolbar. Storage stays plain text
// (**bold**, *italic*, __underline__, [text](url)); the server renderer converts the same whitelist
// to safe HTML. No RTE dependency, no HTML sanitisation surface. Bullet lists are the field itself —
// one line per bullet — so there is no separate list button.
export default function RichTextField({
value,
onChange,
label,
placeholder,
minRows = 2,
ariaLabel,
}: {
value: string;
onChange: (v: string) => void;
label?: string;
placeholder?: string;
minRows?: number;
ariaLabel?: string;
}) {
const ref = useRef<HTMLTextAreaElement | null>(null);
const apply = (before: string, after: string, placeholderText: string) => {
const el = ref.current;
if (!el) return;
const { text, selStart, selEnd } = wrapSelection(value, el.selectionStart, el.selectionEnd, before, after, placeholderText);
onChange(text);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(selStart, selEnd);
});
};
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
<Tooltip title={title}>
<IconButton size="small" aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
{icon}
</IconButton>
</Tooltip>
);
return (
<Box>
<Box sx={{ display: "flex", gap: 0.25, mb: 0.25 }}>
{btn("Bold", <FormatBoldIcon fontSize="inherit" />, "**", "**", "bold text")}
{btn("Italic", <FormatItalicIcon fontSize="inherit" />, "*", "*", "italic text")}
{btn("Underline", <FormatUnderlinedIcon fontSize="inherit" />, "__", "__", "underlined")}
{btn("Link", <LinkIcon fontSize="inherit" />, "[", "](https://)", "link text")}
</Box>
<TextField
inputRef={ref}
label={label}
placeholder={placeholder}
multiline
minRows={minRows}
fullWidth
size="small"
value={value}
onChange={(e) => onChange(e.target.value)}
slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }}
/>
</Box>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { moveItem, wrapSelection } from "./cvBuilder";
describe("moveItem", () => {
test("moves an item forward", () => {
expect(moveItem(["a", "b", "c"], 0, 2)).toEqual(["b", "c", "a"]);
});
test("moves an item backward", () => {
expect(moveItem(["a", "b", "c"], 2, 0)).toEqual(["c", "a", "b"]);
});
test("no-op for equal or out-of-range indices", () => {
const a = ["a", "b"];
expect(moveItem(a, 1, 1)).toBe(a);
expect(moveItem(a, 5, 0)).toBe(a);
expect(moveItem(a, 0, -1)).toBe(a);
});
test("does not mutate the input", () => {
const a = ["a", "b", "c"];
moveItem(a, 0, 2);
expect(a).toEqual(["a", "b", "c"]);
});
});
describe("wrapSelection", () => {
test("wraps a selection and keeps the selection over the inner text", () => {
const r = wrapSelection("hello world", 6, 11, "**", "**");
expect(r.text).toBe("hello **world**");
expect(r.text.slice(r.selStart, r.selEnd)).toBe("world");
});
test("inserts a placeholder when nothing is selected", () => {
const r = wrapSelection("", 0, 0, "**", "**", "bold text");
expect(r.text).toBe("**bold text**");
expect(r.text.slice(r.selStart, r.selEnd)).toBe("bold text");
});
test("wraps a link with a url suffix", () => {
const r = wrapSelection("see docs", 4, 8, "[", "](https://)");
expect(r.text).toBe("see [docs](https://)");
expect(r.text.slice(r.selStart, r.selEnd)).toBe("docs");
});
});
+34 -1
View File
@@ -1,7 +1,7 @@
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 CvSectionSetting = { key: string; hidden?: boolean; title?: string; itemOrder?: string[] };
export type CvItemOverride = { hidden?: boolean; title?: string; subtitle?: string; bullets?: string[] };
export type CvCustomSectionSetting = { key: string; title?: string; items: string[]; hidden?: boolean };
@@ -32,9 +32,15 @@ export type CvTheme = {
accent: string;
photoShape: string;
supportsIcons: boolean;
atsFriendly: boolean;
swatches: string[];
};
// Master profile resolved to sections+entries (GET /api/cv/outline) — what the Content tab edits.
export type CvOutlineEntry = { key?: string; title?: string; subtitle?: string; meta?: string; bullets: string[]; tags: string[] };
export type CvOutlineSection = { key: string; title: string; kind: "bullets" | "tags" | "entries"; bullets: string[]; tags: string[]; entries: CvOutlineEntry[] };
export type CvOutline = { fullName: string; headline?: string; sections: CvOutlineSection[] };
export type CvVariantSummary = {
id: number;
name: string;
@@ -101,8 +107,35 @@ export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
}
// --- API ---
// --- Pure helpers (unit-tested) ---
// Move an array item from one index to another, returning a new array.
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr;
const next = [...arr];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
// Wrap the [start,end) selection of `text` with markdown markers, for the rich-text toolbar.
// Returns the new text and the selection to restore. If nothing is selected, inserts a placeholder.
export function wrapSelection(
text: string,
start: number,
end: number,
before: string,
after: string,
placeholder = "text",
): { text: string; selStart: number; selEnd: number } {
const selected = text.slice(start, end) || placeholder;
const next = text.slice(0, start) + before + selected + after + text.slice(end);
return { text: next, selStart: start + before.length, selEnd: start + before.length + selected.length };
}
export const cvBuilderApi = {
themes: () => api.get<CvTheme[]>("/cv/themes").then((r) => r.data),
outline: () => api.get<CvOutline>("/cv/outline").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),
@@ -0,0 +1,40 @@
import React, { useCallback, useState } from "react";
// Native HTML5 drag-and-drop reordering — zero dependencies. Pointer users drag; keyboard users use
// the up/down buttons the lists also render (the accessible path). Returns per-item props plus the
// current drag/over indices so the caller can animate (dragged item dims, drop target shows a line).
export function useDragReorder(onReorder: (from: number, to: number) => void) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const reset = useCallback(() => {
setDragIndex(null);
setOverIndex(null);
}, []);
const getItemProps = useCallback(
(index: number) => ({
draggable: true,
onDragStart: (e: React.DragEvent) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = "move";
// Firefox needs data set for a drag to start.
e.dataTransfer.setData("text/plain", String(index));
},
onDragOver: (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (overIndex !== index) setOverIndex(index);
},
onDrop: (e: React.DragEvent) => {
e.preventDefault();
if (dragIndex !== null && dragIndex !== index) onReorder(dragIndex, index);
reset();
},
onDragEnd: reset,
}),
[dragIndex, overIndex, onReorder, reset],
);
return { dragIndex, overIndex, getItemProps };
}
+312 -98
View File
@@ -2,8 +2,8 @@ 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,
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
MenuItem, Paper, Select, Skeleton, 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";
@@ -16,12 +16,19 @@ 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 DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import RichTextField from "../components/RichTextField";
import { useDragReorder } from "../hooks/useDragReorder";
import {
AI_ACTIONS, CvCustomSectionSetting, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings,
CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, cvBuilderApi,
AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, moveItem,
} from "../cvBuilder";
const FONTS = [
@@ -33,6 +40,8 @@ const FONTS = [
"'Poppins', 'Segoe UI', Arial, sans-serif",
];
const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"];
const A4_PAGE_PX = (297 / 25.4) * 96; // one A4 page height in CSS px at 96dpi
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
export default function CvBuilderEditor() {
const { id } = useParams();
@@ -43,28 +52,38 @@ export default function CvBuilderEditor() {
const [name, setName] = useState("");
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
const [themes, setThemes] = useState<CvTheme[]>([]);
const [outline, setOutline] = useState<CvOutline | null>(null);
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 [previewing, setPreviewing] = useState(false);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(1);
const [saveState, setSaveState] = useState<SaveState>("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);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const contentHeight = useRef(A4_PAGE_PX);
// Load variant + theme catalog once.
useEffect(() => {
let alive = true;
(async () => {
try {
const [variant, themeList] = await Promise.all([cvBuilderApi.get(variantId), cvBuilderApi.themes()]);
const [variant, themeList, outlineData] = await Promise.all([
cvBuilderApi.get(variantId),
cvBuilderApi.themes(),
cvBuilderApi.outline().catch(() => null),
]);
if (!alive) return;
applyVariant(variant);
setThemes(themeList);
setOutline(outlineData);
} catch (err) {
if (alive) setLoadError(getApiErrorMessage(err, "Could not open this CV."));
}
@@ -80,35 +99,37 @@ export default function CvBuilderEditor() {
setSettings(variant.settings);
setIsPublic(variant.isPublic);
setPublicSlug(variant.publicSlug);
setSaveState("saved");
};
// Debounced live preview whenever settings change.
// Debounced live preview.
useEffect(() => {
if (!settings) return;
setPreviewing(true);
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 */
/* best-effort; keep last good render */
} finally {
setPreviewing(false);
}
}, 350);
}, 300);
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
};
}, [settings]);
// Debounced autosave.
const scheduleSave = useCallback(
(next: CvVariantSettings, nextName?: string) => {
dirty.current = true;
setSaveState("saving");
setSaveState("unsaved");
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
setSaveState("saving");
try {
await cvBuilderApi.save(variantId, { name: nextName ?? name, settings: next, source: "autosave" });
dirty.current = false;
setSaveState("saved");
} catch {
setSaveState("error");
@@ -132,36 +153,6 @@ export default function CvBuilderEditor() {
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);
@@ -174,8 +165,7 @@ export default function CvBuilderEditor() {
};
const copyPublicLink = () => {
const url = `${window.location.origin}/cv/${publicSlug}`;
navigator.clipboard?.writeText(url);
navigator.clipboard?.writeText(`${window.location.origin}/cv/${publicSlug}`);
toast("Public link copied.", "success");
};
@@ -212,6 +202,25 @@ export default function CvBuilderEditor() {
}
};
// Measure rendered content to drive page count + break indicators.
const onIframeLoad = () => {
try {
const doc = iframeRef.current?.contentDocument;
const h = doc?.body?.scrollHeight ?? A4_PAGE_PX;
contentHeight.current = h;
if (iframeRef.current) iframeRef.current.style.height = `${h}px`;
setPages(Math.max(1, Math.round(h / A4_PAGE_PX)));
} catch {
setPages(1);
}
};
const goToPage = (p: number) => {
const clamped = Math.min(Math.max(1, p), pages);
setPage(clamped);
scrollRef.current?.scrollTo({ top: (clamped - 1) * A4_PAGE_PX * zoom, behavior: "smooth" });
};
if (loadError) {
return (
<Box sx={{ p: 3 }}>
@@ -220,16 +229,15 @@ export default function CvBuilderEditor() {
</Box>
);
}
if (!settings) return <Box sx={{ p: 3 }}>Loading</Box>;
if (!settings) return <EditorSkeleton />;
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>
<Tooltip title="Back to CVs"><IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } } }} />
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} />
<SaveBadge state={saveState} />
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
@@ -247,34 +255,49 @@ export default function CvBuilderEditor() {
<Tab label="History" />
</Tabs>
{tab === 0 && (
<ContentTab settings={settings} update={update} sectionRows={sectionRows}
moveSection={moveSection} toggleSection={toggleSection} renameSection={renameSection} />
)}
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
{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 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
<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 }} />
{pages > 1 && (
<Stack direction="row" alignItems="center" spacing={0.5}>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button>
<Typography variant="caption">Page {page}/{pages}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button>
</Stack>
)}
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<IconButton size="small" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(0.4, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
<Slider size="small" value={zoom} min={0.4} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" />
<IconButton size="small" aria-label="Zoom in" onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton>
<Button size="small" onClick={() => setZoom(0.62)}>Fit</Button>
</Stack>
<Box sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
<Box sx={{ width: `calc(210mm * ${zoom})`, flex: "0 0 auto" }}>
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
<Box sx={{ position: "relative", width: `calc(210mm * ${zoom})`, height: `calc(${contentHeight.current}px * ${zoom})`, flex: "0 0 auto" }}>
<iframe
ref={iframeRef}
title="CV preview"
srcDoc={html}
onLoad={onIframeLoad}
style={{
width: "210mm", height: "297mm", border: "none",
width: "210mm", height: `${contentHeight.current}px`, border: "none",
transform: `scale(${zoom})`, transformOrigin: "top left",
boxShadow: "0 8px 30px rgba(0,0,0,0.18)", background: "#fff", display: "block",
}}
/>
{Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => (
<Box key={i} aria-hidden sx={{
position: "absolute", left: 0, right: 0, top: `calc(${(i + 1) * A4_PAGE_PX}px * ${zoom})`,
borderTop: "2px dashed rgba(220,38,38,0.55)", pointerEvents: "none",
}} />
))}
</Box>
</Box>
</Paper>
@@ -282,33 +305,68 @@ export default function CvBuilderEditor() {
);
}
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 },
function EditorSkeleton() {
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 4 }}>
<Skeleton width="60%" height={32} />
<Skeleton width="100%" height={40} sx={{ mt: 1 }} />
<Skeleton variant="rounded" height={44} sx={{ mt: 2 }} />
{[0, 1, 2, 3, 4].map((i) => <Skeleton key={i} variant="rounded" height={40} sx={{ mt: 1 }} />)}
</Paper>
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "#e9edf2", display: "flex", justifyContent: "center" }}>
<Skeleton variant="rounded" width="70%" height={620} />
</Paper>
</Box>
);
}
function SaveBadge({ state }: { state: SaveState }) {
const map: Record<SaveState, { label: string; color: "default" | "warning" | "success" | "error" }> = {
idle: { label: "", color: "default" },
unsaved: { label: "Unsaved", color: "warning" },
saving: { label: "Saving…", color: "warning" },
saved: { label: "Saved", color: "success" },
error: { label: "Save failed", color: "error" },
};
const m = map[state];
if (!m.label) return null;
return <Chip size="small" label={m.label} color={m.color} variant="outlined" />;
return <Chip size="small" label={m.label} color={m.color} variant="outlined" aria-live="polite" />;
}
function ContentTab({ settings, update, sectionRows, moveSection, toggleSection, renameSection }: {
// ---------- Content tab ----------
function ContentTab({ settings, update, outline }: {
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;
outline: CvOutline | null;
}) {
// Full section list = configured order (once touched) else default, always including every known key.
const sectionRows: CvSectionSetting[] = useMemo(() => {
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.sections]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
const patchSection = (key: string, patch: Partial<CvSectionSetting>) =>
writeSections(sectionRows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
const outlineByKey = useMemo(() => {
const m: Record<string, CvOutlineSection> = {};
outline?.sections.forEach((s) => { m[s.key] = s; });
return m;
}, [outline]);
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>) => {
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 (
@@ -319,20 +377,23 @@ function ContentTab({ settings, update, sectionRows, moveSection, toggleSection,
<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>
<Typography variant="caption" color="text.secondary">Drag to reorder, or use the arrows. 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>
<SectionRow
key={row.key}
row={row}
index={i}
total={sectionRows.length}
dragProps={sectionDrag.getItemProps(i)}
dragging={sectionDrag.dragIndex === i}
over={sectionDrag.overIndex === i && sectionDrag.dragIndex !== i}
outlineSection={outlineByKey[row.key]}
settings={settings}
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
onPatch={(p) => patchSection(row.key, p)}
onUpdateSettings={update}
/>
))}
</Stack>
</Box>
@@ -342,13 +403,18 @@ function ContentTab({ settings, update, sectionRows, moveSection, toggleSection,
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Custom sections</Typography>
<Button size="small" startIcon={<AddIcon />} onClick={addCustom}>Add</Button>
</Stack>
{settings.customSections.length === 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
Add sections unique to this CV (e.g. a portfolio note) without changing your master profile.
</Typography>
)}
<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>
onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} />
<IconButton size="small" aria-label="Remove section" 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") })} />
@@ -360,6 +426,138 @@ function ContentTab({ settings, update, sectionRows, moveSection, toggleSection,
);
}
function SectionRow({
row, index, total, dragProps, dragging, over, outlineSection, settings, onMove, onPatch, onUpdateSettings,
}: {
row: CvSectionSetting;
index: number;
total: number;
dragProps: React.HTMLAttributes<HTMLDivElement> & { draggable: boolean };
dragging: boolean;
over: boolean;
outlineSection?: CvOutlineSection;
settings: CvVariantSettings;
onMove: (delta: number) => void;
onPatch: (patch: Partial<CvSectionSetting>) => void;
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
}) {
const [expanded, setExpanded] = useState(false);
const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0;
return (
<Paper
variant="outlined"
{...dragProps}
sx={{
p: 0.5, opacity: dragging ? 0.4 : row.hidden ? 0.5 : 1,
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
transition: "opacity 150ms ease, border-color 120ms ease", cursor: "grab",
}}
>
<Stack direction="row" alignItems="center" gap={0.5}>
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled" }} aria-hidden />
<Stack>
<IconButton size="small" aria-label="Move section up" disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
<IconButton size="small" aria-label="Move section down" disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
</Stack>
<TextField variant="standard" fullWidth value={row.title ?? SECTION_LABELS[row.key] ?? row.key}
onChange={(e) => onPatch({ title: e.target.value })}
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
{editable && (
<IconButton size="small" aria-label="Edit entries" onClick={() => setExpanded((e) => !e)}
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
<ExpandMoreIcon fontSize="small" />
</IconButton>
)}
<IconButton size="small" aria-label={row.hidden ? "Show section" : "Hide section"} onClick={() => onPatch({ hidden: !row.hidden })}>
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</Stack>
{editable && (
<Collapse in={expanded} unmountOnExit>
<Box sx={{ pl: 3, pt: 1 }}>
<EntryEditor section={outlineSection!} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
</Box>
</Collapse>
)}
</Paper>
);
}
function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
section: CvOutlineSection;
row: CvSectionSetting;
settings: CvVariantSettings;
onPatch: (patch: Partial<CvSectionSetting>) => void;
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
}) {
// Entry order: itemOrder if set, else master order; always covering every entry key.
const orderedKeys: string[] = useMemo(() => {
const master = section.entries.map((e) => e.key ?? "").filter(Boolean);
if (!row.itemOrder?.length) return master;
const set = new Set(row.itemOrder);
return [...row.itemOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))];
}, [row.itemOrder, section.entries]);
const entriesByKey = useMemo(() => {
const m: Record<string, (typeof section.entries)[number]> = {};
section.entries.forEach((e) => { if (e.key) m[e.key] = e; });
return m;
}, [section.entries]);
const drag = useDragReorder((from, to) => onPatch({ itemOrder: moveItem(orderedKeys, from, to) }));
const setOverride = (key: string, patch: Partial<CvItemOverride>) => {
const overrides = { ...settings.overrides, [key]: { ...settings.overrides[key], ...patch } };
onUpdateSettings({ overrides });
};
return (
<Stack spacing={0.75}>
{orderedKeys.map((key, i) => {
const entry = entriesByKey[key];
if (!entry) return null;
const ov = settings.overrides[key] ?? {};
const hidden = !!ov.hidden;
return (
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
sx={{
p: 0.75, opacity: drag.dragIndex === i ? 0.4 : hidden ? 0.5 : 1, cursor: "grab",
borderTop: drag.overIndex === i && drag.dragIndex !== i ? "2px solid" : undefined,
borderTopColor: "primary.main", transition: "opacity 150ms ease",
}}>
<Stack direction="row" alignItems="center" gap={0.5}>
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{ov.title || entry.title || "Untitled"}</Typography>
<IconButton size="small" aria-label="Move entry up" disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
<IconButton size="small" aria-label="Move entry down" disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
<IconButton size="small" aria-label={hidden ? "Show entry" : "Hide entry"} onClick={() => setOverride(key, { hidden: !hidden })}>
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Stack>
{!hidden && (
<Stack spacing={0.75} sx={{ mt: 0.75 }}>
<TextField size="small" fullWidth label="Title" value={ov.title ?? entry.title ?? ""}
onChange={(e) => setOverride(key, { title: e.target.value })} />
<TextField size="small" fullWidth label="Subtitle" value={ov.subtitle ?? entry.subtitle ?? ""}
onChange={(e) => setOverride(key, { subtitle: e.target.value })} />
<RichTextField label="Bullet points (one per line)" minRows={2}
value={(ov.bullets ?? entry.bullets).join("\n")}
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
{ov.bullets && (
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>Reset to master bullets</Button>
)}
</Stack>
)}
</Paper>
);
})}
</Stack>
);
}
// ---------- Customize tab ----------
function CustomizeTab({ settings, update, themes }: {
settings: CvVariantSettings;
update: (p: Partial<CvVariantSettings>) => void;
@@ -373,14 +571,16 @@ function CustomizeTab({ settings, update, themes }: {
{themes.map((t) => {
const active = t.id === settings.themeId;
return (
<Paper key={t.id} variant="outlined"
<Paper key={t.id} variant="outlined" role="button" tabIndex={0}
onClick={() => update({ themeId: t.id })}
sx={{ p: 1, cursor: "pointer", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1 }}>
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); update({ themeId: t.id }); } }}
sx={{ p: 1, cursor: "pointer", outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
<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>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
{t.atsFriendly && <Chip size="small" label="ATS-friendly" color="success" variant="outlined" sx={{ mt: 0.5, height: 18, fontSize: 10 }} />}
</Paper>
);
})}
@@ -389,7 +589,7 @@ function CustomizeTab({ settings, update, themes }: {
<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 })} />
<input type="color" aria-label="Accent colour" value={settings.accentColor ?? "#2563eb"} onChange={(e) => update({ accentColor: e.target.value })} />
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Reset</Button>}
</Stack>
@@ -457,7 +657,7 @@ function AiToolsTab() {
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>
<Alert severity="info" sx={{ py: 0.5 }}>AI suggestions never change your profile automatically. Copy what you like back into your CV.</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)} />
@@ -480,14 +680,17 @@ function AiToolsTab() {
}
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>;
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">No saved versions yet. Edits autosave and appear here.</Typography>;
return (
<Stack spacing={0.5}>
<Typography variant="caption" color="text.secondary">{versions.length} saved version{versions.length === 1 ? "" : "s"}. Restoring keeps history it adds a new version.</Typography>
{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>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Version {v.version} {v.isCurrent && <Chip size="small" label="current" sx={{ ml: 0.5, height: 18 }} />}
</Typography>
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc)} · {v.source}</Typography>
</Box>
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>Restore</Button>}
</Paper>
@@ -495,3 +698,14 @@ function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[];
</Stack>
);
}
function relTime(iso: string): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const mins = Math.round(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return new Date(iso).toLocaleDateString();
}