|
|
|
@@ -1,10 +1,11 @@
|
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import { useBlocker, useNavigate, useParams } from "react-router-dom";
|
|
|
|
|
import { Link as RouterLink, 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 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";
|
|
|
|
@@ -20,6 +21,8 @@ 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 UndoIcon from "@mui/icons-material/Undo";
|
|
|
|
|
import RedoIcon from "@mui/icons-material/Redo";
|
|
|
|
|
|
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
|
|
|
import { useToast } from "../toast";
|
|
|
|
@@ -46,12 +49,19 @@ const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "
|
|
|
|
|
const MIN_PREVIEW_ZOOM = 0.32;
|
|
|
|
|
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
|
|
|
|
|
|
|
|
|
function previewPageHtml(html: string, pageIndex: number, pageHeightPx: number): string {
|
|
|
|
|
const offset = pageIndex * pageHeightPx;
|
|
|
|
|
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:#fff!important;}body{transform:translateY(-${offset}px);transform-origin:top left;}</style>`;
|
|
|
|
|
return html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function CvBuilderEditor() {
|
|
|
|
|
const { id } = useParams();
|
|
|
|
|
const variantId = Number(id);
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
const { confirmAction } = useDialogActions();
|
|
|
|
|
const compactEditor = useMediaQuery("(max-width:899.95px)");
|
|
|
|
|
|
|
|
|
|
const [name, setName] = useState("");
|
|
|
|
|
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
|
|
|
|
@@ -65,7 +75,6 @@ export default function CvBuilderEditor() {
|
|
|
|
|
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);
|
|
|
|
@@ -74,6 +83,8 @@ export default function CvBuilderEditor() {
|
|
|
|
|
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);
|
|
|
|
@@ -85,6 +96,8 @@ export default function CvBuilderEditor() {
|
|
|
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const blockerPromptOpen = useRef(false);
|
|
|
|
|
const undoStack = useRef<CvVariantSettings[]>([]);
|
|
|
|
|
const redoStack = useRef<CvVariantSettings[]>([]);
|
|
|
|
|
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
@@ -120,6 +133,9 @@ export default function CvBuilderEditor() {
|
|
|
|
|
setIsPublic(variant.isPublic);
|
|
|
|
|
setPublicSlug(variant.publicSlug);
|
|
|
|
|
setSaveState("saved");
|
|
|
|
|
undoStack.current = [];
|
|
|
|
|
redoStack.current = [];
|
|
|
|
|
setHistoryRevision((value) => value + 1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Debounced live preview.
|
|
|
|
@@ -189,6 +205,31 @@ export default function CvBuilderEditor() {
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
@@ -215,7 +256,6 @@ export default function CvBuilderEditor() {
|
|
|
|
|
confirmLabel: "Discard and leave",
|
|
|
|
|
destructive: true,
|
|
|
|
|
}).then((confirmed) => {
|
|
|
|
|
blockerPromptOpen.current = false;
|
|
|
|
|
if (confirmed) blocker.proceed();
|
|
|
|
|
else blocker.reset();
|
|
|
|
|
});
|
|
|
|
@@ -279,6 +319,20 @@ export default function CvBuilderEditor() {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const duplicateVariant = async () => {
|
|
|
|
|
if (hasUnsavedChanges && !(await retrySave())) {
|
|
|
|
|
toast("Save the current CV before duplicating it.", "error");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const copy = await cvBuilderApi.duplicate(variantId, `${name || "Untitled CV"} copy`);
|
|
|
|
|
navigate(`/career/builder/${copy.id}`);
|
|
|
|
|
toast("CV duplicated. You are editing the copy.", "success");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast(getApiErrorMessage(err, "Could not duplicate this CV."), "error");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadVersions = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setVersions(await cvBuilderApi.versions(variantId));
|
|
|
|
@@ -316,7 +370,6 @@ export default function CvBuilderEditor() {
|
|
|
|
|
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;
|
|
|
|
@@ -331,7 +384,8 @@ export default function CvBuilderEditor() {
|
|
|
|
|
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 target = scrollRef.current?.querySelector<HTMLElement>(`[data-cv-page="${clamped}"]`);
|
|
|
|
|
if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fitPreview = () => {
|
|
|
|
@@ -349,38 +403,57 @@ export default function CvBuilderEditor() {
|
|
|
|
|
}
|
|
|
|
|
if (!settings) return <EditorSkeleton />;
|
|
|
|
|
|
|
|
|
|
const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
|
|
|
|
|
const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
|
|
|
|
|
|
|
|
|
|
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 }}>
|
|
|
|
|
<Stack spacing={1.5} sx={{ minWidth: 0 }}>
|
|
|
|
|
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
|
|
|
|
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
|
|
|
|
|
<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)}
|
|
|
|
|
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)}
|
|
|
|
|
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined}
|
|
|
|
|
sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }}
|
|
|
|
|
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>
|
|
|
|
|
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} />
|
|
|
|
|
<Tooltip title="Undo"><span><IconButton size="small" aria-label="Undo" disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
|
|
|
|
<Tooltip title="Redo"><span><IconButton size="small" aria-label="Redo" disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
|
|
|
|
{compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}>
|
|
|
|
|
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>Edit</Button>
|
|
|
|
|
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>Preview</Button>
|
|
|
|
|
</Stack>}
|
|
|
|
|
<Box sx={{ flex: { sm: 1 } }} />
|
|
|
|
|
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>Duplicate</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>}
|
|
|
|
|
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Download PDF"}</Button>
|
|
|
|
|
</Stack>
|
|
|
|
|
</Paper>
|
|
|
|
|
|
|
|
|
|
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5 }}>
|
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
|
|
|
|
<Paper sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, border: "1px solid", borderColor: "divider" }}>
|
|
|
|
|
|
|
|
|
|
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 5) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
|
|
|
|
<Tab label="Content" />
|
|
|
|
|
<Tab label="Customize" />
|
|
|
|
|
<Tab label="AI Tools" />
|
|
|
|
|
<Tab label="Template" />
|
|
|
|
|
<Tab label="Design" />
|
|
|
|
|
<Tab label="Layout" />
|
|
|
|
|
<Tab label="AI" />
|
|
|
|
|
<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} />}
|
|
|
|
|
{tab === 1 && <CustomizeTab mode="template" settings={settings} update={update} themes={themes} />}
|
|
|
|
|
{tab === 2 && <CustomizeTab mode="design" settings={settings} update={update} themes={themes} />}
|
|
|
|
|
{tab === 3 && <CustomizeTab mode="layout" settings={settings} update={update} themes={themes} />}
|
|
|
|
|
{tab === 4 && <AiToolsTab />}
|
|
|
|
|
{tab === 5 && <HistoryTab versions={versions} onRestore={restore} />}
|
|
|
|
|
</Paper>
|
|
|
|
|
|
|
|
|
|
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
|
|
|
|
<Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, 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" />}
|
|
|
|
@@ -402,30 +475,27 @@ export default function CvBuilderEditor() {
|
|
|
|
|
</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}
|
|
|
|
|
sandbox="allow-same-origin"
|
|
|
|
|
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",
|
|
|
|
|
}} />
|
|
|
|
|
<iframe ref={iframeRef} title="CV preview" srcDoc={html} sandbox="allow-same-origin" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
|
|
|
|
|
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}>
|
|
|
|
|
<Stack spacing={3} alignItems="center">
|
|
|
|
|
{Array.from({ length: pages }).map((_, index) => (
|
|
|
|
|
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
|
|
|
|
|
<Typography variant="caption" sx={{ display: "block", mb: 0.75, color: "#475569", fontWeight: 700 }}>Page {index + 1}</Typography>
|
|
|
|
|
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}>
|
|
|
|
|
<iframe
|
|
|
|
|
title={`CV preview page ${index + 1}`}
|
|
|
|
|
srcDoc={previewPageHtml(html, index, pageMetrics.heightPx)}
|
|
|
|
|
sandbox="allow-same-origin"
|
|
|
|
|
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
))}
|
|
|
|
|
</Box>
|
|
|
|
|
</Stack>
|
|
|
|
|
</Box>
|
|
|
|
|
</Paper>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
</Stack>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -478,6 +548,10 @@ function ContentTab({ settings, update, outline }: {
|
|
|
|
|
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 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)) {
|
|
|
|
@@ -486,7 +560,7 @@ function ContentTab({ settings, update, outline }: {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return base;
|
|
|
|
|
}, [settings.customSections, settings.sections]);
|
|
|
|
|
}, [outline?.sections, settings.customSections, settings.sections]);
|
|
|
|
|
|
|
|
|
|
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
|
|
|
|
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
|
|
|
|
@@ -547,6 +621,9 @@ function ContentTab({ settings, update, outline }: {
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Stack spacing={2}>
|
|
|
|
|
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">Edit master profile</Button>}>
|
|
|
|
|
Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.
|
|
|
|
|
</Alert>
|
|
|
|
|
<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." />
|
|
|
|
@@ -763,15 +840,23 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|
|
|
|
|
|
|
|
|
// ---------- Customize tab ----------
|
|
|
|
|
|
|
|
|
|
function CustomizeTab({ settings, update, themes }: {
|
|
|
|
|
function CustomizeTab({ mode, settings, update, themes }: {
|
|
|
|
|
mode: "template" | "design" | "layout";
|
|
|
|
|
settings: CvVariantSettings;
|
|
|
|
|
update: (p: Partial<CvVariantSettings>) => void;
|
|
|
|
|
themes: CvTheme[];
|
|
|
|
|
}) {
|
|
|
|
|
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
|
|
|
|
const toggleSidebarSection = (key: string) => update({
|
|
|
|
|
sidebarSections: sidebarSections.includes(key)
|
|
|
|
|
? sidebarSections.filter((item) => item !== key)
|
|
|
|
|
: [...sidebarSections, key],
|
|
|
|
|
});
|
|
|
|
|
return (
|
|
|
|
|
<Stack spacing={2}>
|
|
|
|
|
<Box>
|
|
|
|
|
{mode === "template" && <Box>
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</Typography>
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>Templates change presentation only. Your content and hidden-section choices stay intact.</Typography>
|
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
|
|
|
{themes.map((t) => {
|
|
|
|
|
const active = t.id === settings.themeId;
|
|
|
|
@@ -781,9 +866,11 @@ function CustomizeTab({ settings, update, themes }: {
|
|
|
|
|
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>
|
|
|
|
|
<Box sx={{ height: 86, mb: 1, bgcolor: t.swatches[2] || "#fff", border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden", display: "grid", gridTemplateColumns: t.layout.startsWith("sidebar") ? (t.layout === "sidebar-right" ? "1fr 30%" : "30% 1fr") : "1fr" }}>
|
|
|
|
|
{t.layout.startsWith("sidebar") && t.layout !== "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}><Box sx={{ width: 18, height: 18, borderRadius: t.photoShape === "circle" ? "50%" : 0.5, bgcolor: "rgba(255,255,255,.75)", mb: 0.75 }} />{[50, 72, 58, 68].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(255,255,255,.6)", mb: 0.5 }} />)}</Box> : null}
|
|
|
|
|
<Box sx={{ p: 0.9 }}><Box sx={{ width: "55%", height: 5, bgcolor: t.swatches[0], mb: 0.75 }} />{[92, 74, 84, 64, 88, 78].map((w, index) => <Box key={index} sx={{ width: `${w}%`, height: index % 3 === 0 ? 3 : 2, bgcolor: index % 3 === 0 ? t.swatches[0] : "rgba(71,85,105,.28)", mb: 0.65 }} />)}</Box>
|
|
|
|
|
{t.layout === "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}>{[64, 78, 52, 70, 58].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(30,41,59,.34)", mb: 0.6 }} />)}</Box> : null}
|
|
|
|
|
</Box>
|
|
|
|
|
<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 }}>
|
|
|
|
@@ -794,52 +881,62 @@ function CustomizeTab({ settings, update, themes }: {
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Box>
|
|
|
|
|
</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>
|
|
|
|
|
{mode === "design" && <>
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Colour</Typography>
|
|
|
|
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
|
|
|
|
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
|
|
|
|
<IconButton key={color} aria-label={`Use accent ${color}`} onClick={() => update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
|
|
|
|
|
))}
|
|
|
|
|
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", border: "1px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden" }}>
|
|
|
|
|
<input type="color" aria-label="Custom accent colour" value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ width: 48, height: 48, border: 0, padding: 0, cursor: "pointer" }} />
|
|
|
|
|
</Box>
|
|
|
|
|
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Theme default</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>
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>Typography</Typography>
|
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
|
|
|
<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>
|
|
|
|
|
</Box>
|
|
|
|
|
<ControlSlider label="Body size" value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
|
|
|
|
<ControlSlider label="Heading size" value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
|
|
|
|
<ControlSlider label="Line height" value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} />
|
|
|
|
|
<FormControl size="small" fullWidth><InputLabel>Heading treatment</InputLabel><Select label="Heading treatment" value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="caps-rule">Uppercase divider</MenuItem><MenuItem value="underline">Underline</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="bar">Accent bar</MenuItem></Select></FormControl>
|
|
|
|
|
<FormControl size="small" fullWidth><InputLabel>Header treatment</InputLabel><Select label="Header treatment" value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="band">Colour band</MenuItem><MenuItem value="centered">Centred</MenuItem><MenuItem value="kicker">Editorial</MenuItem></Select></FormControl>
|
|
|
|
|
<FormControl size="small" fullWidth><InputLabel>Skills presentation</InputLabel><Select inputProps={{ "aria-label": "Skills presentation" }} label="Skills presentation" value={settings.skillsStyle ?? "tags"} onChange={(e) => update({ skillsStyle: e.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">Tags</MenuItem><MenuItem value="text">Simple text</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)" />
|
|
|
|
|
{mode === "layout" && <>
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Document</Typography>
|
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
|
|
|
<FormControl size="small" fullWidth><InputLabel>Page size</InputLabel><Select inputProps={{ "aria-label": "Page size" }} label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
|
|
|
|
<FormControl size="small" fullWidth><InputLabel>Density</InputLabel><Select inputProps={{ "aria-label": "Density" }} 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>Language</InputLabel><Select inputProps={{ "aria-label": "Language" }} label="Language" value={settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="no">Norwegian</MenuItem></Select></FormControl>
|
|
|
|
|
<FormControl size="small" fullWidth><InputLabel>Date format</InputLabel><Select inputProps={{ "aria-label": "Date format" }} label="Date format" value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
|
|
|
|
</Box>
|
|
|
|
|
<FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>
|
|
|
|
|
<ControlSlider label="Page margins" value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
|
|
|
|
|
<ControlSlider label="Section spacing" value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
|
|
|
|
|
<ControlSlider label="Entry spacing" value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
|
|
|
|
{(settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
|
|
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Sidebar content</Typography>
|
|
|
|
|
<ControlSlider label="Sidebar width" value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
|
|
|
|
|
<Stack>{["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={SECTION_LABELS[key] ?? "Contact details"} />)}</Stack>
|
|
|
|
|
</Paper>}
|
|
|
|
|
<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 templates)" />
|
|
|
|
|
</>}
|
|
|
|
|
</Stack>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ControlSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
|
|
|
|
|
return <Box><Stack direction="row" justifyContent="space-between" alignItems="baseline"><Typography variant="body2">{label}</Typography><Typography variant="caption" color="text.secondary">{Number(value.toFixed(2))}{suffix}</Typography></Stack><Slider size="small" aria-label={label} value={value} min={min} max={max} step={step} onChange={(_, next) => onChange(next as number)} /></Box>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AiToolsTab() {
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
const { canUseAi } = useAccountPlan();
|
|
|
|
@@ -883,13 +980,21 @@ function AiToolsTab() {
|
|
|
|
|
))}
|
|
|
|
|
</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 spacing={1}>
|
|
|
|
|
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
|
|
|
|
<Typography variant="overline" color="text.secondary">Original</Typography>
|
|
|
|
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
|
|
|
|
</Paper>
|
|
|
|
|
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
|
|
|
|
|
<Typography variant="overline" color="primary.main">Suggested — review before using</Typography>
|
|
|
|
|
<TextField aria-label="Editable AI suggestion" multiline minRows={4} fullWidth variant="standard" value={result} onChange={(event) => setResult(event.target.value)} />
|
|
|
|
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
|
|
|
|
<Button size="small" variant="contained" onClick={() => { setText(result); setResult(""); toast("Suggestion accepted into the working text.", "success"); }}>Accept</Button>
|
|
|
|
|
<Button size="small" onClick={() => setResult("")}>Reject</Button>
|
|
|
|
|
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
|
|
|
|
|
</Stack>
|
|
|
|
|
</Paper>
|
|
|
|
|
</Stack>
|
|
|
|
|
)}
|
|
|
|
|
</Stack>
|
|
|
|
|
);
|
|
|
|
|