927 lines
44 KiB
TypeScript
927 lines
44 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useBlocker, useNavigate, useParams } from "react-router-dom";
|
|
|
|
import {
|
|
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
|
|
MenuItem, Paper, Select, Skeleton, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
|
|
} from "@mui/material";
|
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
|
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
|
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
|
import PublicIcon from "@mui/icons-material/Public";
|
|
import AddIcon from "@mui/icons-material/Add";
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
|
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import { useToast } from "../toast";
|
|
import RichTextField from "../components/RichTextField";
|
|
import { useDragReorder } from "../hooks/useDragReorder";
|
|
import {
|
|
AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
|
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
|
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
|
|
} from "../cvBuilder";
|
|
import { useAccountPlan } from "../accountPlan";
|
|
import ProFeatureNotice from "../components/ProFeatureNotice";
|
|
import { useDialogActions } from "../dialogs";
|
|
|
|
const FONTS = [
|
|
"'Segoe UI', Roboto, Arial, sans-serif",
|
|
"Arial, Helvetica, sans-serif",
|
|
"Georgia, 'Times New Roman', serif",
|
|
"'Helvetica Neue', Arial, sans-serif",
|
|
"'Roboto', Arial, sans-serif",
|
|
"'Poppins', 'Segoe UI', Arial, sans-serif",
|
|
];
|
|
const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"];
|
|
const MIN_PREVIEW_ZOOM = 0.32;
|
|
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
|
|
|
export default function CvBuilderEditor() {
|
|
const { id } = useParams();
|
|
const variantId = Number(id);
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const { confirmAction } = useDialogActions();
|
|
|
|
const [name, setName] = useState("");
|
|
const [settings, setSettings] = useState<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 [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 [previewHeight, setPreviewHeight] = useState(() => getCvPageMetrics("a4").heightPx);
|
|
const [previewOverflow, setPreviewOverflow] = useState(false);
|
|
const [pages, setPages] = useState(1);
|
|
const [page, setPage] = 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 saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const saveRevision = useRef(0);
|
|
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
|
|
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 blockerPromptOpen = useRef(false);
|
|
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
(async () => {
|
|
try {
|
|
const [variant, themeList, outlineData] = await Promise.all([
|
|
cvBuilderApi.get(variantId),
|
|
cvBuilderApi.themes(),
|
|
cvBuilderApi.outline().catch(() => null),
|
|
]);
|
|
if (!alive) return;
|
|
applyVariant(variant);
|
|
setThemes(themeList);
|
|
setOutline(outlineData);
|
|
} catch (err) {
|
|
if (alive) setLoadError(getApiErrorMessage(err, "Could not open this CV."));
|
|
}
|
|
})();
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [variantId]);
|
|
|
|
const applyVariant = (variant: CvVariant) => {
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
saveRevision.current += 1;
|
|
setName(variant.name);
|
|
setSettings(variant.settings);
|
|
latestName.current = variant.name;
|
|
latestSettings.current = variant.settings;
|
|
setIsPublic(variant.isPublic);
|
|
setPublicSlug(variant.publicSlug);
|
|
setSaveState("saved");
|
|
};
|
|
|
|
// Debounced live preview.
|
|
useEffect(() => {
|
|
if (!settings) return;
|
|
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<CvVariantSettings>) => {
|
|
setSettings((prev) => {
|
|
if (!prev) return prev;
|
|
const next = { ...prev, ...patch };
|
|
scheduleSave(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const renameVariant = (value: string) => {
|
|
setName(value);
|
|
latestName.current = value;
|
|
if (settings) scheduleSave(settings, value);
|
|
};
|
|
|
|
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error";
|
|
const blocker = useBlocker(hasUnsavedChanges);
|
|
|
|
useEffect(() => {
|
|
if (blocker.state !== "blocked") {
|
|
blockerPromptOpen.current = false;
|
|
return;
|
|
}
|
|
if (blockerPromptOpen.current) return;
|
|
blockerPromptOpen.current = true;
|
|
void confirmAction("This CV has unsaved changes. Leave and discard them?", {
|
|
title: "Discard unsaved CV changes?",
|
|
confirmLabel: "Discard and leave",
|
|
destructive: true,
|
|
}).then((confirmed) => {
|
|
blockerPromptOpen.current = false;
|
|
if (confirmed) blocker.proceed();
|
|
else blocker.reset();
|
|
});
|
|
}, [blocker, confirmAction]);
|
|
|
|
useEffect(() => {
|
|
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!hasUnsavedChanges) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
};
|
|
window.addEventListener("beforeunload", warnBeforeUnload);
|
|
return () => window.removeEventListener("beforeunload", warnBeforeUnload);
|
|
}, [hasUnsavedChanges]);
|
|
|
|
useEffect(() => () => {
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
}, []);
|
|
|
|
const togglePublic = async () => {
|
|
if (hasUnsavedChanges && !(await retrySave())) {
|
|
toast("Save the current CV before changing its public link.", "error");
|
|
return;
|
|
}
|
|
setPublishing(true);
|
|
try {
|
|
const updated = await cvBuilderApi.setPublic(variantId, !isPublic);
|
|
setIsPublic(updated.isPublic);
|
|
setPublicSlug(updated.publicSlug);
|
|
toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success");
|
|
} catch (err) {
|
|
toast(getApiErrorMessage(err, "Could not change visibility."), "error");
|
|
} finally {
|
|
setPublishing(false);
|
|
}
|
|
};
|
|
|
|
const copyPublicLink = () => {
|
|
navigator.clipboard?.writeText(`${window.location.origin}/cv/${publicSlug}`);
|
|
toast("Public link copied.", "success");
|
|
};
|
|
|
|
const exportPdf = async () => {
|
|
if (hasUnsavedChanges && !(await retrySave())) {
|
|
toast("Save the current CV before exporting it.", "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) {
|
|
toast(getApiErrorMessage(err, "PDF export failed."), "error");
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const loadVersions = async () => {
|
|
try {
|
|
setVersions(await cvBuilderApi.versions(variantId));
|
|
} catch (err) {
|
|
toast(getApiErrorMessage(err, "Could not load history."), "error");
|
|
}
|
|
};
|
|
|
|
const restore = async (version: number) => {
|
|
if (!(await confirmAction(`Restore version ${version}? Your current saved CV remains in the version history.`, {
|
|
title: `Restore version ${version}`,
|
|
confirmLabel: "Restore version",
|
|
}))) return;
|
|
if (hasUnsavedChanges && !(await retrySave())) {
|
|
toast("Save the current CV before restoring an older version.", "error");
|
|
return;
|
|
}
|
|
try {
|
|
const updated = await cvBuilderApi.restore(variantId, version);
|
|
applyVariant(updated);
|
|
await loadVersions();
|
|
toast(`Restored version ${version}.`, "success");
|
|
} catch (err) {
|
|
toast(getApiErrorMessage(err, "Restore failed."), "error");
|
|
}
|
|
};
|
|
|
|
// Measure rendered content to drive page count + break indicators.
|
|
const onIframeLoad = () => {
|
|
try {
|
|
const doc = iframeRef.current?.contentDocument;
|
|
const h = Math.max(
|
|
pageMetrics.heightPx,
|
|
doc?.body?.scrollHeight ?? 0,
|
|
doc?.documentElement?.scrollHeight ?? 0,
|
|
);
|
|
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
|
|
setPreviewHeight(h);
|
|
setPages(pageCount);
|
|
setPage((current) => Math.min(current, 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);
|
|
}
|
|
};
|
|
|
|
const goToPage = (p: number) => {
|
|
const clamped = Math.min(Math.max(1, p), pages);
|
|
setPage(clamped);
|
|
scrollRef.current?.scrollTo({ top: (clamped - 1) * pageMetrics.heightPx * zoom, behavior: "smooth" });
|
|
};
|
|
|
|
const fitPreview = () => {
|
|
const availableWidth = Math.max(1, (scrollRef.current?.clientWidth ?? pageMetrics.widthPx) - 24);
|
|
setZoom(Math.min(1, Math.max(MIN_PREVIEW_ZOOM, availableWidth / pageMetrics.widthPx)));
|
|
};
|
|
|
|
if (loadError) {
|
|
return (
|
|
<Box sx={{ p: 3 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/career/builder")}>Back to CVs</Button>
|
|
<Alert severity="error" sx={{ mt: 2 }}>{loadError}</Alert>
|
|
</Box>
|
|
);
|
|
}
|
|
if (!settings) return <EditorSkeleton />;
|
|
|
|
return (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
|
|
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}>
|
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
|
<Tooltip title="Back to CVs"><IconButton size="small" aria-label="Back to CVs" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
|
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
|
|
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined}
|
|
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} />
|
|
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
|
</Stack>
|
|
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
|
|
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Export PDF"}</Button>
|
|
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
|
|
{publishing ? "Updating…" : isPublic ? "Public" : "Private"}
|
|
</Button>
|
|
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
|
|
</Stack>
|
|
|
|
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5 }}>
|
|
<Tab label="Content" />
|
|
<Tab label="Customize" />
|
|
<Tab label="AI Tools" />
|
|
<Tab label="History" />
|
|
</Tabs>
|
|
|
|
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
|
|
{tab === 1 && <CustomizeTab settings={settings} update={update} themes={themes} />}
|
|
{tab === 2 && <AiToolsTab />}
|
|
{tab === 3 && <HistoryTab versions={versions} onRestore={restore} />}
|
|
</Paper>
|
|
|
|
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
|
|
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
|
|
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
|
|
{previewError && <Chip size="small" label="Preview unavailable" color="error" variant="outlined" />}
|
|
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>Retry preview</Button>}
|
|
<Box sx={{ flex: 1 }} />
|
|
{pages >= 3 && <Chip size="small" color="warning" variant="outlined" label={`${pages}-page CV`} />}
|
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button>
|
|
<Typography variant="caption">Page {page} of {pages}</Typography>
|
|
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button>
|
|
</Stack>
|
|
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
|
<IconButton size="small" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
|
|
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" />
|
|
<IconButton size="small" aria-label="Zoom in" onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton>
|
|
<Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
|
|
<Button size="small" onClick={fitPreview}>Fit</Button>
|
|
</Stack>
|
|
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
|
|
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
|
|
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
|
|
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `${previewHeight * zoom}px`, flex: "0 0 auto" }}>
|
|
<iframe
|
|
ref={iframeRef}
|
|
title="CV preview"
|
|
srcDoc={html}
|
|
onLoad={onIframeLoad}
|
|
style={{
|
|
width: `${pageMetrics.widthMm}mm`, height: `${previewHeight}px`, border: "none",
|
|
transform: `scale(${zoom})`, transformOrigin: "top left",
|
|
boxShadow: "0 8px 30px rgba(0,0,0,0.24)", background: "#fff", display: "block",
|
|
}}
|
|
/>
|
|
{Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => (
|
|
<Box key={i} aria-hidden sx={{
|
|
position: "absolute", left: 0, right: 0, top: `${(i + 1) * pageMetrics.heightPx * zoom}px`,
|
|
borderTop: "2px dashed", borderColor: "error.main", opacity: 0.72, pointerEvents: "none",
|
|
}} />
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 map: Record<SaveState, { label: string; color: "default" | "warning" | "success" | "error" }> = {
|
|
idle: { label: "", color: "default" },
|
|
unsaved: { label: "Unsaved", color: "warning" },
|
|
saving: { label: "Saving…", color: "warning" },
|
|
saved: { label: "Saved", color: "success" },
|
|
error: { label: "Save failed", color: "error" },
|
|
};
|
|
const m = map[state];
|
|
if (!m.label) return null;
|
|
return (
|
|
<Stack spacing={0.5} alignItems="flex-end" aria-live="polite">
|
|
<Chip size="small" label={m.label} color={m.color} variant="outlined" />
|
|
{(state === "unsaved" || state === "error") ? (
|
|
<Button size="small" disabled={!canRetry} onClick={onRetry}>Save now</Button>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// ---------- Content tab ----------
|
|
|
|
function ContentTab({ settings, update, outline }: {
|
|
settings: CvVariantSettings;
|
|
update: (p: Partial<CvVariantSettings>) => void;
|
|
outline: CvOutline | null;
|
|
}) {
|
|
const { confirmAction } = useDialogActions();
|
|
// Full section list = configured order (once touched) else default, always including every known key.
|
|
const sectionRows: CvSectionSetting[] = useMemo(() => {
|
|
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
|
|
const have = new Set(base.map((s) => s.key));
|
|
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ 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;
|
|
}, [settings.customSections, settings.sections]);
|
|
|
|
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
|
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
|
|
|
|
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 orderedCustomSections = useMemo(() => {
|
|
const rank = new Map(sectionRows.map((section, index) => [section.key, index]));
|
|
return [...settings.customSections].sort((a, b) =>
|
|
(rank.get(`custom:${a.key}`) ?? Number.MAX_SAFE_INTEGER) - (rank.get(`custom:${b.key}`) ?? Number.MAX_SAFE_INTEGER));
|
|
}, [sectionRows, settings.customSections]);
|
|
|
|
const addCustom = () => {
|
|
const key = `c${Date.now().toString(36)}`;
|
|
update({
|
|
customSections: [...settings.customSections, { key, title: "New section", items: [] }],
|
|
sections: [...sectionRows, { key: `custom:${key}` }],
|
|
});
|
|
};
|
|
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(`Delete the custom section "${section.title || "Untitled"}" and all of its entries?`, {
|
|
title: "Delete custom section",
|
|
confirmLabel: "Delete section",
|
|
destructive: true,
|
|
}))) return;
|
|
update({
|
|
customSections: settings.customSections.filter((c) => c.key !== section.key),
|
|
sections: sectionRows.filter((row) => row.key !== `custom:${section.key}`),
|
|
});
|
|
};
|
|
const updateCustomItem = (key: string, index: number, value: string) => {
|
|
const section = settings.customSections.find((item) => item.key === key);
|
|
if (!section) return;
|
|
updateCustom(key, { items: section.items.map((item, itemIndex) => itemIndex === index ? value : item) });
|
|
};
|
|
const removeCustomItem = async (section: CvCustomSectionSetting, index: number) => {
|
|
const value = section.items[index];
|
|
if (value.trim() && !(await confirmAction("Delete this custom section entry?", {
|
|
title: "Delete custom entry",
|
|
confirmLabel: "Delete entry",
|
|
destructive: true,
|
|
}))) return;
|
|
updateCustom(section.key, { items: section.items.filter((_, itemIndex) => itemIndex !== index) });
|
|
};
|
|
|
|
return (
|
|
<Stack spacing={2}>
|
|
<TextField label="Headline override" size="small" fullWidth value={settings.headline ?? ""}
|
|
onChange={(e) => update({ headline: e.target.value || null })}
|
|
helperText="Blank uses the headline from your master profile." />
|
|
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>Sections</Typography>
|
|
<Typography variant="caption" color="text.secondary">Drag to reorder, or use the arrows. Content comes from your master profile.</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}
|
|
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 });
|
|
}}
|
|
onUpdateSettings={update}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Custom sections</Typography>
|
|
<Button size="small" startIcon={<AddIcon />} onClick={addCustom}>Add</Button>
|
|
</Stack>
|
|
{settings.customSections.length === 0 && (
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
|
|
Add sections unique to this CV (e.g. a portfolio note) without changing your master profile.
|
|
</Typography>
|
|
)}
|
|
{settings.customSections.length > 0 && (
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
|
|
Reorder and show or hide custom sections in the section list above.
|
|
</Typography>
|
|
)}
|
|
<Stack spacing={1} sx={{ mt: 1 }}>
|
|
{orderedCustomSections.map((c) => (
|
|
<Paper key={c.key} variant="outlined" sx={{ p: 1 }}>
|
|
<Stack direction="row" alignItems="center" spacing={1}>
|
|
<TextField variant="standard" fullWidth value={c.title ?? ""} placeholder="Section title"
|
|
error={!c.title?.trim()} helperText={!c.title?.trim() ? "Enter a section title." : undefined}
|
|
onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} />
|
|
<IconButton size="small" aria-label="Remove custom section" onClick={() => void removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
|
</Stack>
|
|
<Stack spacing={1} sx={{ mt: 1 }}>
|
|
{c.items.map((item, itemIndex) => (
|
|
<Stack key={`${c.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
|
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item}
|
|
error={!item.trim()} helperText={!item.trim() ? "Enter content or delete this entry." : undefined}
|
|
onChange={(event) => updateCustomItem(c.key, itemIndex, event.target.value)} />
|
|
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0}
|
|
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
|
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1}
|
|
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
|
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
|
</Stack>
|
|
))}
|
|
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button>
|
|
</Stack>
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function SectionRow({
|
|
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateSettings,
|
|
}: {
|
|
row: CvSectionSetting;
|
|
index: number;
|
|
total: number;
|
|
dragProps: React.HTMLAttributes<HTMLDivElement> & { draggable: boolean };
|
|
dragging: boolean;
|
|
over: boolean;
|
|
outlineSection?: CvOutlineSection;
|
|
customSection?: CvCustomSectionSetting;
|
|
settings: CvVariantSettings;
|
|
onMove: (delta: number) => void;
|
|
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
|
onRenameCustom: (title: string) => void;
|
|
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0;
|
|
const sectionLabel = customSection?.title ?? row.title ?? SECTION_LABELS[row.key] ?? row.key;
|
|
|
|
return (
|
|
<Paper
|
|
variant="outlined"
|
|
{...dragProps}
|
|
sx={{
|
|
p: 0.5, opacity: dragging ? 0.4 : row.hidden ? 0.5 : 1,
|
|
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
|
|
transition: "opacity 150ms ease, border-color 120ms ease", cursor: "grab",
|
|
}}
|
|
>
|
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
|
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled" }} aria-hidden />
|
|
<Stack>
|
|
<IconButton size="small" aria-label={`Move ${sectionLabel} section up`} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
|
<IconButton size="small" aria-label={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
|
</Stack>
|
|
<TextField variant="standard" fullWidth value={sectionLabel}
|
|
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
|
|
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
|
|
{customSection && <Chip size="small" label="Custom" variant="outlined" sx={{ height: 20 }} />}
|
|
{editable && (
|
|
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel} entries`} aria-expanded={expanded} onClick={() => setExpanded((e) => !e)}
|
|
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
|
<ExpandMoreIcon fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
<IconButton size="small" aria-label={`${row.hidden ? "Show" : "Hide"} ${sectionLabel} section`} onClick={() => onPatch({ hidden: !row.hidden })}>
|
|
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
|
</IconButton>
|
|
</Stack>
|
|
{editable && (
|
|
<Collapse in={expanded} unmountOnExit>
|
|
<Box sx={{ pl: 3, pt: 1 }}>
|
|
<EntryEditor section={outlineSection!} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
|
</Box>
|
|
</Collapse>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|
section: CvOutlineSection;
|
|
row: CvSectionSetting;
|
|
settings: CvVariantSettings;
|
|
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
|
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
|
}) {
|
|
// Entry order: itemOrder if set, else master order; always covering every entry key.
|
|
const orderedKeys: string[] = useMemo(() => {
|
|
const master = section.entries.map((e) => e.key ?? "").filter(Boolean);
|
|
if (!row.itemOrder?.length) return master;
|
|
const set = new Set(row.itemOrder);
|
|
return [...row.itemOrder.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.entries]);
|
|
|
|
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 });
|
|
};
|
|
|
|
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 || entry.title || "Untitled";
|
|
return (
|
|
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
|
|
sx={{
|
|
p: 0.75, opacity: drag.dragIndex === i ? 0.4 : hidden ? 0.5 : 1, cursor: "grab",
|
|
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}>
|
|
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
|
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{ov.title || entry.title || "Untitled"}</Typography>
|
|
<IconButton size="small" aria-label={`Move ${entryLabel} entry up`} disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
|
<IconButton size="small" aria-label={`Move ${entryLabel} entry down`} disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
|
<IconButton size="small" aria-label={`${hidden ? "Show" : "Hide"} ${entryLabel} entry`} onClick={() => setOverride(key, { hidden: !hidden })}>
|
|
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
|
</IconButton>
|
|
</Stack>
|
|
{!hidden && (
|
|
<Stack spacing={0.75} sx={{ mt: 0.75 }}>
|
|
<TextField size="small" fullWidth label="Title" value={ov.title ?? entry.title ?? ""}
|
|
onChange={(e) => setOverride(key, { title: e.target.value })} />
|
|
<TextField size="small" fullWidth label="Subtitle" value={ov.subtitle ?? entry.subtitle ?? ""}
|
|
onChange={(e) => setOverride(key, { subtitle: e.target.value })} />
|
|
<RichTextField label="Bullet points (one per line)" minRows={2}
|
|
value={(ov.bullets ?? entry.bullets).join("\n")}
|
|
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
|
{ov.bullets && (
|
|
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>Reset to master bullets</Button>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
</Paper>
|
|
);
|
|
})}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// ---------- Customize tab ----------
|
|
|
|
function CustomizeTab({ settings, update, themes }: {
|
|
settings: CvVariantSettings;
|
|
update: (p: Partial<CvVariantSettings>) => void;
|
|
themes: CvTheme[];
|
|
}) {
|
|
return (
|
|
<Stack spacing={2}>
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</Typography>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
{themes.map((t) => {
|
|
const active = t.id === settings.themeId;
|
|
const locked = t.available === false;
|
|
return (
|
|
<Paper key={t.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
|
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
|
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }}
|
|
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
|
|
<Stack direction="row" spacing={0.5} sx={{ mb: 0.5 }}>
|
|
{t.swatches.map((s, i) => <Box key={i} sx={{ width: 14, height: 14, borderRadius: "3px", bgcolor: s, border: "1px solid rgba(0,0,0,0.1)" }} />)}
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
|
{t.atsFriendly && <Chip size="small" label="ATS-friendly" color="success" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
|
{t.requiresPro && <Chip size="small" label={locked ? "Pro" : "Pro unlocked"} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
<Typography variant="body2" sx={{ flex: 1 }}>Accent colour</Typography>
|
|
<input type="color" aria-label="Accent colour" value={settings.accentColor ?? "#2563eb"} onChange={(e) => update({ accentColor: e.target.value })} />
|
|
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Reset</Button>}
|
|
</Stack>
|
|
|
|
<FormControl size="small" fullWidth>
|
|
<InputLabel>Heading font</InputLabel>
|
|
<Select label="Heading font" value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}>
|
|
<MenuItem value="">Theme default</MenuItem>
|
|
{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl size="small" fullWidth>
|
|
<InputLabel>Body font</InputLabel>
|
|
<Select label="Body font" value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}>
|
|
<MenuItem value="">Theme default</MenuItem>
|
|
{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<FormControl size="small" fullWidth>
|
|
<InputLabel>Density</InputLabel>
|
|
<Select label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}>
|
|
<MenuItem value="compact">Compact</MenuItem>
|
|
<MenuItem value="balanced">Balanced</MenuItem>
|
|
<MenuItem value="roomy">Roomy</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl size="small" fullWidth>
|
|
<InputLabel>Page size</InputLabel>
|
|
<Select label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}>
|
|
<MenuItem value="a4">A4</MenuItem>
|
|
<MenuItem value="letter">Letter</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Divider />
|
|
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
|
|
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function AiToolsTab() {
|
|
const { toast } = useToast();
|
|
const { canUseAi } = useAccountPlan();
|
|
const [text, setText] = useState("");
|
|
const [role, setRole] = useState("");
|
|
const [result, setResult] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const run = async (action: string) => {
|
|
if (!canUseAi) return;
|
|
if (!text.trim()) {
|
|
toast("Paste some text to work on first.", "info");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const res = await cvBuilderApi.aiAssist({ action, text, role: role || undefined });
|
|
setResult(res.result);
|
|
} catch (err) {
|
|
toast(getApiErrorMessage(err, "AI request failed."), "error");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack spacing={1.5}>
|
|
{canUseAi ? (
|
|
<Alert severity="info" sx={{ py: 0.5 }}>AI suggestions never change your profile automatically. Copy what you like back into your CV.</Alert>
|
|
) : (
|
|
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine CV wording with Pro.">
|
|
Generate optional writing suggestions while keeping all CV content editable on Free.
|
|
</ProFeatureNotice>
|
|
)}
|
|
<TextField label="Text to improve" multiline minRows={4} fullWidth size="small" value={text} onChange={(e) => setText(e.target.value)}
|
|
placeholder="Paste a summary, a bullet, or a whole section…" />
|
|
<TextField label="Target role (optional)" size="small" fullWidth value={role} onChange={(e) => setRole(e.target.value)} />
|
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
|
{AI_ACTIONS.map((a) => (
|
|
<Button key={a.key} size="small" variant="outlined" disabled={busy || !canUseAi} startIcon={<AutoFixHighIcon />} onClick={() => run(a.key)}>{canUseAi ? a.label : "Pro required"}</Button>
|
|
))}
|
|
</Box>
|
|
{result && (
|
|
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Suggestion</Typography>
|
|
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{result}</Typography>
|
|
</Paper>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) {
|
|
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">No saved versions yet. Edits autosave and appear here.</Typography>;
|
|
return (
|
|
<Stack spacing={0.5}>
|
|
<Typography variant="caption" color="text.secondary">{versions.length} saved version{versions.length === 1 ? "" : "s"}. Restoring keeps history — it adds a new version.</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 }}>
|
|
Version {v.version} {v.isCurrent && <Chip size="small" label="current" sx={{ ml: 0.5, height: 18 }} />}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc)} · {v.source}</Typography>
|
|
</Box>
|
|
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>Restore</Button>}
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function relTime(iso: string): string {
|
|
const then = new Date(iso).getTime();
|
|
const diff = Date.now() - then;
|
|
const mins = Math.round(diff / 60000);
|
|
if (mins < 1) return "just now";
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hrs = Math.round(mins / 60);
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
return new Date(iso).toLocaleDateString();
|
|
}
|