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>
);
}