feat(cv): expand builder studio tools
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
|
||||
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, 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";
|
||||
@@ -22,11 +22,16 @@ 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 AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import TranslateIcon from "@mui/icons-material/Translate";
|
||||
import SpellcheckIcon from "@mui/icons-material/Spellcheck";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import RichTextField from "../components/RichTextField";
|
||||
import AiSectionAssistant from "../components/cv/AiSectionAssistant";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
||||
import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
@@ -453,17 +458,17 @@ export default function CvBuilderEditor() {
|
||||
<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 === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||
<Tab label={t("cvEditorContent")} />
|
||||
<Tab label={t("cvEditorTemplate")} />
|
||||
<Tab label={t("cvEditorDesign")} />
|
||||
<Tab label={t("cvEditorLayout")} />
|
||||
<Tab label={t("cvEditorContent")} />
|
||||
<Tab label={t("cvEditorCustomize")} />
|
||||
<Tab label={t("cvEditorAiTools")} />
|
||||
<Tab label={t("cvEditorHistory")} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
|
||||
{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 === 0 && <CustomizeTab mode="template" settings={settings} update={update} themes={themes} />}
|
||||
{tab === 1 && <ContentTab settings={settings} update={update} outline={outline} />}
|
||||
{tab === 2 && <CustomizeTab mode="customize" settings={settings} update={update} themes={themes} />}
|
||||
{tab === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
|
||||
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
</Paper>
|
||||
|
||||
@@ -550,6 +555,194 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Whole-document AI ----------
|
||||
|
||||
type DocumentAiBlock = {
|
||||
id: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null): DocumentAiBlock[] {
|
||||
const blocks: DocumentAiBlock[] = [];
|
||||
const configured = new Map(settings.sections.map((section) => [section.key, section]));
|
||||
if ((settings.headline ?? outline?.headline)?.trim()) {
|
||||
blocks.push({ id: "headline", items: [(settings.headline ?? outline?.headline ?? "").trim()] });
|
||||
}
|
||||
for (const section of outline?.sections ?? []) {
|
||||
const sectionSetting = configured.get(section.key);
|
||||
if (sectionSetting?.hidden) continue;
|
||||
const title = sectionSetting?.title ?? section.title;
|
||||
if (section.kind === "entries") {
|
||||
const orderedKeys = sectionSetting?.itemOrder?.length
|
||||
? [...sectionSetting.itemOrder, ...section.entries.map((entry, index) => entry.key ?? `${index}`).filter((key) => !sectionSetting.itemOrder?.includes(key))]
|
||||
: section.entries.map((entry, index) => entry.key ?? `${index}`);
|
||||
const byKey = new Map(section.entries.map((entry, index) => [entry.key ?? `${index}`, entry]));
|
||||
for (const key of orderedKeys) {
|
||||
const entry = byKey.get(key);
|
||||
if (!entry) continue;
|
||||
const override = settings.overrides[key] ?? {};
|
||||
if (override.hidden) continue;
|
||||
blocks.push({
|
||||
id: `entry:${section.key}:${key}`,
|
||||
title: override.title ?? entry.title,
|
||||
subtitle: override.subtitle ?? entry.subtitle,
|
||||
items: override.bullets ?? entry.bullets,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blocks.push({ id: `section:${section.key}`, title, items: sectionSetting?.items ?? (section.kind === "tags" ? section.tags : section.bullets) });
|
||||
}
|
||||
}
|
||||
for (const section of settings.customSections ?? []) {
|
||||
if (!section.hidden) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
|
||||
}
|
||||
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
|
||||
}
|
||||
|
||||
function parseDocumentAiBlocks(value: string, originals: DocumentAiBlock[]): DocumentAiBlock[] | null {
|
||||
try {
|
||||
const start = value.indexOf("[");
|
||||
const end = value.lastIndexOf("]");
|
||||
if (start < 0 || end <= start) return null;
|
||||
const parsed = JSON.parse(value.slice(start, end + 1));
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const originalIds = new Set(originals.map((block) => block.id));
|
||||
const result = parsed.filter((block): block is DocumentAiBlock => block && typeof block.id === "string" && originalIds.has(block.id)).map((block) => {
|
||||
const original = originals.find((item) => item.id === block.id);
|
||||
return {
|
||||
id: block.id,
|
||||
title: typeof block.title === "string" ? block.title : original?.title,
|
||||
subtitle: typeof block.subtitle === "string" ? block.subtitle : original?.subtitle,
|
||||
items: Array.isArray(block.items) ? block.items.filter((item): item is string => typeof item === "string") : original?.items,
|
||||
};
|
||||
});
|
||||
return result.length === originals.length && new Set(result.map((block) => block.id)).size === originals.length ? result : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDocumentAiBlocks(settings: CvVariantSettings, blocks: DocumentAiBlock[]): CvVariantSettings {
|
||||
const byId = new Map(blocks.map((block) => [block.id, block]));
|
||||
const headline = byId.get("headline")?.items?.[0];
|
||||
const sections = settings.sections.map((section) => {
|
||||
const block = byId.get(`section:${section.key}`);
|
||||
return block ? { ...section, title: block.title, items: block.items } : section;
|
||||
});
|
||||
const configuredKeys = new Set(sections.map((section) => section.key));
|
||||
for (const block of blocks) {
|
||||
if (!block.id.startsWith("section:")) continue;
|
||||
const key = block.id.slice("section:".length);
|
||||
if (!configuredKeys.has(key)) sections.push({ key, title: block.title, items: block.items });
|
||||
}
|
||||
const overrides = { ...settings.overrides };
|
||||
for (const block of blocks) {
|
||||
if (!block.id.startsWith("entry:")) continue;
|
||||
const key = block.id.split(":").slice(2).join(":");
|
||||
overrides[key] = { ...overrides[key], title: block.title, subtitle: block.subtitle, bullets: block.items };
|
||||
}
|
||||
const customSections = settings.customSections.map((section) => {
|
||||
const block = byId.get(`custom:${section.key}`);
|
||||
return block ? { ...section, title: block.title, items: block.items ?? [] } : section;
|
||||
});
|
||||
return { ...settings, ...(headline !== undefined ? { headline } : {}), sections, overrides, customSections };
|
||||
}
|
||||
|
||||
function DocumentAiTab({ settings, outline, update }: {
|
||||
settings: CvVariantSettings;
|
||||
outline: CvOutline | null;
|
||||
update: (patch: Partial<CvVariantSettings>) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const blocks = useMemo(() => documentAiBlocks(settings, outline), [settings, outline]);
|
||||
const [targetLanguage, setTargetLanguage] = useState(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [suggestion, setSuggestion] = useState<DocumentAiBlock[] | null>(null);
|
||||
|
||||
useEffect(() => setTargetLanguage(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en"), [settings.language]);
|
||||
|
||||
const run = async (action: string) => {
|
||||
if (!blocks.length) return;
|
||||
setBusy(action);
|
||||
setSuggestion(null);
|
||||
try {
|
||||
const response = await cvBuilderApi.aiAssist({
|
||||
action,
|
||||
text: JSON.stringify(blocks),
|
||||
language: targetLanguage,
|
||||
context: "This is a complete CV represented as JSON blocks. Return only a JSON array with every original id exactly once and the same title, subtitle and items fields. Preserve all facts, employers, dates, qualifications and technologies. Never add unsupported claims.",
|
||||
});
|
||||
const parsed = parseDocumentAiBlocks(response.result, blocks);
|
||||
if (!parsed) throw new Error(t("cvDocumentAiInvalid"));
|
||||
setSuggestion(parsed);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{ key: "document-translate", title: t("cvDocumentAiTranslate"), description: t("cvDocumentAiTranslateHelp"), icon: <TranslateIcon /> },
|
||||
{ key: "document-improve", title: t("cvDocumentAiImprove"), description: t("cvDocumentAiImproveHelp"), icon: <AutoFixHighIcon /> },
|
||||
{ key: "document-grammar", title: t("cvDocumentAiGrammar"), description: t("cvDocumentAiGrammarHelp"), icon: <SpellcheckIcon /> },
|
||||
];
|
||||
const changed = suggestion?.filter((block) => JSON.stringify(block) !== JSON.stringify(blocks.find((original) => original.id === block.id))) ?? [];
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 850 }}>{t("cvDocumentAiTitle")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("cvDocumentAiHelp")}</Typography>
|
||||
</Box>
|
||||
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
|
||||
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||
</TextField>
|
||||
<Stack spacing={1}>
|
||||
{actions.map((action) => (
|
||||
<Paper key={action.key} variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<Box sx={{ color: "primary.main", pt: 0.25 }}>{action.icon}</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{action.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{action.description}</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" size="small" disabled={!canUseAi || !!busy || !blocks.length} onClick={() => void run(action.key)}>
|
||||
{busy === action.key ? t("cvAiWorking") : t("cvDocumentAiReview")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
{suggestion && (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvDocumentAiReviewTitle", { count: changed.length })}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("cvDocumentAiReviewHelp")}</Typography>
|
||||
{changed.length === 0 ? <Alert severity="info">{t("cvDocumentAiNoChanges")}</Alert> : (
|
||||
<Stack spacing={1} sx={{ maxHeight: 360, overflowY: "auto" }}>
|
||||
{changed.map((block) => {
|
||||
const original = blocks.find((item) => item.id === block.id);
|
||||
return <Paper key={block.id} variant="outlined" sx={{ p: 1 }}><Typography variant="caption" sx={{ fontWeight: 800 }}>{block.title ?? original?.title ?? block.id}</Typography><Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1, mt: 0.5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{[original?.subtitle, ...(original?.items ?? [])].filter(Boolean).join("\n")}</Typography><Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{[block.subtitle, ...(block.items ?? [])].filter(Boolean).join("\n")}</Typography></Box></Paper>;
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>{t("cvAiCheckFacts")}</Alert>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
||||
<Button variant="contained" disabled={!changed.length} onClick={() => { const next = applyDocumentAiBlocks(settings, suggestion); update(next); setSuggestion(null); toast(t("cvDocumentAiApplied"), "success"); }}>{t("cvAiApply")}</Button>
|
||||
<Button onClick={() => setSuggestion(null)}>{t("cvAiReject")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Content tab ----------
|
||||
|
||||
type Translate = ReturnType<typeof useI18n>["t"];
|
||||
@@ -579,12 +772,13 @@ function ContentTab({ settings, update, outline }: {
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const [sectionToAdd, setSectionToAdd] = useState("free-text");
|
||||
const [addContentOpen, setAddContentOpen] = useState(false);
|
||||
// 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 populated = new Set((outline?.sections ?? []).map((section) => section.key));
|
||||
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key, hidden: !populated.has(key) }));
|
||||
const have = new Set(base.map((s) => s.key));
|
||||
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key });
|
||||
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key, hidden: !populated.has(key) });
|
||||
for (const section of outline?.sections ?? []) if (!have.has(section.key)) {
|
||||
base.push({ key: section.key, title: section.title });
|
||||
have.add(section.key);
|
||||
@@ -615,8 +809,8 @@ function ContentTab({ settings, update, outline }: {
|
||||
settings.customSections.map((section) => [`custom:${section.key}`, section]),
|
||||
), [settings.customSections]);
|
||||
|
||||
const addCustom = () => {
|
||||
const preset = CUSTOM_SECTION_PRESETS.find((item) => item.key === sectionToAdd);
|
||||
const addCustom = (type: string) => {
|
||||
const preset = CUSTOM_SECTION_PRESETS.find((item) => item.key === type);
|
||||
if (preset && settings.customSections.some((item) => item.presetKey === preset.key)) return;
|
||||
const key = `c${Date.now().toString(36)}`;
|
||||
update({
|
||||
@@ -629,6 +823,11 @@ function ContentTab({ settings, update, outline }: {
|
||||
}],
|
||||
sections: [...sectionRows, { key: `custom:${key}` }],
|
||||
});
|
||||
setAddContentOpen(false);
|
||||
};
|
||||
const addBuiltIn = (key: string) => {
|
||||
patchSection(key, { hidden: false });
|
||||
setAddContentOpen(false);
|
||||
};
|
||||
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
|
||||
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
|
||||
@@ -688,21 +887,45 @@ function ContentTab({ settings, update, outline }: {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorAddSection")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorAddSectionHelp")}</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
||||
<TextField select size="small" fullWidth label={t("cvEditorSectionType")} value={sectionToAdd} onChange={(event) => setSectionToAdd(event.target.value)}>
|
||||
<MenuItem value="free-text">{t("cvEditorFreeTextSection")}</MenuItem>
|
||||
{CUSTOM_SECTION_PRESETS.map((preset) => <MenuItem key={preset.key} value={preset.key} disabled={settings.customSections.some((item) => item.presetKey === preset.key)}>{presetLabel(preset.key, preset.title, t)}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={addCustom}>{t("cvEditorAddSectionButton")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddContentOpen(true)} sx={{ alignSelf: "center", minWidth: 190 }}>{t("cvEditorAddContent")}</Button>
|
||||
<Dialog open={addContentOpen} onClose={() => setAddContentOpen(false)} fullWidth maxWidth="md" aria-labelledby="cv-add-content-title">
|
||||
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("cvEditorAddContentHelp")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", md: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
|
||||
{DEFAULT_SECTION_ORDER.map((key) => {
|
||||
const row = sectionRows.find((section) => section.key === key);
|
||||
const added = !!row && !row.hidden;
|
||||
const label = sectionName(key, SECTION_LABELS[key] ?? key, t);
|
||||
return <AddContentCard key={key} title={label} description={t("cvEditorAddBuiltInHelp", { section: label })} added={added} onAdd={() => addBuiltIn(key)} />;
|
||||
})}
|
||||
{CUSTOM_SECTION_PRESETS.map((preset) => {
|
||||
const added = settings.customSections.some((item) => item.presetKey === preset.key);
|
||||
const label = presetLabel(preset.key, preset.title, t);
|
||||
return <AddContentCard key={preset.key} title={label} description={t("cvEditorAddPresetHelp", { section: label })} added={added} onAdd={() => addCustom(preset.key)} />;
|
||||
})}
|
||||
<AddContentCard title={t("cvEditorCustom")} description={t("cvEditorAddCustomHelp")} added={false} onAdd={() => addCustom("free-text")} />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setAddContentOpen(false)}>{t("cancel")}</Button></DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AddContentCard({ title, description, added, onAdd }: { title: string; description: string; added: boolean; onAdd: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 1.25, minHeight: 112, display: "flex", flexDirection: "column", alignItems: "flex-start", bgcolor: added ? "action.disabledBackground" : "background.paper" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, mt: 0.25 }}>{description}</Typography>
|
||||
<Button size="small" startIcon={!added ? <AddIcon /> : undefined} disabled={added} onClick={onAdd} sx={{ mt: 0.75, ml: -0.75 }}>
|
||||
{added ? t("cvEditorContentAdded") : t("cvEditorAdd")}
|
||||
</Button>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({
|
||||
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
|
||||
}: {
|
||||
@@ -765,6 +988,23 @@ function SectionRow({
|
||||
</Stack>
|
||||
<Collapse in={expanded} unmountOnExit>
|
||||
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
||||
<Paper variant="outlined" sx={{ p: 1, mb: 1.25, bgcolor: "background.paper" }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800 }}>{t("cvEditorSectionLayout")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t("cvEditorSectionLayoutHelp")}</Typography>
|
||||
</Box>
|
||||
<TextField select size="small" label={t("cvEditorPresentation")} value={row.presentation ?? "rows"} onChange={(event) => onPatch({ presentation: event.target.value as CvSectionSetting["presentation"] })} sx={{ minWidth: 145 }}>
|
||||
<MenuItem value="rows">{t("cvEditorRows")}</MenuItem>
|
||||
<MenuItem value="grid">{t("cvEditorGrid")}</MenuItem>
|
||||
<MenuItem value="compact">{t("cvEditorCompact")}</MenuItem>
|
||||
{(outlineSection?.kind === "tags" || (!outlineSection && customSection?.contentType !== "entries")) && <MenuItem value="bubble">{t("cvEditorBubble")}</MenuItem>}
|
||||
</TextField>
|
||||
{row.presentation === "grid" && <Stack direction="row" spacing={0.5} role="group" aria-label={t("cvEditorColumns")}>
|
||||
{[1, 2].map((columns) => <Button key={columns} size="small" variant={(row.columns ?? 1) === columns ? "contained" : "outlined"} onClick={() => onPatch({ columns: columns as 1 | 2 })}>{columns}</Button>)}
|
||||
</Stack>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
{customSection ? (
|
||||
<Stack spacing={1}>
|
||||
<TextField select size="small" label={t("cvEditorContentFormat")} value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
||||
@@ -875,7 +1115,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
// ---------- Customize tab ----------
|
||||
|
||||
function CustomizeTab({ mode, settings, update, themes }: {
|
||||
mode: "template" | "design" | "layout";
|
||||
mode: "template" | "customize";
|
||||
settings: CvVariantSettings;
|
||||
update: (p: Partial<CvVariantSettings>) => void;
|
||||
themes: CvTheme[];
|
||||
@@ -917,7 +1157,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
</Box>
|
||||
</Box>}
|
||||
|
||||
{mode === "design" && <>
|
||||
{mode === "customize" && <>
|
||||
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorColour")}</Typography>
|
||||
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
||||
@@ -943,7 +1183,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
{settings.skillsStyle === "grouped" && <TextField label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp")} 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" && <>
|
||||
{mode === "customize" && <>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorDocument")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
{supports("page") && <>
|
||||
|
||||
Reference in New Issue
Block a user