From 582c4e07e93f13e139ec863a36ef8235cac65292 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 14:46:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(career):=20builder=20UX=20overhaul=20?= =?UTF-8?q?=E2=80=94=20DnD,=20rich=20text,=20per-item=20editing,=20richer?= =?UTF-8?q?=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/RichTextField.tsx | 73 ++++ job-tracker-ui/src/cvBuilder.test.ts | 39 ++ job-tracker-ui/src/cvBuilder.ts | 35 +- job-tracker-ui/src/hooks/useDragReorder.ts | 40 ++ job-tracker-ui/src/views/CvBuilderEditor.tsx | 410 +++++++++++++----- 5 files changed, 498 insertions(+), 99 deletions(-) create mode 100644 job-tracker-ui/src/components/RichTextField.tsx create mode 100644 job-tracker-ui/src/cvBuilder.test.ts create mode 100644 job-tracker-ui/src/hooks/useDragReorder.ts diff --git a/job-tracker-ui/src/components/RichTextField.tsx b/job-tracker-ui/src/components/RichTextField.tsx new file mode 100644 index 0000000..453ed86 --- /dev/null +++ b/job-tracker-ui/src/components/RichTextField.tsx @@ -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(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) => ( + + e.preventDefault()} onClick={() => apply(before, after, ph)}> + {icon} + + + ); + + return ( + + + {btn("Bold", , "**", "**", "bold text")} + {btn("Italic", , "*", "*", "italic text")} + {btn("Underline", , "__", "__", "underlined")} + {btn("Link", , "[", "](https://)", "link text")} + + onChange(e.target.value)} + slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }} + /> + + ); +} diff --git a/job-tracker-ui/src/cvBuilder.test.ts b/job-tracker-ui/src/cvBuilder.test.ts new file mode 100644 index 0000000..a1e89d7 --- /dev/null +++ b/job-tracker-ui/src/cvBuilder.test.ts @@ -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"); + }); +}); diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts index 4040db3..6c4bc42 100644 --- a/job-tracker-ui/src/cvBuilder.ts +++ b/job-tracker-ui/src/cvBuilder.ts @@ -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(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("/cv/themes").then((r) => r.data), + outline: () => api.get("/cv/outline").then((r) => r.data), list: () => api.get("/cv/variants").then((r) => r.data), create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) => api.post("/cv/variants", body).then((r) => r.data), diff --git a/job-tracker-ui/src/hooks/useDragReorder.ts b/job-tracker-ui/src/hooks/useDragReorder.ts new file mode 100644 index 0000000..6de73c7 --- /dev/null +++ b/job-tracker-ui/src/hooks/useDragReorder.ts @@ -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(null); + const [overIndex, setOverIndex] = useState(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 }; +} diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index a016129..e0b96c2 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -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(null); const [themes, setThemes] = useState([]); + const [outline, setOutline] = useState(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("idle"); const [versions, setVersions] = useState([]); const [loadError, setLoadError] = useState(null); const saveTimer = useRef | null>(null); const previewTimer = useRef | null>(null); - const dirty = useRef(false); + const iframeRef = useRef(null); + const scrollRef = useRef(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 ( @@ -220,16 +229,15 @@ export default function CvBuilderEditor() { ); } - if (!settings) return Loading…; + if (!settings) return ; return ( - {/* Left: controls */} - navigate("/career/builder")}> + navigate("/career/builder")}> 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" } }} /> @@ -247,34 +255,49 @@ export default function CvBuilderEditor() { - {tab === 0 && ( - - )} + {tab === 0 && } {tab === 1 && } {tab === 2 && } {tab === 3 && } - {/* Right: live preview */} - + Live preview + {previewing && } - Zoom - setZoom(v as number)} sx={{ width: 120 }} /> + {pages > 1 && ( + + + Page {page}/{pages} + + + )} + + setZoom((z) => Math.max(0.4, +(z - 0.1).toFixed(2)))}> + setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" /> + setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}> + - - + +