fix(ui): clarify document errors and retain readable actions
This commit is contained in:
@@ -41,6 +41,8 @@ import {
|
||||
import { previewDocumentHtml } from "../cvPreview";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
|
||||
const MIN_PREVIEW_ZOOM = 0.2;
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
|
||||
@@ -75,7 +77,9 @@ export default function CvBuilderEditor() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<{ status?: number } | null>(null);
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const [opening, setOpening] = useState(true);
|
||||
const [mobilePane, setMobilePane] = useState<"edit" | "preview">("edit");
|
||||
const [historyRevision, setHistoryRevision] = useState(0);
|
||||
|
||||
@@ -93,6 +97,8 @@ export default function CvBuilderEditor() {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadRegion = useRef<HTMLDivElement | null>(null);
|
||||
const retryFocus = useRef(false);
|
||||
const blockerPromptOpen = useRef(false);
|
||||
const undoStack = useRef<CvVariantSettings[]>([]);
|
||||
const redoStack = useRef<CvVariantSettings[]>([]);
|
||||
@@ -103,6 +109,7 @@ export default function CvBuilderEditor() {
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setOpening(true);
|
||||
(async () => {
|
||||
try {
|
||||
const [variant, themeList, outlineData] = await Promise.all([
|
||||
@@ -114,16 +121,24 @@ export default function CvBuilderEditor() {
|
||||
applyVariant(variant);
|
||||
setThemes(themeList);
|
||||
setOutline(outlineData);
|
||||
setLoadError(null);
|
||||
} catch (err) {
|
||||
if (alive) setLoadError(getApiErrorMessage(err, t("cvEditorOpenFailed")));
|
||||
if (alive) setLoadError({ status: (err as { response?: { status?: number } })?.response?.status });
|
||||
} finally {
|
||||
if (alive) setOpening(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
editorEpoch.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [variantId]);
|
||||
}, [variantId, loadAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!retryFocus.current || opening) return;
|
||||
retryFocus.current = false;
|
||||
(loadError ? loadRegion.current : editorRef.current)?.focus();
|
||||
}, [opening, loadError]);
|
||||
|
||||
const applyVariant = (variant: CvVariant) => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
@@ -514,10 +529,15 @@ export default function CvBuilderEditor() {
|
||||
}, [compactEditor, editorReady, fitPreview, mobilePane]);
|
||||
|
||||
if (loadError) {
|
||||
const denied = loadError.status === 401 || loadError.status === 403;
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box ref={loadRegion} role="region" aria-label={t("cvEditorOpenFailed")} tabIndex={-1} aria-busy={opening} sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("cvBuilder")} />
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/career/builder")}>{t("cvEditorBack")}</Button>
|
||||
<Alert severity="error" sx={{ mt: 2 }}>{loadError}</Alert>
|
||||
<ViewStateNotice loading={opening} title={t("cvEditorOpenFailed")}
|
||||
description={loadError.status === 404 ? t("cvEditorSourceUnavailable") : denied ? t("cvEditorPermissionDenied") : undefined}
|
||||
error={{ kind: denied ? "unauthorized" : "unavailable", message: t("cvEditorOpenFailed"), retryable: !loadError.status || loadError.status >= 500 || loadError.status === 429 }}
|
||||
onRetry={() => { retryFocus.current = true; loadRegion.current?.focus(); setLoadAttempt(value => value + 1); }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -578,8 +598,8 @@ export default function CvBuilderEditor() {
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
<Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
|
||||
<Stack direction="row">
|
||||
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
<Tooltip describeChild title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
<Tooltip describeChild title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
@@ -1360,17 +1380,20 @@ function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[];
|
||||
return (
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorVersionCount", { count: versions.length })}</Typography>
|
||||
{versions.map((v) => (
|
||||
{versions.map((v) => {
|
||||
const sources = { autosave: "historySourceAutosave", manual: "historySourceManual", restore: "historySourceRestore", create: "historySourceCreate" } as const;
|
||||
const source = Object.hasOwn(sources, v.source) ? sources[v.source as keyof typeof sources] : undefined;
|
||||
return (
|
||||
<Paper key={v.version} variant="outlined" sx={{ p: 1, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
<Typography component="div" variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t("cvEditorVersion", { version: v.version })} {v.isCurrent && <Chip size="small" label={t("cvEditorCurrent")} sx={{ ml: 0.5, height: 18 }} />}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {v.source}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {source ? t(source) : v.source}</Typography>
|
||||
</Box>
|
||||
{!v.isCurrent && <Button size="small" aria-label={t("cvEditorRestoreTitle", { version: v.version })} onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
|
||||
</Paper>
|
||||
))}
|
||||
); })}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function DocumentsPage() {
|
||||
return { id: jobApplicationId, jobTitle: item?.jobTitle ?? t("documentsJobNumber", { id: jobApplicationId }), companyName: item?.companyName ?? "" };
|
||||
}, [jobApplicationId, jobs, result, t]);
|
||||
const from = { from: `${location.pathname}${location.search}${location.hash}`, fromState: location.state };
|
||||
const buttonSx = { whiteSpace: "normal", overflowWrap: "anywhere", maxWidth: "100%", minHeight: 40 } as const;
|
||||
const buttonSx = { whiteSpace: "normal", overflowWrap: "normal", maxWidth: "100%", minHeight: 40 } as const;
|
||||
const metadataSx = { overflowWrap: "anywhere", minWidth: 0 } as const;
|
||||
|
||||
const date = (item: DocumentItem) => {
|
||||
@@ -288,7 +288,7 @@ export default function DocumentsPage() {
|
||||
<Typography color="text.secondary" sx={{ mt: 1 }}>{t(result.total > 0 ? "documentsPageEmptyHelp" : filtered ? "documentsNoMatchesHelp" : "documentsEmptyHelp")}</Typography>
|
||||
{result.total > 0 ? <Button variant="outlined" sx={{ ...buttonSx, mt: 2 }} onClick={() => { resultsRef.current?.focus(); updateQuery({ page: "1" }); }}>{t("documentsFirstPage")}</Button> : null}
|
||||
</Paper> : desktop ?
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}><Table size="small" aria-label={t("documentsYourDocuments")} sx={{ tableLayout: "fixed", "& th, & td": { verticalAlign: "top", overflowWrap: "anywhere" } }}>
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}><Table size="small" aria-label={t("documentsYourDocuments")} sx={{ "& th, & td": { verticalAlign: "top", overflowWrap: "anywhere" } }}>
|
||||
<TableHead><TableRow><TableCell sx={{ width: "30%" }}>{t("documentsName")}</TableCell><TableCell sx={{ width: "17%" }}>{t("documentsJobFilter")}</TableCell><TableCell sx={{ width: "15%" }}>{t("documentsState")}</TableCell><TableCell sx={{ width: "18%" }}>{t("documentsRecordedDate")}</TableCell><TableCell sx={{ width: "20%" }}>{t("documentsActions")}</TableCell></TableRow></TableHead>
|
||||
<TableBody>{items.map((item) => <TableRow key={item.id}><TableCell component="th" scope="row">{details(item)}</TableCell><TableCell>{job(item)}</TableCell><TableCell>{badges(item)}</TableCell><TableCell>{date(item)}</TableCell><TableCell>{actions(item)}</TableCell></TableRow>)}</TableBody>
|
||||
</Table></TableContainer> : <Stack spacing={2}>{items.map((item) => <Paper key={item.id} component="article" aria-label={item.name} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}><Stack spacing={1.5}>{details(item)}{job(item)}{badges(item)}{date(item)}{actions(item)}</Stack></Paper>)}</Stack>}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -10,22 +11,16 @@ export default function NotFoundPage() {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: { xs: 4, md: 6 },
|
||||
borderRadius: 4,
|
||||
border: "none",
|
||||
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
||||
p: "var(--app-card-padding)",
|
||||
borderRadius: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "grid", gap: 1.5, maxWidth: 560 }}>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: { xs: 48, md: 64 }, lineHeight: 1, letterSpacing: "-0.02em", color: "primary.main" }}>
|
||||
404
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800 }}>
|
||||
{t("notFoundTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("notFoundBody")}
|
||||
</Typography>
|
||||
<PageHeader level="h2" title={t("notFoundTitle")} description={t("notFoundBody")} />
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mt: 1 }}>
|
||||
<Button variant="contained" onClick={() => navigate("/jobs")}>
|
||||
{t("goHome")}
|
||||
|
||||
Reference in New Issue
Block a user