import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useBlocker, useNavigate, useParams } from "react-router-dom"; import { 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"; 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 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, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, cvBuilderApi, moveItem, } from "../cvBuilder"; import { useAccountPlan } from "../accountPlan"; import { useDialogActions } from "../dialogs"; 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"]; 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(); const variantId = Number(id); const navigate = useNavigate(); const { toast } = useToast(); const { confirmAction } = useDialogActions(); 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 [previewing, setPreviewing] = useState(false); const [previewError, setPreviewError] = useState(false); const [previewRevision, setPreviewRevision] = useState(0); 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 saveRevision = useRef(0); const latestSettings = useRef(null); const latestName = useRef(""); const previewTimer = useRef | null>(null); const iframeRef = useRef(null); const scrollRef = useRef(null); const contentHeight = useRef(A4_PAGE_PX); const blockerPromptOpen = useRef(false); useEffect(() => { let alive = true; (async () => { try { 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.")); } })(); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [variantId]); const applyVariant = (variant: CvVariant) => { if (saveTimer.current) clearTimeout(saveTimer.current); saveRevision.current += 1; setName(variant.name); setSettings(variant.settings); latestName.current = variant.name; latestSettings.current = variant.settings; setIsPublic(variant.isPublic); setPublicSlug(variant.publicSlug); setSaveState("saved"); }; // 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); setPreviewError(false); } catch { setPreviewError(true); } finally { setPreviewing(false); } }, 300); return () => { if (previewTimer.current) clearTimeout(previewTimer.current); }; }, [settings, previewRevision]); const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => { setSaveState("saving"); try { await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" }); if (saveRevision.current === revision) setSaveState("saved"); return true; } catch { if (saveRevision.current === revision) setSaveState("error"); return false; } }, [variantId]); const scheduleSave = useCallback( (next: CvVariantSettings, nextName = latestName.current) => { latestSettings.current = next; latestName.current = nextName; const revision = ++saveRevision.current; setSaveState("unsaved"); if (saveTimer.current) clearTimeout(saveTimer.current); if (!nextName.trim()) return; saveTimer.current = setTimeout(async () => { await performSave(next, nextName, revision); }, 800); }, [performSave], ); const retrySave = useCallback(async () => { if (!latestSettings.current || !latestName.current.trim()) return false; if (saveTimer.current) clearTimeout(saveTimer.current); const revision = ++saveRevision.current; return performSave(latestSettings.current, latestName.current, revision); }, [performSave]); const update = (patch: Partial) => { setSettings((prev) => { if (!prev) return prev; const next = { ...prev, ...patch }; scheduleSave(next); return next; }); }; const renameVariant = (value: string) => { setName(value); latestName.current = value; if (settings) scheduleSave(settings, value); }; const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error"; const blocker = useBlocker(hasUnsavedChanges); useEffect(() => { if (blocker.state !== "blocked") { blockerPromptOpen.current = false; return; } if (blockerPromptOpen.current) return; blockerPromptOpen.current = true; void confirmAction("This CV has unsaved changes. Leave and discard them?", { title: "Discard unsaved CV changes?", confirmLabel: "Discard and leave", destructive: true, }).then((confirmed) => { blockerPromptOpen.current = false; if (confirmed) blocker.proceed(); else blocker.reset(); }); }, [blocker, confirmAction]); useEffect(() => { const warnBeforeUnload = (event: BeforeUnloadEvent) => { if (!hasUnsavedChanges) return; event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", warnBeforeUnload); return () => window.removeEventListener("beforeunload", warnBeforeUnload); }, [hasUnsavedChanges]); useEffect(() => () => { if (saveTimer.current) clearTimeout(saveTimer.current); }, []); 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 = () => { navigator.clipboard?.writeText(`${window.location.origin}/cv/${publicSlug}`); 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"); } }; // 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 ( {loadError} ); } if (!settings) return ; return ( navigate("/career/builder")}> renameVariant(e.target.value)} error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined} slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} /> void retrySave()} /> {isPublic && } { setTab(v); if (v === 3) loadVersions(); }} variant="fullWidth" sx={{ mb: 1.5 }}> {tab === 0 && } {tab === 1 && } {tab === 2 && } {tab === 3 && } Live preview {previewing && } {previewError && } {previewError && } {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)))}>