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, Divider, IconButton, 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 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, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, } from "../cvBuilder"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; const MIN_PREVIEW_ZOOM = 0.2; type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error"; function previewDocumentHtml(html: string, pageHeightPx: number, pageMarginMm: number, pageIndex = 0): string { const height = Math.max(1, Math.round(pageHeightPx * 100) / 100); const margin = Math.max(0, Math.round(pageMarginMm * (96 / 25.4) * 100) / 100); const offset = Math.max(0, pageIndex) * height; const previewCss = ``; // Screen media does not apply Chromium's paged-media fragmentation. Reproduce the important // keep-together decisions before each preview frame crops the selected physical page. const previewScript = ``; const withCss = html.includes("") ? html.replace("", `${previewCss}`) : `${previewCss}${html}`; return withCss.includes("") ? withCss.replace("", `${previewScript}`) : `${withCss}${previewScript}`; } 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 [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 [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 saveQueue = useRef>(Promise.resolve(true)); 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 blockerPromptOpen = useRef(false); const undoStack = useRef([]); const redoStack = useRef([]); const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]); const pageMarginMm = useMemo(() => { const themeMargin = themes.find((theme) => theme.id === settings?.themeId)?.pageMarginMm; return settings?.pageMarginMm ?? themeMargin ?? 16; }, [settings?.pageMarginMm, settings?.themeId, themes]); 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; }; // 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"); 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 save = async () => { if (saveRevision.current === revision) 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; } }; 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("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 }; 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"; 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 { const updated = await cvBuilderApi.restore(variantId, version); applyVariant(updated); await loadVersions(); toast(t("cvEditorRestoredVersion", { version }), "success"); } catch (err) { toast(getApiErrorMessage(err, t("cvEditorRestoreFailed")), "error"); } }; // Measure rendered content to drive page count + break indicators. const onIframeLoad = () => { const win = iframeRef.current?.contentWindow; const measure = () => { try { const doc = iframeRef.current?.contentDocument; const h = Math.max( pageMetrics.heightPx, doc?.body?.scrollHeight ?? 0, doc?.documentElement?.scrollHeight ?? 0, ); const pageCount = getCvPageCount(h, pageMetrics.heightPx); setPages(pageCount); const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx; const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0); setPreviewOverflow(contentWidth > viewportWidth + 2); } catch { setPages(1); setPreviewOverflow(false); } }; if (win) win.requestAnimationFrame(() => win.requestAnimationFrame(measure)); else measure(); }; 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, 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 ( navigate("/career/builder")}> renameVariant(e.target.value)} error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined} sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }} slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": t("cvEditorName") } }} /> void retrySave()} /> {compactEditor && } {isPublic && } { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}> {tab === 0 && sectionName(key, fallback, t)} />} {tab === 1 && } {tab === 2 && sectionName(key, fallback, t)} />} {tab === 3 && } {tab === 4 && } {t("cvEditorLivePreview")} {previewing && } {previewError && } {previewError && } {t("cvEditorPageCount", { count: pages })} {previewOverflow && {t("cvEditorOverflowWarning")}} {pages >= 3 && {t("cvEditorLongDocument", { pages })}}