fix: preserve blank CVs and reliable editor undo

This commit is contained in:
cesnimda
2026-10-27 07:37:03 +01:00
parent 5d4b8f23ce
commit 663023a088
22 changed files with 950 additions and 101 deletions
+42 -6
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography,
Alert, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Menu, MenuItem, Paper, Radio, RadioGroup, Stack, TextField, Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
@@ -10,7 +10,7 @@ import PublicIcon from "@mui/icons-material/Public";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
@@ -45,6 +45,20 @@ export default function CvBuilderPage() {
const [newName, setNewName] = useState(t("cvDashboardUntitled"));
const [newTheme, setNewTheme] = useState("modern");
const [creating, setCreating] = useState(false);
const [contentSource, setContentSource] = useState("blank");
const [profileOutline, setProfileOutline] = useState<CvOutline | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const [profileFailed, setProfileFailed] = useState(false);
const [selectedSections, setSelectedSections] = useState<string[]>([]);
const [includeContact, setIncludeContact] = useState(false);
const loadProfile = async () => {
setProfileLoading(true);
setProfileFailed(false);
try { setProfileOutline(await cvBuilderApi.outline()); }
catch { setProfileFailed(true); }
finally { setProfileLoading(false); }
};
useEffect(() => {
if (pending) contentRef.current?.focus();
@@ -77,7 +91,12 @@ export default function CvBuilderPage() {
setCreateError(null);
setCreating(true);
try {
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: {
...emptyCvVariantSettings(newTheme),
sectionsExplicit: true,
includeProfileContact: contentSource === "profile" && includeContact,
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],
} });
setCreateOpen(false);
navigate(`/career/builder/${variant.id}`);
} catch (err) {
@@ -144,8 +163,8 @@ export default function CvBuilderPage() {
link.click();
URL.revokeObjectURL(url);
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" });
const key = await getCvExportFailureKey(err);
setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
} finally { finishAction(); }
};
@@ -216,10 +235,27 @@ export default function CvBuilderPage() {
<Typography id="create-cv-help" color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
{createError && <Alert id="create-cv-error" severity="error" sx={{ mb: 2 }}>{getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}</Alert>}
<TextField autoFocus fullWidth disabled={creating} required label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
<RadioGroup aria-label={t("cvDashboardContentSource")} value={contentSource} onChange={(event) => {
setContentSource(event.target.value);
if (event.target.value === "profile" && !profileOutline && !profileLoading) void loadProfile();
}}>
<FormControlLabel value="blank" control={<Radio disabled={creating} />} label={t("cvDashboardStartBlank")} />
<FormControlLabel value="profile" control={<Radio disabled={creating} />} label={t("cvDashboardFromProfile")} />
</RadioGroup>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t(contentSource === "profile" ? "cvDashboardProfileSelectionHelp" : "cvDashboardBlankHelp")}</Typography>
{contentSource === "profile" && <Stack sx={{ mb: 2 }}>
{profileLoading && <Typography role="status">{t("loading")}</Typography>}
{profileFailed && <Alert severity="error" action={<Button onClick={() => void loadProfile()}>{t("retry")}</Button>}>{t("cvDashboardProfileLoadFailed")}</Alert>}
{profileOutline && <>
<FormControlLabel control={<Checkbox disabled={creating} checked={includeContact} onChange={(event) => setIncludeContact(event.target.checked)} />} label={t("cvEditorIncludeProfileContact")} />
{profileOutline.sections.length === 0 && <Typography>{t("cvDashboardProfileEmpty")}</Typography>}
{profileOutline.sections.map((section) => <FormControlLabel key={section.key} label={section.title} control={<Checkbox disabled={creating} checked={selectedSections.includes(section.key)} onChange={(event) => setSelectedSections((previous) => event.target.checked ? [...previous, section.key] : previous.filter((key) => key !== section.key))} />} />)}
</>}
</Stack>}
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
<CvTemplateGallery disabled={creating} themes={themes.length ? themes : [{ id: "modern", name: t("cvDashboardDefaultTemplate"), category: t("cvEditorTemplate"), description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} />
</DialogContent>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim() || (contentSource === "profile" && (!profileOutline || profileLoading))} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
</Dialog>
</Box>
);