|
|
|
@@ -15,7 +15,6 @@ 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";
|
|
|
|
@@ -27,14 +26,14 @@ import RedoIcon from "@mui/icons-material/Redo";
|
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
|
|
|
import { useToast } from "../toast";
|
|
|
|
|
import RichTextField from "../components/RichTextField";
|
|
|
|
|
import AiSectionAssistant from "../components/cv/AiSectionAssistant";
|
|
|
|
|
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
|
|
|
|
import { useDragReorder } from "../hooks/useDragReorder";
|
|
|
|
|
import {
|
|
|
|
|
AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
|
|
|
|
CUSTOM_SECTION_PRESETS, 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 = [
|
|
|
|
@@ -49,10 +48,15 @@ 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}`;
|
|
|
|
|
function previewDocumentHtml(html: string, pageHeightPx: number, pageIndex = 0): string {
|
|
|
|
|
const height = Math.max(1, Math.round(pageHeightPx * 100) / 100);
|
|
|
|
|
const offset = Math.max(0, pageIndex) * height;
|
|
|
|
|
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:#fff!important;}.page{transform-origin:top left;}</style>`;
|
|
|
|
|
// Screen media does not apply Chromium's paged-media fragmentation. Reproduce the important
|
|
|
|
|
// keep-together decisions before each preview frame crops the selected physical page.
|
|
|
|
|
const previewScript = `<script data-cv-preview-pagination>(()=>{const H=${height};const OFFSET=${offset};const page=document.querySelector('.page');if(!page)return;const topOf=(el)=>{let top=0,node=el;while(node&&node!==document.body){top+=node.offsetTop||0;node=node.offsetParent;}return top;};const paginate=()=>{document.querySelectorAll('[data-cv-preview-pad]').forEach((el)=>{el.style.paddingTop='';el.removeAttribute('data-cv-preview-pad');});const pushToNextPage=(el,heightOverride)=>{const current=parseFloat(getComputedStyle(el).paddingTop)||0;const top=topOf(el)+current;const blockHeight=heightOverride||Math.max(0,el.offsetHeight-current);if(blockHeight<=0||blockHeight>H-2)return;const remaining=H-(top%H);if(blockHeight>remaining+1){el.style.paddingTop=(current+remaining)+'px';el.setAttribute('data-cv-preview-pad','true');}};document.querySelectorAll('.section').forEach((section)=>{if(!section.classList.contains('section-flow')){pushToNextPage(section);return;}const title=section.querySelector(':scope > .section-title');const first=title&&title.nextElementSibling;if(title&&first){const groupedBottom=topOf(first)+first.offsetHeight;pushToNextPage(section,groupedBottom-topOf(section));}section.querySelectorAll(':scope > .entry:not(.entry-flow),:scope > .skill-groups > .skill-group,:scope > .bullets > li:not(.item-flow),:scope > .entry-flow .bullets > li:not(.item-flow)').forEach((block)=>pushToNextPage(block));});page.style.transform='translateY(-'+OFFSET+'px)';};paginate();requestAnimationFrame(()=>requestAnimationFrame(paginate));})();</script>`;
|
|
|
|
|
const withCss = html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
|
|
|
|
|
return withCss.includes("</body>") ? withCss.replace("</body>", `${previewScript}</body>`) : `${withCss}${previewScript}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function CvBuilderEditor() {
|
|
|
|
@@ -362,23 +366,28 @@ export default function CvBuilderEditor() {
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
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 win = iframeRef.current?.contentWindow;
|
|
|
|
|
const measure = () => {
|
|
|
|
|
try {
|
|
|
|
|
const doc = iframeRef.current?.contentDocument;
|
|
|
|
|
const h = Math.max(
|
|
|
|
|
pageMetrics.heightPx,
|
|
|
|
|
doc?.body?.scrollHeight ?? 0,
|
|
|
|
|
doc?.documentElement?.scrollHeight ?? 0,
|
|
|
|
|
);
|
|
|
|
|
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
|
|
|
|
|
setPages(pageCount);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if (win) win.requestAnimationFrame(() => win.requestAnimationFrame(measure));
|
|
|
|
|
else measure();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const goToPage = (p: number) => {
|
|
|
|
@@ -436,12 +445,11 @@ export default function CvBuilderEditor() {
|
|
|
|
|
<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 }}>
|
|
|
|
|
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
|
|
|
|
<Tab label="Content" />
|
|
|
|
|
<Tab label="Template" />
|
|
|
|
|
<Tab label="Design" />
|
|
|
|
|
<Tab label="Layout" />
|
|
|
|
|
<Tab label="AI" />
|
|
|
|
|
<Tab label="History" />
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
|
|
|
@@ -449,8 +457,7 @@ export default function CvBuilderEditor() {
|
|
|
|
|
{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} />}
|
|
|
|
|
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
|
|
|
|
</Paper>
|
|
|
|
|
|
|
|
|
|
<Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
|
|
|
@@ -475,7 +482,7 @@ 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>}
|
|
|
|
|
<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" }} />
|
|
|
|
|
<iframe ref={iframeRef} title="CV preview" 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`, 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) => (
|
|
|
|
@@ -484,8 +491,8 @@ export default function CvBuilderEditor() {
|
|
|
|
|
<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"
|
|
|
|
|
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>
|
|
|
|
@@ -543,6 +550,7 @@ function ContentTab({ settings, update, outline }: {
|
|
|
|
|
outline: CvOutline | null;
|
|
|
|
|
}) {
|
|
|
|
|
const { confirmAction } = useDialogActions();
|
|
|
|
|
const [sectionToAdd, setSectionToAdd] = useState("free-text");
|
|
|
|
|
// 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 }));
|
|
|
|
@@ -578,16 +586,18 @@ function ContentTab({ settings, update, outline }: {
|
|
|
|
|
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 preset = CUSTOM_SECTION_PRESETS.find((item) => item.key === sectionToAdd);
|
|
|
|
|
if (preset && settings.customSections.some((item) => item.presetKey === preset.key)) return;
|
|
|
|
|
const key = `c${Date.now().toString(36)}`;
|
|
|
|
|
update({
|
|
|
|
|
customSections: [...settings.customSections, { key, title: "New section", items: [] }],
|
|
|
|
|
customSections: [...settings.customSections, {
|
|
|
|
|
key,
|
|
|
|
|
title: preset?.title ?? "New section",
|
|
|
|
|
items: [],
|
|
|
|
|
presetKey: preset?.key ?? null,
|
|
|
|
|
contentType: preset?.contentType ?? "paragraphs",
|
|
|
|
|
}],
|
|
|
|
|
sections: [...sectionRows, { key: `custom:${key}` }],
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
@@ -604,21 +614,6 @@ function ContentTab({ settings, update, outline }: {
|
|
|
|
|
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}>
|
|
|
|
|
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">Edit master profile</Button>}>
|
|
|
|
@@ -650,61 +645,37 @@ function ContentTab({ settings, update, outline }: {
|
|
|
|
|
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}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</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>
|
|
|
|
|
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Add a section</Typography>
|
|
|
|
|
<Typography variant="caption" color="text.secondary">Preset sections use the same editable, reorderable card as every other section.</Typography>
|
|
|
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
|
|
|
|
<TextField select size="small" fullWidth label="Section type" value={sectionToAdd} onChange={(event) => setSectionToAdd(event.target.value)}>
|
|
|
|
|
<MenuItem value="free-text">Custom free-text section</MenuItem>
|
|
|
|
|
{CUSTOM_SECTION_PRESETS.map((preset) => <MenuItem key={preset.key} value={preset.key} disabled={settings.customSections.some((item) => item.presetKey === preset.key)}>{preset.title}</MenuItem>)}
|
|
|
|
|
</TextField>
|
|
|
|
|
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>Add section</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>
|
|
|
|
|
</Paper>
|
|
|
|
|
</Stack>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SectionRow({
|
|
|
|
|
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateSettings,
|
|
|
|
|
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
|
|
|
|
|
}: {
|
|
|
|
|
row: CvSectionSetting;
|
|
|
|
|
index: number;
|
|
|
|
@@ -718,24 +689,30 @@ function SectionRow({
|
|
|
|
|
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 { confirmAction } = useDialogActions();
|
|
|
|
|
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;
|
|
|
|
|
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}
|
|
|
|
|
sx={{
|
|
|
|
|
p: 0.5, opacity: dragging ? 0.4 : row.hidden ? 0.5 : 1,
|
|
|
|
|
opacity: dragging ? 0.4 : row.hidden ? 0.58 : 1,
|
|
|
|
|
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
|
|
|
|
|
transition: "opacity 150ms ease, border-color 120ms ease", cursor: "grab",
|
|
|
|
|
transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease",
|
|
|
|
|
overflow: "hidden", "&:hover": { boxShadow: 1 },
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
|
|
|
|
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled" }} aria-hidden />
|
|
|
|
|
<Stack direction="row" alignItems="center" gap={0.5} sx={{ px: 0.75, py: 0.5 }}>
|
|
|
|
|
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled", cursor: "grab" }} 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>
|
|
|
|
@@ -744,23 +721,48 @@ function SectionRow({
|
|
|
|
|
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>
|
|
|
|
|
<IconButton size="small" aria-label={`Remove ${sectionLabel} section from this CV`} onClick={() => {
|
|
|
|
|
if (customSection) onRemoveCustom();
|
|
|
|
|
else void confirmAction(`Remove ${sectionLabel} from this CV? Your master career data will be preserved.`, { title: "Remove section", confirmLabel: "Remove from CV", destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
|
|
|
|
|
}}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
|
|
|
|
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel}`} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}
|
|
|
|
|
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
|
|
|
|
<ExpandMoreIcon 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>
|
|
|
|
|
)}
|
|
|
|
|
<Collapse in={expanded} unmountOnExit>
|
|
|
|
|
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
|
|
|
|
{customSection ? (
|
|
|
|
|
<Stack spacing={1}>
|
|
|
|
|
<TextField select size="small" label="Content format" value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
|
|
|
|
<MenuItem value="paragraphs">Paragraphs</MenuItem><MenuItem value="bullets">Bullet list</MenuItem><MenuItem value="entries">Multiple entries</MenuItem>
|
|
|
|
|
</TextField>
|
|
|
|
|
{customSection.contentType === "entries" ? <Stack spacing={1}>
|
|
|
|
|
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
|
|
|
|
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
|
|
|
|
|
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
|
|
|
|
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === customSection.items.length - 1} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
|
|
|
|
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void confirmAction("Delete this custom section entry?", { title: "Delete custom entry", confirmLabel: "Delete entry", destructive: true }).then((confirmed) => { if (confirmed) onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); })}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
|
|
|
|
</Stack>)}
|
|
|
|
|
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>Add entry</Button>
|
|
|
|
|
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? "Paragraphs (separate with a blank line)" : "Bullet points (one per line)"} 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} text={customText} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
|
|
|
|
</Stack>
|
|
|
|
|
) : outlineSection?.kind === "entries" ? (
|
|
|
|
|
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
|
|
|
|
) : (
|
|
|
|
|
<Stack spacing={1}>
|
|
|
|
|
<RichTextField label={outlineSection?.kind === "tags" ? "Items (one per line)" : "Content (one item per line)"} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
|
|
|
|
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>Reset to master profile</Button>}
|
|
|
|
|
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
|
|
|
|
</Stack>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
</Collapse>
|
|
|
|
|
</Paper>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
@@ -826,6 +828,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|
|
|
|
<RichTextField label="Bullet points (one per line)" minRows={2}
|
|
|
|
|
value={(ov.bullets ?? entry.bullets).join("\n")}
|
|
|
|
|
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
|
|
|
|
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
|
|
|
|
{ov.bullets && (
|
|
|
|
|
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>Reset to master bullets</Button>
|
|
|
|
|
)}
|
|
|
|
@@ -846,6 +849,8 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
|
|
|
|
update: (p: Partial<CvVariantSettings>) => void;
|
|
|
|
|
themes: CvTheme[];
|
|
|
|
|
}) {
|
|
|
|
|
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
|
|
|
|
|
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
|
|
|
|
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
|
|
|
|
const toggleSidebarSection = (key: string) => update({
|
|
|
|
|
sidebarSections: sidebarSections.includes(key)
|
|
|
|
@@ -866,13 +871,10 @@ function CustomizeTab({ mode, 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 } }}>
|
|
|
|
|
<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>
|
|
|
|
|
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={t} height={110} /></Box>
|
|
|
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{t.description}</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 }} />}
|
|
|
|
@@ -884,50 +886,55 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
|
|
|
|
</Box>}
|
|
|
|
|
|
|
|
|
|
{mode === "design" && <>
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Colour</Typography>
|
|
|
|
|
{supports("accent") && <><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 component="label" sx={{ width: 34, height: 34, borderRadius: "50%", bgcolor: settings.accentColor ?? "#3157d5", border: "2px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden", position: "relative" }}>
|
|
|
|
|
<input type="color" aria-label="Custom accent colour" value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
|
|
|
|
|
</Box>
|
|
|
|
|
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Theme default</Button>}
|
|
|
|
|
</Stack>
|
|
|
|
|
</Stack></>}
|
|
|
|
|
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>Typography</Typography>
|
|
|
|
|
{supports("typography") && <><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>
|
|
|
|
|
<ControlSlider label="Line height" value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
|
|
|
|
|
{supports("heading") && <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>}
|
|
|
|
|
{supports("header") && <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>}
|
|
|
|
|
{supports("skills") && <><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">Compact text</MenuItem><MenuItem value="bullets">Bullet list</MenuItem><MenuItem value="grouped">Grouped skills</MenuItem></Select></FormControl>
|
|
|
|
|
{settings.skillsStyle === "grouped" && <TextField label="Skill groups" helperText="One group per line, for example: Backend: C#, .NET, Python" multiline minRows={3} value={(settings.skillGroups ?? []).map((group) => `${group.name}: ${group.items.join(", ")}`).join("\n")} onChange={(event) => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map((line) => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map((item) => item.trim()).filter(Boolean) }; }) })} />}</>}
|
|
|
|
|
</>}
|
|
|
|
|
|
|
|
|
|
{mode === "layout" && <>
|
|
|
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Document</Typography>
|
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
|
|
|
{supports("page") && <>
|
|
|
|
|
<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>
|
|
|
|
|
{supports("layout") && <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>}
|
|
|
|
|
{supports("spacing") && <>
|
|
|
|
|
<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 }}>
|
|
|
|
|
</>}
|
|
|
|
|
{supports("sidebar") && (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)" />
|
|
|
|
|
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />}
|
|
|
|
|
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons" />}
|
|
|
|
|
</>}
|
|
|
|
|
</Stack>
|
|
|
|
|
);
|
|
|
|
@@ -937,69 +944,6 @@ function ControlSlider({ label, value, min, max, step, suffix = "", onChange }:
|
|
|
|
|
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();
|
|
|
|
|
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 && (
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|