1388 lines
77 KiB
TypeScript
1388 lines
77 KiB
TypeScript
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<CvVariantSettings | null>(null);
|
|
const [themes, setThemes] = useState<CvTheme[]>([]);
|
|
const [outline, setOutline] = useState<CvOutline | null>(null);
|
|
const [isPublic, setIsPublic] = useState(false);
|
|
const [publicSlug, setPublicSlug] = useState("");
|
|
const [jobApplicationId, setJobApplicationId] = useState<number | null>(null);
|
|
const [actionsAnchor, setActionsAnchor] = useState<HTMLElement | null>(null);
|
|
const [tab, setTab] = useState(1);
|
|
const [html, setHtml] = useState("");
|
|
const [measuredHtml, setMeasuredHtml] = useState<string | null>(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<SaveState>("idle");
|
|
const [exporting, setExporting] = useState(false);
|
|
const [publishing, setPublishing] = useState(false);
|
|
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [mobilePane, setMobilePane] = useState<"edit" | "preview">("edit");
|
|
const [historyRevision, setHistoryRevision] = useState(0);
|
|
|
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const saveRevision = useRef(0);
|
|
const persistedRevision = useRef(0);
|
|
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
|
|
const serverVersion = useRef(0);
|
|
const editorEpoch = useRef(0);
|
|
const revisionConflict = useRef(false);
|
|
const latestSettings = useRef<CvVariantSettings | null>(null);
|
|
const latestName = useRef("");
|
|
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const previewRequest = useRef(0);
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const editorRef = useRef<HTMLDivElement | null>(null);
|
|
const blockerPromptOpen = useRef(false);
|
|
const undoStack = useRef<CvVariantSettings[]>([]);
|
|
const redoStack = useRef<CvVariantSettings[]>([]);
|
|
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<CvVariantSettings>) => {
|
|
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 (
|
|
<Box sx={{ p: 3 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/career/builder")}>{t("cvEditorBack")}</Button>
|
|
<Alert severity="error" sx={{ mt: 2 }}>{loadError}</Alert>
|
|
</Box>
|
|
);
|
|
}
|
|
if (!settings) return <EditorSkeleton />;
|
|
|
|
const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
|
|
const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
|
|
|
|
return (
|
|
<Stack spacing={1.5} data-content-density="comfortable" sx={{
|
|
minWidth: 0,
|
|
"--cv-pane-top": "calc(var(--app-shell-header-height, 76px) + 12px)",
|
|
"& [tabindex], & button, & input, & textarea, & [role=combobox]": { scrollMarginTop: "var(--cv-pane-top)" },
|
|
}}>
|
|
{saveState === "conflict" && <Alert severity="warning">
|
|
{t("cvEditorConflictMessage")}
|
|
<Stack direction="row" gap={1} flexWrap="wrap" sx={{ mt: 1 }}>
|
|
<Button size="small" onClick={downloadUnsavedCopy}>{t("cvEditorConflictDownload")}</Button>
|
|
<Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button>
|
|
</Stack>
|
|
</Alert>}
|
|
<Paper component="header" variant="outlined" sx={{ p: 1.5 }}>
|
|
<Stack direction={{ xs: "column", sm: "row" }} alignItems={{ sm: "center" }} gap={1.5}>
|
|
<Stack direction="row" alignItems="flex-start" gap={1} sx={{ flex: 1, minWidth: 0 }}>
|
|
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)} fullWidth
|
|
error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined}
|
|
sx={{ "& input": { typography: "sectionTitle" } }}
|
|
slotProps={{ htmlInput: { "aria-label": t("cvEditorName") } }} />
|
|
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }}>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorScope")}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorVersion", { version: serverVersion.current })}</Typography>
|
|
{jobApplicationId && <Button component={RouterLink} to={`/jobs/${jobApplicationId}`} size="small">{t("cvEditorLinkedApplication")}</Button>}
|
|
{isPublic && <Chip size="small" icon={<PublicIcon />} label={t("cvEditorShared")} variant="outlined" />}
|
|
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
|
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} sx={{ flex: { xs: 1, sm: "none" } }} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
|
|
<Tooltip title={t("cvEditorMoreActions")}><IconButton size="small" aria-label={t("cvEditorMoreActions")} aria-haspopup="menu" aria-expanded={!!actionsAnchor} aria-controls={actionsAnchor ? "cv-document-actions" : undefined} onClick={(event) => setActionsAnchor(event.currentTarget)}><MoreHorizIcon /></IconButton></Tooltip>
|
|
</Stack>
|
|
</Stack>
|
|
<Menu id="cv-document-actions" anchorEl={actionsAnchor} open={!!actionsAnchor} onClose={() => setActionsAnchor(null)} slotProps={{ list: { "aria-label": t("cvEditorMoreActions") } }}>
|
|
<MenuItem disabled={!name.trim()} onClick={() => { setActionsAnchor(null); void duplicateVariant(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorDuplicate")}</MenuItem>
|
|
<MenuItem disabled={publishing || exporting || !name.trim()} onClick={() => { setActionsAnchor(null); void togglePublic(); }}><PublicIcon fontSize="small" sx={{ mr: 1 }} />{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}</MenuItem>
|
|
{isPublic && <MenuItem onClick={() => { setActionsAnchor(null); copyPublicLink(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorCopyLink")}</MenuItem>}
|
|
</Menu>
|
|
{compactEditor && <Stack direction="row" gap={0.5} sx={{ mt: 1.5 }}>
|
|
<Button fullWidth size="small" aria-pressed={mobilePane === "edit"} variant={mobilePane === "edit" ? "contained" : "outlined"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
|
|
<Button fullWidth size="small" aria-pressed={mobilePane === "preview"} variant={mobilePane === "preview" ? "contained" : "outlined"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
|
|
</Stack>}
|
|
</Paper>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
|
<Paper ref={editorRef} tabIndex={-1} variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => 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" }}>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
|
|
<Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
|
|
<Stack direction="row">
|
|
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
|
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
|
</Stack>
|
|
</Stack>
|
|
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
|
|
{[1, 0, 2, 3, 4].map((value) => <Tab key={value} value={value} id={`cv-studio-tab-${value}`} aria-controls={`cv-studio-panel-${value}`} label={t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])} />)}
|
|
</Tabs>
|
|
<Box sx={{ p: { xs: 1, sm: 1.5 } }}>
|
|
<Box role="tabpanel" id="cv-studio-panel-1" aria-labelledby="cv-studio-tab-1" hidden={tab !== 1}>
|
|
<ContentTab settings={settings} update={update} outline={outline} onGenerate={generateAi} onAccept={acceptAi} showPreviewEntry={!compactEditor} onFocusPreview={() => {
|
|
scrollRef.current?.focus({ preventScroll: true });
|
|
scrollRef.current?.parentElement?.scrollIntoView?.({ block: "start", inline: "nearest" });
|
|
}} />
|
|
</Box>
|
|
{[0, 2, 3, 4].map((value) => <Box key={value} role="tabpanel" id={`cv-studio-panel-${value}`} aria-labelledby={`cv-studio-tab-${value}`} hidden={tab !== value}>
|
|
{tab === value && <>
|
|
{value === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
|
{value === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
|
{value === 3 && <DocumentAiTab settings={settings} outline={outline} onGenerate={generateAi} onAccept={acceptAi} />}
|
|
{value === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
|
</>}
|
|
</Box>)}
|
|
</Box>
|
|
</Paper>
|
|
|
|
<Paper variant="outlined" onFocusCapture={(event) => {
|
|
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)" }}>
|
|
<Box sx={{ flex: "0 1 auto", minHeight: 0, maxHeight: "50%", overflowY: "auto", scrollbarWidth: "thin" }}>
|
|
<Stack direction="row" alignItems="center" gap={1} sx={{ px: 1.5, py: 1, flexWrap: "wrap", borderBottom: "1px solid", borderColor: "divider" }}>
|
|
<Box sx={{ flex: "1 1 140px", minWidth: 0 }}>
|
|
<Typography component="h2" variant="subtitle2">{t("cvEditorLivePreview")}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorPreviewScale", { percent: Math.round(zoom * 100) })}</Typography>
|
|
</Box>
|
|
<Typography variant="caption" color="text.secondary" role="status">
|
|
{previewBusy ? t("cvEditorUpdatingPreview") : previewReady ? t("cvEditorPageCount", { count: pages }) : null}
|
|
</Typography>
|
|
<Button size="small" onClick={() => {
|
|
setMobilePane("edit");
|
|
requestAnimationFrame(() => {
|
|
editorRef.current?.focus({ preventScroll: true });
|
|
editorRef.current?.scrollIntoView({ block: "start", inline: "nearest" });
|
|
});
|
|
}}>{t("cvEditorFocusEditor")}</Button>
|
|
</Stack>
|
|
{previewError && !previewing && <Alert severity="error" action={<Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}</Button>} sx={{ m: 1, "& .MuiAlert-action": { alignSelf: "center", flexShrink: 1 } }}>
|
|
<Typography variant="body2" fontWeight={600}>{t("cvEditorPreviewUnavailable")}</Typography>
|
|
<Typography variant="body2">{t(html ? "cvEditorPreviewPrevious" : "cvEditorPreviewEmpty")}</Typography>
|
|
</Alert>}
|
|
{!!html && previewOverflow && <Alert severity="warning" aria-hidden={!previewReady} sx={{ m: 1, visibility: previewReady ? "visible" : "hidden" }}>{t("cvEditorOverflowWarning")}</Alert>}
|
|
{!!html && pages >= 3 && <Alert severity="info" aria-hidden={!previewReady} sx={{ m: 1, visibility: previewReady ? "visible" : "hidden" }}>{t("cvEditorLongDocument", { pages })}</Alert>}
|
|
<Typography id="cv-preview-help" variant="caption" color="text.secondary" sx={{ display: "block", px: 1.5, py: 0.75 }}>{t("cvEditorPreviewHelp")}</Typography>
|
|
</Box>
|
|
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", visibility: "hidden", pointerEvents: "none" }} />
|
|
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} aria-describedby="cv-preview-help" aria-busy={previewBusy} tabIndex={0} onKeyDown={(event) => {
|
|
// Keep document-boundary keys in this pane, including when already at its edge.
|
|
if (event.target === event.currentTarget && event.ctrlKey && (event.key === "Home" || event.key === "End")) {
|
|
event.preventDefault();
|
|
event.currentTarget.scrollTop = event.key === "Home" ? 0 : event.currentTarget.scrollHeight;
|
|
}
|
|
}} sx={{ overflow: "auto", flex: "1 1 0", minHeight: 0, maxHeight: "86vh", px: 1, py: 1.5, bgcolor: "background.default", scrollbarWidth: "thin" }}>
|
|
<Stack spacing={2} alignItems="center">
|
|
{!html && previewBusy && <Skeleton variant="rectangular" width="100%" height={320} />}
|
|
{!!html && Array.from({ length: pages }).map((_, index) => (
|
|
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
|
|
<Typography aria-hidden={!previewReady} variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5, visibility: previewReady ? "visible" : "hidden" }}>{t("cvEditorPageOf", { page: index + 1, pages })}</Typography>
|
|
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "common.white", boxShadow: 1, outline: "1px solid", outlineColor: "divider", overflow: "hidden" }}>
|
|
<iframe
|
|
title={t("cvEditorPreviewPage", { page: index + 1 })}
|
|
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, index)}
|
|
sandbox="allow-same-origin allow-scripts"
|
|
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
</Paper>
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function EditorSkeleton() {
|
|
return (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2 }}>
|
|
<Paper sx={{ p: 2, borderRadius: 4 }}>
|
|
<Skeleton width="60%" height={32} />
|
|
<Skeleton width="100%" height={40} sx={{ mt: 1 }} />
|
|
<Skeleton variant="rounded" height={44} sx={{ mt: 2 }} />
|
|
{[0, 1, 2, 3, 4].map((i) => <Skeleton key={i} variant="rounded" height={40} sx={{ mt: 1 }} />)}
|
|
</Paper>
|
|
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", display: "flex", justifyContent: "center" }}>
|
|
<Skeleton variant="rounded" width="70%" height={620} />
|
|
</Paper>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: boolean; onRetry: () => void }) {
|
|
const { t } = useI18n();
|
|
const map: Record<SaveState, { label: string; color: "default" | "warning" | "success" | "error" }> = {
|
|
idle: { label: "", color: "default" },
|
|
unsaved: { label: t("cvEditorUnsaved"), color: "warning" },
|
|
saving: { label: t("cvEditorSaving"), color: "warning" },
|
|
saved: { label: t("cvEditorSaved"), color: "success" },
|
|
error: { label: t("cvEditorSaveFailed"), color: "error" },
|
|
conflict: { label: t("cvEditorConflict"), color: "warning" },
|
|
};
|
|
const m = map[state];
|
|
if (!m.label) return null;
|
|
return (
|
|
<Stack direction="row" gap={0.5} alignItems="center" aria-live="polite">
|
|
{state === "saved" ? <Typography variant="caption" color="text.secondary">{m.label}</Typography> : <Chip size="small" label={m.label} color={m.color} variant="outlined" />}
|
|
{(state === "unsaved" || state === "error") ? (
|
|
<Button size="small" disabled={!canRetry} onClick={onRetry}>{t("cvEditorSaveNow")}</Button>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// ---------- Whole-document AI ----------
|
|
|
|
type DocumentAiBlock = {
|
|
id: string;
|
|
title?: string;
|
|
subtitle?: string;
|
|
items?: string[];
|
|
};
|
|
|
|
function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null): DocumentAiBlock[] {
|
|
const blocks: DocumentAiBlock[] = [];
|
|
const configured = new Map(settings.sections.map((section) => [section.key, section]));
|
|
if ((settings.headline ?? outline?.headline)?.trim()) {
|
|
blocks.push({ id: "headline", items: [(settings.headline ?? outline?.headline ?? "").trim()] });
|
|
}
|
|
for (const section of outline?.sections ?? []) {
|
|
const sectionSetting = configured.get(section.key);
|
|
if (sectionSetting?.hidden) continue;
|
|
const title = sectionSetting?.title ?? section.title;
|
|
if (section.kind === "entries") {
|
|
const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`)));
|
|
const storedOrder = Array.from(new Set(sectionSetting?.itemOrder ?? []));
|
|
const orderedKeys = storedOrder.length
|
|
? [...storedOrder.filter((key) => entryKeys.includes(key)), ...entryKeys.filter((key) => !storedOrder.includes(key))]
|
|
: entryKeys;
|
|
const byKey = new Map(section.entries.map((entry, index) => [entry.key ?? `${index}`, entry]));
|
|
for (const key of orderedKeys) {
|
|
const entry = byKey.get(key);
|
|
if (!entry) continue;
|
|
const override = settings.overrides[key] ?? {};
|
|
if (override.hidden) continue;
|
|
blocks.push({
|
|
id: `entry:${section.key}:${key}`,
|
|
title: override.title ?? entry.title,
|
|
subtitle: override.subtitle ?? entry.subtitle,
|
|
items: override.bullets ?? entry.bullets,
|
|
});
|
|
}
|
|
} else {
|
|
blocks.push({ id: `section:${section.key}`, title, items: sectionSetting?.items ?? (section.kind === "tags" ? section.tags : section.bullets) });
|
|
}
|
|
}
|
|
for (const section of settings.customSections ?? []) {
|
|
if (!(configured.get(`custom:${section.key}`)?.hidden ?? section.hidden)) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
|
|
}
|
|
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
|
|
}
|
|
|
|
function parseDocumentAiBlocks(value: string, originals: DocumentAiBlock[]): DocumentAiBlock[] | null {
|
|
try {
|
|
const start = value.indexOf("[");
|
|
const end = value.lastIndexOf("]");
|
|
if (start < 0 || end <= start) return null;
|
|
const parsed = JSON.parse(value.slice(start, end + 1));
|
|
if (!Array.isArray(parsed)) return null;
|
|
const originalIds = new Set(originals.map((block) => block.id));
|
|
const result = parsed.filter((block): block is DocumentAiBlock => block && typeof block.id === "string" && originalIds.has(block.id)).map((block) => {
|
|
const original = originals.find((item) => item.id === block.id);
|
|
return {
|
|
id: block.id,
|
|
title: typeof block.title === "string" ? block.title : original?.title,
|
|
subtitle: typeof block.subtitle === "string" ? block.subtitle : original?.subtitle,
|
|
items: Array.isArray(block.items) ? block.items.filter((item): item is string => typeof item === "string") : original?.items,
|
|
};
|
|
});
|
|
return result.length === originals.length && new Set(result.map((block) => block.id)).size === originals.length ? result : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallbacks & {
|
|
settings: CvVariantSettings;
|
|
outline: CvOutline | null;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const { canUseAi } = useAccountPlan();
|
|
const blocks = useMemo(() => documentAiBlocks(settings, outline), [settings, outline]);
|
|
const [targetLanguage, setTargetLanguage] = useState(normalizeCvLanguage(settings.language));
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [suggestion, setSuggestion] = useState<DocumentAiBlock[] | null>(null);
|
|
const [originals, setOriginals] = useState<DocumentAiBlock[]>([]);
|
|
const [proposal, setProposal] = useState<CvAiProposal | null>(null);
|
|
const requestId = useRef(0);
|
|
const sourceKey = JSON.stringify(blocks);
|
|
|
|
useEffect(() => setTargetLanguage(normalizeCvLanguage(settings.language)), [settings.language]);
|
|
useEffect(() => {
|
|
requestId.current += 1;
|
|
setSuggestion(null);
|
|
setProposal(null);
|
|
setBusy(null);
|
|
return () => { requestId.current += 1; };
|
|
}, [sourceKey, settings.language, canUseAi]);
|
|
|
|
const discard = () => {
|
|
requestId.current += 1;
|
|
setSuggestion(null);
|
|
setProposal(null);
|
|
setBusy(null);
|
|
};
|
|
|
|
const run = async (action: string) => {
|
|
if (!blocks.length || !canUseAi || busy) return;
|
|
const request = ++requestId.current;
|
|
setBusy(action);
|
|
setSuggestion(null);
|
|
try {
|
|
const response = await onGenerate({
|
|
action,
|
|
language: targetLanguage,
|
|
context: "This is a complete CV represented as JSON blocks. Return only a JSON array with every original id exactly once and the same title, subtitle and items fields. Preserve all facts, employers, dates, qualifications and technologies. Never add unsupported claims.",
|
|
});
|
|
if (request !== requestId.current) return;
|
|
const source: DocumentAiBlock[] = JSON.parse(response.original);
|
|
if (!Array.isArray(source) || !source.length || source.some((block) => !block || typeof block.id !== "string") || new Set(source.map((block) => block.id)).size !== source.length) throw new Error(t("cvDocumentAiInvalid"));
|
|
const parsed = parseDocumentAiBlocks(response.result, source);
|
|
if (!parsed) throw new Error(t("cvDocumentAiInvalid"));
|
|
setOriginals(source);
|
|
setProposal(response);
|
|
setSuggestion(parsed);
|
|
} catch (error) {
|
|
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
|
} finally {
|
|
if (request === requestId.current) setBusy(null);
|
|
}
|
|
};
|
|
|
|
const accept = async () => {
|
|
if (!proposal || busy) return;
|
|
const request = ++requestId.current;
|
|
setBusy("apply");
|
|
try {
|
|
await onAccept(proposal);
|
|
if (request !== requestId.current) return;
|
|
discard();
|
|
toast(t("cvDocumentAiApplied"), "success");
|
|
} catch (error) {
|
|
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
|
} finally {
|
|
if (request === requestId.current) setBusy(null);
|
|
}
|
|
};
|
|
|
|
const actions = [
|
|
{ key: "document-translate", title: t("cvDocumentAiTranslate"), description: t("cvDocumentAiTranslateHelp"), icon: <TranslateIcon /> },
|
|
{ key: "document-improve", title: t("cvDocumentAiImprove"), description: t("cvDocumentAiImproveHelp"), icon: <AutoFixHighIcon /> },
|
|
{ key: "document-grammar", title: t("cvDocumentAiGrammar"), description: t("cvDocumentAiGrammarHelp"), icon: <SpellcheckIcon /> },
|
|
];
|
|
const changed = suggestion?.filter((block) => JSON.stringify(block) !== JSON.stringify(originals.find((original) => original.id === block.id))) ?? [];
|
|
|
|
return (
|
|
<Stack spacing={2}>
|
|
<Box>
|
|
<Typography variant="h6" sx={{ fontWeight: 850 }}>{t("cvDocumentAiTitle")}</Typography>
|
|
<Typography variant="body2" color="text.secondary">{t("cvDocumentAiHelp")}</Typography>
|
|
</Box>
|
|
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
|
|
<TextField select size="small" disabled={!!busy} label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
|
|
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
|
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
|
</TextField>
|
|
<Stack spacing={1}>
|
|
{actions.map((action) => (
|
|
<Paper key={action.key} variant="outlined" sx={{ p: 1.5 }}>
|
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
|
<Box sx={{ color: "primary.main", pt: 0.25 }}>{action.icon}</Box>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{action.title}</Typography>
|
|
<Typography variant="body2" color="text.secondary">{action.description}</Typography>
|
|
</Box>
|
|
<Button variant="outlined" size="small" disabled={!canUseAi || !!busy || !blocks.length} onClick={() => void run(action.key)}>
|
|
{busy === action.key ? t("cvAiWorking") : t("cvDocumentAiReview")}
|
|
</Button>
|
|
</Stack>
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
{suggestion && (
|
|
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvDocumentAiReviewTitle", { count: changed.length })}</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("cvDocumentAiReviewHelp")}</Typography>
|
|
{changed.length === 0 ? <Alert severity="info">{t("cvDocumentAiNoChanges")}</Alert> : (
|
|
<Stack spacing={1} sx={{ maxHeight: 360, overflowY: "auto" }}>
|
|
{changed.map((block) => {
|
|
const original = originals.find((item) => item.id === block.id);
|
|
return <Paper key={block.id} variant="outlined" sx={{ p: 1 }}><Typography variant="caption" sx={{ fontWeight: 800 }}>{block.title ?? original?.title ?? block.id}</Typography><Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1, mt: 0.5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{[original?.subtitle, ...(original?.items ?? [])].filter(Boolean).join("\n")}</Typography><Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{[block.subtitle, ...(block.items ?? [])].filter(Boolean).join("\n")}</Typography></Box></Paper>;
|
|
})}
|
|
</Stack>
|
|
)}
|
|
<Alert severity="info" sx={{ mt: 1.5 }}>{t("cvAiCheckFacts")}</Alert>
|
|
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
|
<Button variant="contained" disabled={!!busy || !changed.length} onClick={() => void accept()}>{t("cvAiApply")}</Button>
|
|
<Button disabled={busy === "apply"} onClick={discard}>{t("cvAiReject")}</Button>
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// ---------- Content tab ----------
|
|
|
|
type Translate = ReturnType<typeof useI18n>["t"];
|
|
|
|
function sectionName(key: string, fallback: string, t: Translate) {
|
|
const labels: Record<string, string> = {
|
|
summary: t("cvSectionSummary"), experience: t("cvSectionExperience"), education: t("cvSectionEducation"),
|
|
projects: t("cvSectionProjects"), skills: t("cvSectionSkills"), certifications: t("cvSectionCertifications"),
|
|
languages: t("cvSectionLanguages"), interests: t("cvSectionInterests"), awards: t("cvSectionAwards"),
|
|
publications: t("cvSectionPublications"), organisations: t("cvSectionOrganisations"), references: t("cvSectionReferences"),
|
|
};
|
|
return labels[key] ?? fallback;
|
|
}
|
|
|
|
function presetLabel(key: string, fallback: string, t: Translate) {
|
|
const labels: Record<string, string> = {
|
|
courses: t("cvSectionCourses"), volunteer: t("cvSectionVolunteer"),
|
|
"additional-experience": t("cvSectionAdditionalExperience"), declaration: t("cvSectionDeclaration"),
|
|
};
|
|
return labels[key] ?? fallback;
|
|
}
|
|
|
|
function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview, onGenerate, onAccept }: CvAiCallbacks & {
|
|
settings: CvVariantSettings;
|
|
update: (p: Partial<CvVariantSettings>) => void;
|
|
outline: CvOutline | null;
|
|
showPreviewEntry: boolean;
|
|
onFocusPreview: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { confirmAction } = useDialogActions();
|
|
const [addContentOpen, setAddContentOpen] = useState(false);
|
|
const addContentButton = useRef<HTMLButtonElement | null>(null);
|
|
const [activeSection, setActiveSection] = useState<string | null>(null);
|
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
|
|
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
// Full section list = configured order (once touched) else default, always including every known key.
|
|
const sectionRows: CvSectionSetting[] = useMemo(() => {
|
|
const populated = new Set((outline?.sections ?? []).map((section) => section.key));
|
|
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key, hidden: !populated.has(key) }));
|
|
const have = new Set(base.map((s) => s.key));
|
|
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key, hidden: !populated.has(key) });
|
|
for (const section of outline?.sections ?? []) if (!have.has(section.key)) {
|
|
base.push({ key: section.key, title: section.title });
|
|
have.add(section.key);
|
|
}
|
|
for (const custom of settings.customSections) {
|
|
const key = `custom:${custom.key}`;
|
|
if (!have.has(key)) {
|
|
base.push({ key, hidden: custom.hidden });
|
|
have.add(key);
|
|
}
|
|
}
|
|
return base;
|
|
}, [outline?.sections, settings.customSections, settings.sections]);
|
|
|
|
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
|
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
|
|
const toggleSection = (key: string) => setExpandedSections((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(key)) next.delete(key); else next.add(key);
|
|
return next;
|
|
});
|
|
const goToSection = (key: string) => {
|
|
setActiveSection(key);
|
|
setExpandedSections((previous) => new Set(previous).add(key));
|
|
requestAnimationFrame(() => {
|
|
sectionButtons.current[key]?.focus({ preventScroll: true });
|
|
sectionButtons.current[key]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
});
|
|
};
|
|
|
|
const patchSection = (key: string, patch: Partial<CvSectionSetting>) =>
|
|
writeSections(sectionRows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
|
|
|
const outlineByKey = useMemo(() => {
|
|
const m: Record<string, CvOutlineSection> = {};
|
|
outline?.sections.forEach((s) => { m[s.key] = s; });
|
|
return m;
|
|
}, [outline]);
|
|
|
|
const customBySectionKey = useMemo(() => Object.fromEntries(
|
|
settings.customSections.map((section) => [`custom:${section.key}`, section]),
|
|
), [settings.customSections]);
|
|
|
|
const addCustom = (type: string) => {
|
|
const preset = CUSTOM_SECTION_PRESETS.find((item) => item.key === type);
|
|
if (preset && settings.customSections.some((item) => item.presetKey === preset.key)) return;
|
|
const key = `c${Date.now().toString(36)}`;
|
|
update({
|
|
customSections: [...settings.customSections, {
|
|
key,
|
|
title: preset?.title ?? t("cvEditorNewSection"),
|
|
items: [],
|
|
presetKey: preset?.key ?? null,
|
|
contentType: preset?.contentType ?? "paragraphs",
|
|
}],
|
|
sections: [...sectionRows, { key: `custom:${key}` }],
|
|
});
|
|
setAddContentOpen(false);
|
|
};
|
|
const addBuiltIn = (key: string) => {
|
|
patchSection(key, { hidden: false });
|
|
setAddContentOpen(false);
|
|
};
|
|
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
|
|
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
|
|
const removeCustom = async (section: CvCustomSectionSetting) => {
|
|
if (!(await confirmAction(t("cvEditorDeleteCustomMessage", { title: section.title || t("cvEditorUntitled") }), {
|
|
title: t("cvEditorDeleteCustomTitle"),
|
|
confirmLabel: t("cvEditorDeleteSection"),
|
|
destructive: true,
|
|
}))) return;
|
|
update({
|
|
customSections: settings.customSections.filter((c) => c.key !== section.key),
|
|
sections: sectionRows.filter((row) => row.key !== `custom:${section.key}`),
|
|
});
|
|
requestAnimationFrame(() => addContentButton.current?.focus());
|
|
};
|
|
return (
|
|
<Stack spacing={2}>
|
|
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap">
|
|
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: "1 1 14rem", minWidth: 0 }}>
|
|
<TextField select fullWidth size="small" label={t("cvEditorGoToSection")}
|
|
value={sectionRows.some((row) => row.key === activeSection) ? activeSection : ""}
|
|
onChange={(event) => setActiveSection(event.target.value)}>
|
|
{sectionRows.map((row) => <MenuItem key={row.key} value={row.key} onClick={() => goToSection(row.key)}>
|
|
{customBySectionKey[row.key]?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t)} · {t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}
|
|
</MenuItem>)}
|
|
</TextField>
|
|
</Box>
|
|
{showPreviewEntry && <Button size="small" onClick={onFocusPreview}>{t("cvEditorFocusPreview")}</Button>}
|
|
</Stack>
|
|
<Alert severity="info" sx={{ flexWrap: "wrap", "& .MuiAlert-message": { flex: "1 1 12rem", minWidth: 0, overflowWrap: "anywhere" }, "& .MuiAlert-action": { ml: "auto" } }} action={<Button component={RouterLink} to="/career?section=profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
|
|
{t("cvEditorMasterProfileHelp")}
|
|
</Alert>
|
|
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
|
|
onChange={(e) => update({ headline: e.target.value || null })}
|
|
helperText={t("cvEditorHeadlineHelp")} />
|
|
|
|
<Box>
|
|
<Typography component="h3" variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
|
|
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
|
{sectionRows.map((row, i) => (
|
|
<SectionRow
|
|
key={row.key}
|
|
row={row}
|
|
index={i}
|
|
total={sectionRows.length}
|
|
dragProps={sectionDrag.getItemProps(i)}
|
|
dragging={sectionDrag.dragIndex === i}
|
|
over={sectionDrag.overIndex === i && sectionDrag.dragIndex !== i}
|
|
outlineSection={outlineByKey[row.key]}
|
|
customSection={customBySectionKey[row.key]}
|
|
settings={settings}
|
|
expanded={expandedSections.has(row.key)}
|
|
onToggle={() => toggleSection(row.key)}
|
|
onSectionFocus={() => setActiveSection(row.key)}
|
|
toggleRef={(button) => { sectionButtons.current[row.key] = button; }}
|
|
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
|
|
onPatch={(p) => patchSection(row.key, p)}
|
|
onRenameCustom={(title) => {
|
|
const custom = customBySectionKey[row.key];
|
|
if (custom) updateCustom(custom.key, { title });
|
|
}}
|
|
onUpdateCustom={(patch) => {
|
|
const custom = customBySectionKey[row.key];
|
|
if (custom) updateCustom(custom.key, patch);
|
|
}}
|
|
onRemoveCustom={() => {
|
|
const custom = customBySectionKey[row.key];
|
|
if (custom) void removeCustom(custom);
|
|
}}
|
|
onUpdateSettings={update}
|
|
onGenerate={onGenerate}
|
|
onAccept={onAccept}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Button ref={addContentButton} variant="contained" startIcon={<AddIcon />} onClick={() => setAddContentOpen(true)} sx={{ alignSelf: "center", minWidth: 190 }}>{t("cvEditorAddContent")}</Button>
|
|
<Dialog open={addContentOpen} onClose={() => setAddContentOpen(false)} fullWidth maxWidth="md" aria-labelledby="cv-add-content-title" data-content-density="comfortable">
|
|
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
|
|
<DialogContent dividers>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("cvEditorAddContentHelp")}</Typography>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", md: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
|
|
{DEFAULT_SECTION_ORDER.map((key) => {
|
|
const row = sectionRows.find((section) => section.key === key);
|
|
const added = !!row && !row.hidden;
|
|
const label = sectionName(key, SECTION_LABELS[key] ?? key, t);
|
|
return <AddContentCard key={key} title={label} description={t("cvEditorAddBuiltInHelp", { section: label })} added={added} onAdd={() => addBuiltIn(key)} />;
|
|
})}
|
|
{CUSTOM_SECTION_PRESETS.map((preset) => {
|
|
const added = settings.customSections.some((item) => item.presetKey === preset.key);
|
|
const label = presetLabel(preset.key, preset.title, t);
|
|
return <AddContentCard key={preset.key} title={label} description={t("cvEditorAddPresetHelp", { section: label })} added={added} onAdd={() => addCustom(preset.key)} />;
|
|
})}
|
|
<AddContentCard title={t("cvEditorCustom")} description={t("cvEditorAddCustomHelp")} added={false} onAdd={() => addCustom("free-text")} />
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions><Button onClick={() => setAddContentOpen(false)}>{t("cancel")}</Button></DialogActions>
|
|
</Dialog>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function AddContentCard({ title, description, added, onAdd }: { title: string; description: string; added: boolean; onAdd: () => void }) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<Paper variant="outlined" sx={{ p: 1.25, minHeight: 112, display: "flex", flexDirection: "column", alignItems: "flex-start", bgcolor: added ? "action.disabledBackground" : "background.paper" }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, mt: 0.25 }}>{description}</Typography>
|
|
<Button size="small" startIcon={!added ? <AddIcon /> : undefined} disabled={added} onClick={onAdd} sx={{ mt: 0.75, ml: -0.75 }}>
|
|
{added ? t("cvEditorContentAdded") : t("cvEditorAdd")}
|
|
</Button>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function SectionRow({
|
|
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings, onGenerate, onAccept,
|
|
}: CvAiCallbacks & {
|
|
row: CvSectionSetting;
|
|
index: number;
|
|
total: number;
|
|
dragProps: React.HTMLAttributes<HTMLDivElement> & { draggable: boolean };
|
|
dragging: boolean;
|
|
over: boolean;
|
|
outlineSection?: CvOutlineSection;
|
|
customSection?: CvCustomSectionSetting;
|
|
settings: CvVariantSettings;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
onSectionFocus: () => void;
|
|
toggleRef: (button: HTMLButtonElement | null) => void;
|
|
onMove: (delta: number) => void;
|
|
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
|
onRenameCustom: (title: string) => void;
|
|
onUpdateCustom: (patch: Partial<CvCustomSectionSetting>) => void;
|
|
onRemoveCustom: () => void;
|
|
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { confirmAction } = useDialogActions();
|
|
const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
|
|
const customInputs = useRef<Record<number, HTMLTextAreaElement | null>>({});
|
|
const addEntryButton = useRef<HTMLButtonElement | null>(null);
|
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
const focusCustomEntry = (index: number) => requestAnimationFrame(() => {
|
|
(customInputs.current[index] ?? addEntryButton.current)?.focus();
|
|
});
|
|
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
|
|
const editableItems = row.items ?? sourceItems;
|
|
const customText = customSection?.items.join("\n\n") ?? "";
|
|
|
|
return (
|
|
<Paper
|
|
variant="outlined"
|
|
{...dragProps}
|
|
role="group" aria-label={sectionLabel} onFocusCapture={onSectionFocus}
|
|
sx={{
|
|
// Hidden in the document is not disabled in the editor: keep names and
|
|
// controls readable. The visibility icon/label still conveys inclusion.
|
|
opacity: dragging ? 0.4 : 1,
|
|
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
|
|
transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease",
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Stack direction="row" alignItems="center" gap={0.5} flexWrap="wrap" sx={{ px: 0.75, py: 0.5 }}>
|
|
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled", cursor: "grab" }} aria-hidden />
|
|
<Stack>
|
|
<IconButton size="small" aria-label={t("cvEditorMoveSectionUp", { section: sectionLabel })} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
|
<IconButton size="small" aria-label={t("cvEditorMoveSectionDown", { section: sectionLabel })} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
|
</Stack>
|
|
<TextField variant="standard" fullWidth value={sectionLabel} sx={{ flex: "1 1 10rem", minWidth: 0 }}
|
|
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
|
|
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": t("cvEditorSectionName", { section: row.key }) } }} />
|
|
<IconButton size="small" aria-label={t(row.hidden ? "cvEditorShowSection" : "cvEditorHideSection", { section: sectionLabel })} onClick={() => onPatch({ hidden: !row.hidden })}>
|
|
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
|
</IconButton>
|
|
<IconButton size="small" aria-label={t("cvEditorRemoveSection", { section: sectionLabel })} onClick={() => {
|
|
if (customSection) onRemoveCustom();
|
|
else void confirmAction(t("cvEditorRemoveSectionMessage", { section: sectionLabel }), { title: t("cvEditorRemoveSectionTitle"), confirmLabel: t("cvEditorRemoveFromCv"), destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
|
|
}}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
|
<IconButton ref={toggleRef} size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={onToggle}
|
|
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
|
<ExpandMoreIcon fontSize="small" />
|
|
</IconButton>
|
|
</Stack>
|
|
<Collapse in={expanded} unmountOnExit>
|
|
<Box ref={contentRef} sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
|
<span>{customSection ? t("cvEditorCvOnly") : t("cvEditorEntryScopeHelp")}</span> · <span>{t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}</span>
|
|
</Typography>
|
|
<Box sx={{ mb: 1.5 }}>
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Typography variant="caption" sx={{ fontWeight: 800 }}>{t("cvEditorSectionLayout")}</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t("cvEditorSectionLayoutHelp")}</Typography>
|
|
</Box>
|
|
<TextField select size="small" label={t("cvEditorPresentation")} value={row.presentation ?? "rows"} onChange={(event) => onPatch({ presentation: event.target.value as CvSectionSetting["presentation"] })} sx={{ minWidth: 145 }}>
|
|
<MenuItem value="rows">{t("cvEditorRows")}</MenuItem>
|
|
<MenuItem value="grid">{t("cvEditorGrid")}</MenuItem>
|
|
<MenuItem value="compact">{t("cvEditorCompact")}</MenuItem>
|
|
{(outlineSection?.kind === "tags" || (!outlineSection && customSection?.contentType !== "entries")) && <MenuItem value="bubble">{t("cvEditorBubble")}</MenuItem>}
|
|
</TextField>
|
|
{row.presentation === "grid" && <Stack direction="row" spacing={0.5} role="group" aria-label={t("cvEditorColumns")}>
|
|
{[1, 2].map((columns) => <Button key={columns} size="small" variant={(row.columns ?? 1) === columns ? "contained" : "outlined"} onClick={() => onPatch({ columns: columns as 1 | 2 })}>{columns}</Button>)}
|
|
</Stack>}
|
|
</Stack>
|
|
</Box>
|
|
{customSection ? (
|
|
<Stack spacing={1}>
|
|
<TextField select size="small" label={t("cvEditorContentFormat")} value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
|
<MenuItem value="paragraphs">{t("cvEditorParagraphs")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="entries">{t("cvEditorMultipleEntries")}</MenuItem>
|
|
</TextField>
|
|
{customSection.contentType === "entries" ? <Stack spacing={1}>
|
|
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} spacing={0.5}>
|
|
<TextField inputRef={(input) => { customInputs.current[itemIndex] = input; }} fullWidth size="small" multiline minRows={2} label={t("cvEditorEntry", { number: itemIndex + 1 })} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
|
|
<Stack direction="row" gap={0.5}>
|
|
<IconButton size="small" aria-label={t("cvEditorMoveEntryUp", { number: itemIndex + 1 })} disabled={itemIndex === 0} onClick={() => { onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) }); focusCustomEntry(itemIndex - 1); }}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
|
<IconButton size="small" aria-label={t("cvEditorMoveEntryDown", { number: itemIndex + 1 })} disabled={itemIndex === customSection.items.length - 1} onClick={() => { onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) }); focusCustomEntry(itemIndex + 1); }}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
|
<IconButton size="small" aria-label={t("cvEditorDeleteEntryNumber", { number: itemIndex + 1 })} onClick={() => void confirmAction(t("cvEditorDeleteEntryMessage"), { title: t("cvEditorDeleteEntryTitle"), confirmLabel: t("cvEditorDeleteEntry"), destructive: true }).then((confirmed) => { if (confirmed) { onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); focusCustomEntry(Math.max(0, Math.min(itemIndex, customSection.items.length - 2))); } })}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
|
</Stack>
|
|
</Stack>)}
|
|
<Button ref={addEntryButton} size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => { onUpdateCustom({ items: [...customSection.items, ""] }); focusCustomEntry(customSection.items.length); }}>{t("cvEditorAddEntry")}</Button>
|
|
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
|
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
|
<AiSectionAssistant sectionName={sectionLabel} targetId={`custom:${customSection.key}`} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onGenerate={onGenerate} onAccept={onAccept} />
|
|
</Stack>
|
|
) : outlineSection?.kind === "entries" ? (
|
|
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} onGenerate={onGenerate} onAccept={onAccept} />
|
|
) : (
|
|
<Stack spacing={1}>
|
|
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} helperText={t(row.items != null ? "cvEditorCustomized" : "cvEditorUsingSource")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
|
{row.items && <Stack spacing={0.5}>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>
|
|
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => {
|
|
const field = contentRef.current?.querySelector<HTMLTextAreaElement>("textarea:not([aria-hidden])");
|
|
onPatch({ items: undefined }); requestAnimationFrame(() => field?.focus());
|
|
}}>{t("cvEditorResetMasterProfile")}</Button>
|
|
</Stack>}
|
|
<AiSectionAssistant sectionName={sectionLabel} targetId={`section:${row.key}`} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onGenerate={onGenerate} onAccept={onAccept} />
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
</Collapse>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function EntryEditor({ section, row, settings, onPatch, onUpdateSettings, onGenerate, onAccept }: CvAiCallbacks & {
|
|
section: CvOutlineSection;
|
|
row: CvSectionSetting;
|
|
settings: CvVariantSettings;
|
|
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
|
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const fieldRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement | null>>({});
|
|
// Entry order: itemOrder if set, else master order; always covering every entry key.
|
|
const orderedKeys: string[] = useMemo(() => {
|
|
const master = Array.from(new Set(section.entries.map((e) => e.key ?? "").filter(Boolean)));
|
|
if (!row.itemOrder?.length) return master;
|
|
const storedOrder = Array.from(new Set(row.itemOrder));
|
|
const set = new Set(storedOrder);
|
|
return [...storedOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))];
|
|
}, [row.itemOrder, section.entries]);
|
|
|
|
const entriesByKey = useMemo(() => {
|
|
const m: Record<string, (typeof section.entries)[number]> = {};
|
|
section.entries.forEach((e) => { if (e.key) m[e.key] = e; });
|
|
return m;
|
|
}, [section]);
|
|
|
|
const drag = useDragReorder((from, to) => onPatch({ itemOrder: moveItem(orderedKeys, from, to) }));
|
|
|
|
const setOverride = (key: string, patch: Partial<CvItemOverride>) => {
|
|
const overrides = { ...settings.overrides, [key]: { ...settings.overrides[key], ...patch } };
|
|
onUpdateSettings({ overrides });
|
|
};
|
|
const resetField = (key: string, field: "title" | "subtitle" | "bullets") => {
|
|
setOverride(key, { [field]: undefined });
|
|
requestAnimationFrame(() => fieldRefs.current[`${key}:${field}`]?.focus());
|
|
};
|
|
|
|
return (
|
|
<Stack spacing={0.75}>
|
|
{orderedKeys.map((key, i) => {
|
|
const entry = entriesByKey[key];
|
|
if (!entry) return null;
|
|
const ov = settings.overrides[key] ?? {};
|
|
const hidden = !!ov.hidden;
|
|
const entryLabel = ov.title?.trim() || entry.title || t("cvEditorUntitled");
|
|
return (
|
|
<Box key={key} {...drag.getItemProps(i)} role="group" aria-label={entryLabel}
|
|
sx={{
|
|
py: 1.25, minWidth: 0, opacity: drag.dragIndex === i ? 0.4 : 1,
|
|
borderBottom: "1px solid", borderBottomColor: "divider",
|
|
borderTop: drag.overIndex === i && drag.dragIndex !== i ? "2px solid" : undefined,
|
|
borderTopColor: "primary.main", transition: "opacity 150ms ease",
|
|
}}>
|
|
<Stack direction="row" alignItems="center" gap={0.5} flexWrap="wrap">
|
|
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
|
<Box sx={{ flex: "1 1 10rem", minWidth: 0 }}>
|
|
<Typography component="p" variant="subtitle2" sx={{ overflowWrap: "anywhere" }}>{entryLabel}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{t(hidden ? "cvEditorOmitted" : "cvEditorIncluded")}</Typography>
|
|
</Box>
|
|
<Stack direction="row" gap={0.5}>
|
|
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryUp", { entry: entryLabel })} disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
|
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryDown", { entry: entryLabel })} disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
|
<IconButton size="small" aria-label={t(hidden ? "cvEditorShowEntry" : "cvEditorHideEntry", { entry: entryLabel })} onClick={() => setOverride(key, { hidden: !hidden })}>
|
|
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
|
</IconButton>
|
|
</Stack>
|
|
</Stack>
|
|
<Stack spacing={0.75} sx={{ mt: 0.75 }}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1 }}>
|
|
{(["title", "subtitle"] as const).map((field) => {
|
|
const fieldLabel = t(field === "title" ? "cvEditorEntryTitle" : "cvEditorEntrySubtitle");
|
|
return <Box key={field} sx={{ minWidth: 0 }}>
|
|
<TextField inputRef={(input) => { fieldRefs.current[`${key}:${field}`] = input; }} size="small" fullWidth label={fieldLabel} value={ov[field] ?? entry[field] ?? ""}
|
|
placeholder={entry[field]} helperText={t(ov[field]?.trim() ? "cvEditorCustomized" : ov[field] != null ? "cvEditorBlankUsesSource" : "cvEditorUsingSource")}
|
|
onChange={(e) => setOverride(key, { [field]: e.target.value })} />
|
|
{ov[field] != null && <Button size="small" onClick={() => resetField(key, field)}>{t("cvEditorResetField", { field: fieldLabel })}</Button>}
|
|
</Box>;
|
|
})}
|
|
</Box>
|
|
<Box ref={(box: HTMLDivElement | null) => { fieldRefs.current[`${key}:bullets`] = box?.querySelector<HTMLTextAreaElement>("textarea:not([aria-hidden])") ?? null; }}>
|
|
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
|
|
helperText={t(ov.bullets != null ? "cvEditorCustomized" : "cvEditorUsingSource")}
|
|
value={(ov.bullets ?? entry.bullets).join("\n")}
|
|
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
|
</Box>
|
|
{ov.bullets != null && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resetField(key, "bullets")}>{t("cvEditorResetField", { field: t("cvEditorBulletsHelp") })}</Button>}
|
|
{(ov.title != null || ov.subtitle != null || ov.bullets != null) && <Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>}
|
|
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} targetId={`entry:${section.key}:${key}`} text={(ov.bullets ?? entry.bullets).join("\n")} documentLanguage={settings.language} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onGenerate={onGenerate} onAccept={onAccept} />
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) {
|
|
const { language, t } = useI18n();
|
|
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">{t("cvEditorNoVersions")}</Typography>;
|
|
return (
|
|
<Stack spacing={0.5}>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorVersionCount", { count: versions.length })}</Typography>
|
|
{versions.map((v) => (
|
|
<Paper key={v.version} variant="outlined" sx={{ p: 1, display: "flex", alignItems: "center", gap: 1 }}>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
|
{t("cvEditorVersion", { version: v.version })} {v.isCurrent && <Chip size="small" label={t("cvEditorCurrent")} sx={{ ml: 0.5, height: 18 }} />}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {v.source}</Typography>
|
|
</Box>
|
|
{!v.isCurrent && <Button size="small" aria-label={t("cvEditorRestoreTitle", { version: v.version })} onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function relTime(iso: string, language: "en" | "nb"): string {
|
|
const then = new Date(iso).getTime();
|
|
const diff = Date.now() - then;
|
|
const mins = Math.round(diff / 60000);
|
|
if (mins < 1) return language === "nb" ? "akkurat nå" : "just now";
|
|
if (mins < 60) return language === "nb" ? `${mins} min siden` : `${mins}m ago`;
|
|
const hrs = Math.round(mins / 60);
|
|
if (hrs < 24) return language === "nb" ? `${hrs} t siden` : `${hrs}h ago`;
|
|
return new Date(iso).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB");
|
|
}
|