import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom"; import { Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography, } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; 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 ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import UndoIcon from "@mui/icons-material/Undo"; import RedoIcon from "@mui/icons-material/Redo"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import TranslateIcon from "@mui/icons-material/Translate"; import SpellcheckIcon from "@mui/icons-material/Spellcheck"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { useAccountPlan } from "../accountPlan"; import RichTextField from "../components/RichTextField"; import AiSectionAssistant from "../components/cv/AiSectionAssistant"; import CvCustomizeTab from "../components/cv/CvCustomizeTab"; import ProFeatureNotice from "../components/ProFeatureNotice"; import { useDragReorder } from "../hooks/useDragReorder"; import { CUSTOM_SECTION_PRESETS, CvAiCallbacks, CvAiInput, CvAiProposal, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, } from "../cvBuilder"; import { previewDocumentHtml } from "../cvPreview"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; const MIN_PREVIEW_ZOOM = 0.2; type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict"; export default function CvBuilderEditor() { const { id } = useParams(); const variantId = Number(id); const navigate = useNavigate(); const { toast } = useToast(); const { t } = useI18n(); const { confirmAction } = useDialogActions(); const compactEditor = useMediaQuery("(max-width:899.95px)"); 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 [jobApplicationId, setJobApplicationId] = useState(null); const [actionsAnchor, setActionsAnchor] = useState(null); const [tab, setTab] = useState(1); const [html, setHtml] = useState(""); const [measuredHtml, setMeasuredHtml] = useState(null); const [zoom, setZoom] = useState(0.62); const [previewing, setPreviewing] = useState(false); const [previewError, setPreviewError] = useState(false); const [previewRevision, setPreviewRevision] = useState(0); const [previewOverflow, setPreviewOverflow] = useState(false); const [pages, setPages] = useState(1); const [saveState, setSaveState] = useState("idle"); const [exporting, setExporting] = useState(false); const [publishing, setPublishing] = useState(false); const [versions, setVersions] = useState([]); const [loadError, setLoadError] = useState(null); const [mobilePane, setMobilePane] = useState<"edit" | "preview">("edit"); const [historyRevision, setHistoryRevision] = useState(0); const saveTimer = useRef | null>(null); const saveRevision = useRef(0); const persistedRevision = useRef(0); const saveQueue = useRef>(Promise.resolve(true)); const serverVersion = useRef(0); const editorEpoch = useRef(0); const revisionConflict = useRef(false); const latestSettings = useRef(null); const latestName = useRef(""); const previewTimer = useRef | null>(null); const previewRequest = useRef(0); const iframeRef = useRef(null); const scrollRef = useRef(null); const editorRef = useRef(null); const blockerPromptOpen = useRef(false); const undoStack = useRef([]); const redoStack = useRef([]); const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]); const editorReady = settings !== null; const previewBusy = previewing || (!previewError && (!html || measuredHtml !== html)); const previewReady = !!html && measuredHtml === html && !previewBusy && !previewError; 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, t("cvEditorOpenFailed"))); } })(); return () => { alive = false; editorEpoch.current += 1; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [variantId]); const applyVariant = (variant: CvVariant) => { if (saveTimer.current) clearTimeout(saveTimer.current); saveRevision.current += 1; persistedRevision.current = saveRevision.current; editorEpoch.current += 1; serverVersion.current = variant.version; revisionConflict.current = false; setName(variant.name); setSettings(variant.settings); latestName.current = variant.name; latestSettings.current = variant.settings; setIsPublic(variant.isPublic); setPublicSlug(variant.publicSlug); setJobApplicationId(variant.jobApplicationId ?? null); setSaveState("saved"); undoStack.current = []; redoStack.current = []; setHistoryRevision((value) => value + 1); }; // Debounced live preview. useEffect(() => { if (!settings) return; const request = ++previewRequest.current; setPreviewing(true); if (previewTimer.current) clearTimeout(previewTimer.current); previewTimer.current = setTimeout(async () => { try { const render = await cvBuilderApi.previewSettings(settings); if (previewRequest.current !== request) return; setHtml(render.html); setPreviewError(false); } catch { if (previewRequest.current !== request) return; setPreviewError(true); } finally { if (previewRequest.current === request) setPreviewing(false); } }, 300); return () => { if (previewTimer.current) clearTimeout(previewTimer.current); }; }, [settings, previewRevision]); const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => { const epoch = editorEpoch.current; const save = async () => { if (epoch !== editorEpoch.current || revisionConflict.current) return false; if (saveRevision.current === revision) setSaveState("saving"); try { const saved = await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave", expectedVersion: serverVersion.current }); if (epoch !== editorEpoch.current) return false; serverVersion.current = saved.version; persistedRevision.current = revision; if (saveRevision.current === revision) setSaveState("saved"); return true; } catch (error) { if (epoch !== editorEpoch.current) return false; const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code; if (code === "cv_revision_conflict" || code === "cv_revision_required") { revisionConflict.current = true; setSaveState("conflict"); } else if (saveRevision.current === revision) setSaveState("error"); return false; } }; const queued = saveQueue.current.then(save, save); saveQueue.current = queued; return queued; }, [variantId]); const scheduleSave = useCallback( (next: CvVariantSettings, nextName = latestName.current) => { latestSettings.current = next; latestName.current = nextName; const revision = ++saveRevision.current; setSaveState(revisionConflict.current ? "conflict" : "unsaved"); if (saveTimer.current) clearTimeout(saveTimer.current); if (!nextName.trim() || revisionConflict.current) return; saveTimer.current = setTimeout(async () => { saveTimer.current = null; 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 flushAiSource = async () => { const epoch = editorEpoch.current; if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = null; await saveQueue.current; if (epoch !== editorEpoch.current || revisionConflict.current || !latestSettings.current || !latestName.current.trim()) { throw new Error(t("cvAiFailed")); } const revision = saveRevision.current; if (persistedRevision.current !== revision && !(await performSave(latestSettings.current, latestName.current, revision))) { throw new Error(t("cvAiFailed")); } if (epoch !== editorEpoch.current || revision !== saveRevision.current) throw new Error(t("cvAiFailed")); return { epoch, revision }; }; const generateAi = async (input: CvAiInput) => { const { epoch, revision } = await flushAiSource(); const proposal = await cvBuilderApi.aiAssist({ ...input, variantId, expectedVersion: serverVersion.current }); if (epoch !== editorEpoch.current || revision !== saveRevision.current) throw new Error(t("cvAiFailed")); return proposal; }; const acceptAi = async (proposal: CvAiProposal, result?: string) => { const { epoch, revision } = await flushAiSource(); const previous = latestSettings.current!; const apply = async () => { if (epoch !== editorEpoch.current || revision !== saveRevision.current || revisionConflict.current) throw new Error(t("cvAiFailed")); try { const saved = await cvBuilderApi.applyAi(variantId, { proposalToken: proposal.proposalToken, ...(result !== undefined ? { result } : {}) }); if (epoch !== editorEpoch.current) throw new Error(t("cvAiFailed")); serverVersion.current = saved.version; if (revision !== saveRevision.current) { revisionConflict.current = true; setSaveState("conflict"); throw new Error(t("cvAiFailed")); } undoStack.current = [...undoStack.current.slice(-59), previous]; redoStack.current = []; saveRevision.current += 1; persistedRevision.current = saveRevision.current; latestSettings.current = saved.settings; latestName.current = saved.name; setSettings(saved.settings); setName(saved.name); setIsPublic(saved.isPublic); setPublicSlug(saved.publicSlug); setJobApplicationId(saved.jobApplicationId ?? null); setSaveState("saved"); setHistoryRevision((value) => value + 1); } catch (error) { const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code; if (epoch === editorEpoch.current && (code === "cv_revision_conflict" || code === "cv_revision_required")) { revisionConflict.current = true; setSaveState("conflict"); } throw error; } }; const queued = saveQueue.current.then(apply, apply); saveQueue.current = queued.then(() => true, () => false); await queued; }; const update = (patch: Partial) => { setSettings((prev) => { if (!prev) return prev; const next = { ...prev, ...patch }; undoStack.current = [...undoStack.current.slice(-59), prev]; redoStack.current = []; setHistoryRevision((value) => value + 1); scheduleSave(next); return next; }); }; const undo = () => { setSettings((current) => { const previous = undoStack.current.pop(); if (!current || !previous) return current; redoStack.current.push(current); setHistoryRevision((value) => value + 1); scheduleSave(previous); return previous; }); }; const redo = () => { setSettings((current) => { const next = redoStack.current.pop(); if (!current || !next) return current; undoStack.current.push(current); setHistoryRevision((value) => value + 1); 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" || saveState === "conflict"; const downloadUnsavedCopy = () => { const url = URL.createObjectURL(new Blob([JSON.stringify({ name: latestName.current, settings: latestSettings.current, expectedVersion: serverVersion.current }, null, 2)], { type: "application/json" })); const link = document.createElement("a"); link.href = url; link.download = "unsaved-cv-settings.json"; link.click(); URL.revokeObjectURL(url); }; const reloadSavedVariant = async () => { if (!(await confirmAction(t("cvEditorConflictReloadMessage"), { title: t("cvEditorConflictReload"), confirmLabel: t("cvEditorConflictReload"), destructive: true }))) return; const revision = saveRevision.current; await saveQueue.current; try { const saved = await cvBuilderApi.get(variantId); if (saveRevision.current === revision) applyVariant(saved); } catch (error) { toast(getApiErrorMessage(error, t("cvEditorOpenFailed")), "error"); } }; const blocker = useBlocker(hasUnsavedChanges); useEffect(() => { if (blocker.state !== "blocked") { blockerPromptOpen.current = false; return; } if (blockerPromptOpen.current) return; blockerPromptOpen.current = true; void confirmAction(t("cvEditorDiscardMessage"), { title: t("cvEditorDiscardTitle"), confirmLabel: t("cvEditorDiscardLeave"), destructive: true, }).then((confirmed) => { if (confirmed) blocker.proceed(); else blocker.reset(); }); }, [blocker, confirmAction, t]); 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 () => { if (hasUnsavedChanges && !(await retrySave())) { toast(t("cvEditorSaveBeforeVisibility"), "error"); return; } setPublishing(true); try { const updated = await cvBuilderApi.setPublic(variantId, !isPublic); setIsPublic(updated.isPublic); setPublicSlug(updated.publicSlug); toast(t(updated.isPublic ? "cvEditorNowPublic" : "cvEditorNowPrivate"), "success"); } catch (err) { toast(getApiErrorMessage(err, t("cvEditorVisibilityFailed")), "error"); } finally { setPublishing(false); } }; const copyPublicLink = () => { navigator.clipboard?.writeText(`${window.location.origin}/cv/${publicSlug}`); toast(t("cvEditorLinkCopied"), "success"); }; const exportPdf = async () => { if (hasUnsavedChanges && !(await retrySave())) { toast(t("cvEditorSaveBeforeExport"), "error"); return; } setExporting(true); 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) { const status = (err as { response?: { status?: number } })?.response?.status; toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error"); } finally { setExporting(false); } }; const duplicateVariant = async () => { if (hasUnsavedChanges && !(await retrySave())) { toast(t("cvEditorSaveBeforeDuplicate"), "error"); return; } try { const copy = await cvBuilderApi.duplicate(variantId, t("cvEditorCopyName", { name: name || t("cvEditorUntitledCv") })); navigate(`/career/builder/${copy.id}`); toast(t("cvEditorDuplicated"), "success"); } catch (err) { toast(getApiErrorMessage(err, t("cvEditorDuplicateFailed")), "error"); } }; const loadVersions = async () => { try { setVersions(await cvBuilderApi.versions(variantId)); } catch (err) { toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error"); } }; const restore = async (version: number) => { if (!(await confirmAction(t("cvEditorRestoreMessage", { version }), { title: t("cvEditorRestoreTitle", { version }), confirmLabel: t("cvEditorRestoreVersion"), }))) return; if (hasUnsavedChanges && !(await retrySave())) { toast(t("cvEditorSaveBeforeRestore"), "error"); return; } try { await saveQueue.current; const revision = saveRevision.current; const updated = await cvBuilderApi.restore(variantId, version, serverVersion.current); if (saveRevision.current === revision) applyVariant(updated); else { // Edits made while restore was in flight remain recoverable; never replace them on response. revisionConflict.current = true; setSaveState("conflict"); } await loadVersions(); toast(t("cvEditorRestoredVersion", { version }), "success"); } catch (err) { const code = (err as { response?: { data?: { code?: string } } })?.response?.data?.code; if (code === "cv_revision_conflict" || code === "cv_revision_required") { revisionConflict.current = true; setSaveState("conflict"); } toast(getApiErrorMessage(err, t("cvEditorRestoreFailed")), "error"); } }; // Measure rendered content to drive page count + break indicators. const onIframeLoad = () => { const frame = iframeRef.current; const win = frame?.contentWindow; const doc = frame?.contentDocument; const measure = () => { if (frame !== iframeRef.current || doc !== frame?.contentDocument) return; try { setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1); setPreviewOverflow(doc?.documentElement.dataset.cvPreviewOverflow === "true"); setMeasuredHtml(html); } catch { setPages(1); setPreviewOverflow(false); } }; if (doc?.documentElement.dataset.cvPreviewReady === "true" || !doc?.querySelector(".page")) measure(); else win?.addEventListener("cv-preview-ready", measure, { once: true }); }; const fitPreview = useCallback(() => { setZoom(getCvPreviewFitZoom(scrollRef.current?.clientWidth ?? pageMetrics.widthPx, pageMetrics.widthPx, 16, MIN_PREVIEW_ZOOM)); }, [pageMetrics.widthPx]); useEffect(() => { const container = scrollRef.current; if (!container) return; const resize = () => fitPreview(); resize(); window.addEventListener("resize", resize); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize); observer?.observe(container); return () => { window.removeEventListener("resize", resize); observer?.disconnect(); }; }, [compactEditor, editorReady, fitPreview, mobilePane]); if (loadError) { return ( {loadError} ); } if (!settings) return ; const canUndo = historyRevision >= 0 && undoStack.current.length > 0; const canRedo = historyRevision >= 0 && redoStack.current.length > 0; return ( {saveState === "conflict" && {t("cvEditorConflictMessage")} } navigate("/career/builder")}> renameVariant(e.target.value)} fullWidth error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined} sx={{ "& input": { typography: "sectionTitle" } }} slotProps={{ htmlInput: { "aria-label": t("cvEditorName") } }} /> {t("cvEditorScope")} {t("cvEditorVersion", { version: serverVersion.current })} {jobApplicationId && } {isPublic && } label={t("cvEditorShared")} variant="outlined" />} void retrySave()} /> setActionsAnchor(event.currentTarget)}> setActionsAnchor(null)} slotProps={{ list: { "aria-label": t("cvEditorMoreActions") } }}> { setActionsAnchor(null); void duplicateVariant(); }}>{t("cvEditorDuplicate")} { setActionsAnchor(null); void togglePublic(); }}>{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")} {isPublic && { setActionsAnchor(null); copyPublicLink(); }}>{t("cvEditorCopyLink")}} {compactEditor && } setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: "var(--cv-pane-top)", minWidth: 0, maxHeight: { md: "calc(100dvh - var(--cv-pane-top) - 12px)" }, overflowY: { md: "auto" }, scrollbarWidth: "thin" }}> {t("cvEditorEditingRegion")} { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}> {[1, 0, 2, 3, 4].map((value) => )} {[0, 2, 3, 4].map((value) => )} { setMobilePane("preview"); const pane = event.currentTarget; requestAnimationFrame(() => { if (pane.contains(document.activeElement)) pane.scrollIntoView?.({ block: "start", inline: "nearest" }); }); }} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "flex", flexDirection: "column", minWidth: 0, position: { md: "sticky" }, top: "var(--cv-pane-top)", height: "calc(100dvh - var(--cv-pane-top) - 12px)", scrollMarginTop: "var(--cv-pane-top)" }}> {t("cvEditorLivePreview")} {t("cvEditorPreviewScale", { percent: Math.round(zoom * 100) })} {previewBusy ? t("cvEditorUpdatingPreview") : previewReady ? t("cvEditorPageCount", { count: pages }) : null} {previewError && !previewing && setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}} sx={{ m: 1, "& .MuiAlert-action": { alignSelf: "center", flexShrink: 1 } }}> {t("cvEditorPreviewUnavailable")} {t(html ? "cvEditorPreviewPrevious" : "cvEditorPreviewEmpty")} } {!!html && previewOverflow && {t("cvEditorOverflowWarning")}} {!!html && pages >= 3 && {t("cvEditorLongDocument", { pages })}} {t("cvEditorPreviewHelp")}