feat(ui): migrate CV library actions and recovery
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
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,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
|
||||
@@ -16,98 +15,126 @@ import { useDialogActions } from "../dialogs";
|
||||
import CvTemplateGallery from "../components/CvTemplateGallery";
|
||||
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
|
||||
export default function CvBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { language, t } = useI18n();
|
||||
const localeRef = useRef(t);
|
||||
localeRef.current = t;
|
||||
const { confirmAction, promptForValue } = useDialogActions();
|
||||
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resource = useViewResource(async () => ({
|
||||
variants: await cvBuilderApi.list(),
|
||||
themes: await cvBuilderApi.themes().catch(() => []),
|
||||
}), { initialData: { variants: [] as CvVariantSummary[], themes: [] as CvTheme[] }, errorMessage: "" });
|
||||
const { variants, themes } = resource.data;
|
||||
const loading = resource.loading || resource.refreshing;
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const createTitleRef = useRef<HTMLHeadingElement>(null);
|
||||
const actionBusy = useRef(false);
|
||||
const actionAnchor = useRef<HTMLElement | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null);
|
||||
const [createError, setCreateError] = useState<{ error: unknown } | null>(null);
|
||||
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
|
||||
const [themes, setThemes] = useState<CvTheme[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState(t("cvDashboardUntitled"));
|
||||
const [newTheme, setNewTheme] = useState("modern");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setVariants(await cvBuilderApi.list());
|
||||
try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, t("cvDashboardLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
if (pending) contentRef.current?.focus();
|
||||
else if (document.activeElement === contentRef.current && actionAnchor.current?.isConnected) actionAnchor.current.focus();
|
||||
}, [pending]);
|
||||
|
||||
const createNew = async () => {
|
||||
const startAction = (showPending = true) => {
|
||||
if (actionBusy.current) return false;
|
||||
actionBusy.current = true;
|
||||
actionAnchor.current = menu?.anchor ?? null;
|
||||
setMenu(null);
|
||||
setActionError(null);
|
||||
if (showPending) setPending(true);
|
||||
return true;
|
||||
};
|
||||
const finishAction = () => { actionBusy.current = false; setPending(false); };
|
||||
const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => {
|
||||
resource.setData((data) => ({ ...data, variants: update(data.variants) }));
|
||||
};
|
||||
|
||||
const createNew = () => {
|
||||
if (actionBusy.current) return;
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const confirmCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
if (!newName.trim() || actionBusy.current) return;
|
||||
actionBusy.current = true;
|
||||
createTitleRef.current?.focus();
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
|
||||
setCreateOpen(false);
|
||||
navigate(`/career/builder/${variant.id}`);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t("cvDashboardCreateFailed")), "error");
|
||||
} finally { setCreating(false); }
|
||||
setCreateError({ error: err });
|
||||
} finally { actionBusy.current = false; setCreating(false); }
|
||||
};
|
||||
|
||||
const duplicate = async (id: number) => {
|
||||
if (!startAction()) return;
|
||||
try {
|
||||
await cvBuilderApi.duplicate(id);
|
||||
await load();
|
||||
toast(t("cvDashboardDuplicated"), "success");
|
||||
await resource.reload();
|
||||
toast(localeRef.current("cvDashboardDuplicated"), "success");
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t("cvDashboardDuplicateFailed")), "error");
|
||||
setActionError({ error: err, key: "cvDashboardDuplicateFailed" });
|
||||
} finally {
|
||||
setMenu(null);
|
||||
finishAction();
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (!startAction(false)) return;
|
||||
const variant = variants.find((item) => item.id === id);
|
||||
setMenu(null);
|
||||
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
|
||||
title: t("cvDashboardDeleteCv"),
|
||||
confirmLabel: t("cvDashboardDeleteCv"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
try {
|
||||
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
|
||||
title: t("cvDashboardDeleteCv"),
|
||||
confirmLabel: t("cvDashboardDeleteCv"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
setPending(true);
|
||||
await cvBuilderApi.remove(id);
|
||||
setVariants((v) => v.filter((x) => x.id !== id));
|
||||
toast(t("cvDashboardDeleted"), "success");
|
||||
toast(localeRef.current("cvDashboardDeleted"), "success");
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t("cvDashboardDeleteFailed")), "error");
|
||||
setActionError({ error: err, key: "cvDashboardDeleteFailed" });
|
||||
} finally {
|
||||
setMenu(null);
|
||||
finishAction();
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (id: number) => {
|
||||
if (!startAction(false)) return;
|
||||
const current = variants.find((item) => item.id === id);
|
||||
setMenu(null);
|
||||
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
|
||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
try {
|
||||
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
|
||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
setPending(true);
|
||||
const variant = await cvBuilderApi.get(id);
|
||||
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
|
||||
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
|
||||
toast(t("cvDashboardRenamed"), "success");
|
||||
} catch (err) { toast(getApiErrorMessage(err, t("cvDashboardRenameFailed")), "error"); }
|
||||
toast(localeRef.current("cvDashboardRenamed"), "success");
|
||||
} catch (err) { setActionError({ error: err, key: "cvDashboardRenameFailed" }); }
|
||||
finally { finishAction(); }
|
||||
};
|
||||
|
||||
const download = async (id: number, cvName: string) => {
|
||||
setMenu(null);
|
||||
if (!startAction()) return;
|
||||
try {
|
||||
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
@@ -118,42 +145,38 @@ export default function CvBuilderPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvDashboardDownloadFailed")), "error");
|
||||
}
|
||||
setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" });
|
||||
} finally { finishAction(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>{t("cvDashboardTitle")}</Typography>
|
||||
<Typography color="text.secondary" sx={{ maxWidth: 720 }}>{t("cvDashboardSubtitle")}</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
{!loading && variants.length === 0 && !error && (
|
||||
<Paper sx={{ p: 4, borderRadius: 4, textAlign: "center" }}>
|
||||
<DescriptionOutlinedIcon sx={{ fontSize: 48, color: "text.disabled" }} />
|
||||
<Typography sx={{ mt: 1, fontWeight: 700 }}>{t("cvDashboardEmptyTitle")}</Typography>
|
||||
<Box ref={contentRef} role="region" aria-label={t("cvDashboardTitle")} tabIndex={-1} sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<PageHeader level="h2" title={t("cvDashboardLibraryTitle")} description={t("cvDashboardSubtitle")}
|
||||
actions={<Button variant="contained" disabled={pending || loading} startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>} />
|
||||
<ViewStateNotice title={t("cvDashboardLoadFailed")} loading={loading}
|
||||
error={resource.error ? { ...resource.error, message: resource.error.message || t("cvDashboardLoadFailed") } : null}
|
||||
onRetry={() => { if (actionBusy.current || loading) return; contentRef.current?.focus(); void resource.reload(); }} compact />
|
||||
{actionError && <Alert severity="error">{getApiErrorMessage(actionError.error, t(actionError.key))}</Alert>}
|
||||
{pending && <Typography role="status" variant="body2">{t("loading")}</Typography>}
|
||||
{!loading && variants.length === 0 && !resource.error && (
|
||||
<Paper variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1 }}>
|
||||
<Typography component="h2" variant="sectionTitle">{t("cvDashboardEmptyTitle")}</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardEmptyBody")}</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
<Box sx={{ display: "grid", minWidth: 0, gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
{variants.map((v) => (
|
||||
<Paper
|
||||
key={v.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-disabled={pending}
|
||||
aria-label={t("cvDashboardOpenNamed", { name: v.name })}
|
||||
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||
onClick={() => navigate(`/career/builder/${v.id}`)}
|
||||
sx={{ p: "var(--app-card-padding)", minWidth: 0, borderRadius: 1, cursor: pending ? "default" : "pointer", border: "1px solid", borderColor: "divider", "&:hover": { borderColor: "primary.main" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||
onClick={() => { if (!actionBusy.current) navigate(`/career/builder/${v.id}`); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
||||
if (!actionBusy.current && event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
navigate(`/career/builder/${v.id}`);
|
||||
}
|
||||
@@ -161,12 +184,12 @@ export default function CvBuilderPage() {
|
||||
>
|
||||
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
|
||||
<Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography>
|
||||
<IconButton size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ mt: 1, minWidth: 0 }}>{v.name}</Typography>
|
||||
<IconButton disabled={pending} aria-haspopup="menu" size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); if (!actionBusy.current) setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" sx={{ mt: 1, minWidth: 0 }}>
|
||||
<Chip size="small" label={v.themeId} />
|
||||
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
|
||||
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label={t("cvEditorPublic")} />}
|
||||
@@ -187,13 +210,14 @@ export default function CvBuilderPage() {
|
||||
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>{t("cvDashboardDelete")}</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title">
|
||||
<DialogTitle id="create-cv-title">{t("cvDashboardCreateTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
|
||||
<TextField autoFocus fullWidth label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
|
||||
<CvTemplateGallery themes={themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => setNewTheme(theme.id)} previewHeight={125} showDescriptions={false} />
|
||||
<Dialog open={createOpen} onClose={() => { if (!actionBusy.current) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title" aria-describedby={`create-cv-help${createError ? " create-cv-error" : ""}`}>
|
||||
<DialogTitle ref={createTitleRef} tabIndex={-1} id="create-cv-title">{t("cvDashboardCreateTitle")}</DialogTitle>
|
||||
<DialogContent role="region" aria-labelledby="create-cv-title" tabIndex={creating ? 0 : -1}>
|
||||
<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" } }} />
|
||||
<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>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user