feat(ui): simplify A3 workspace and refresh saved AI availability
This commit is contained in:
@@ -70,6 +70,7 @@ export default function CvBuilderEditor() {
|
||||
const [jobApplicationId, setJobApplicationId] = useState<number | null>(null);
|
||||
const [actionsAnchor, setActionsAnchor] = useState<HTMLElement | null>(null);
|
||||
const [tab, setTab] = useState(1);
|
||||
const [editorWidth, setEditorWidth] = useState(0);
|
||||
const [html, setHtml] = useState("");
|
||||
const [measuredHtml, setMeasuredHtml] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState(0.62);
|
||||
@@ -577,17 +578,23 @@ export default function CvBuilderEditor() {
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const resize = () => fitPreview();
|
||||
const resize = () => {
|
||||
fitPreview();
|
||||
if (editorRef.current?.clientWidth) setEditorWidth(editorRef.current.clientWidth);
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
||||
observer?.observe(container);
|
||||
if (editorRef.current) observer?.observe(editorRef.current);
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [compactEditor, editorReady, fitPreview, mobilePane]);
|
||||
|
||||
const compactStudioNavigation = compactEditor || (editorWidth > 0 && editorWidth < 560);
|
||||
|
||||
if (loadError) {
|
||||
const denied = loadError.status === 401 || loadError.status === 403;
|
||||
return (
|
||||
@@ -620,8 +627,8 @@ export default function CvBuilderEditor() {
|
||||
<Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button>
|
||||
</Stack>
|
||||
</Alert>}
|
||||
<Paper component="header" variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} alignItems={{ sm: "center" }} gap={1.5}>
|
||||
<Box component="header" sx={{ py: 0.5 }}>
|
||||
<Stack direction="row" alignItems="flex-start" gap={1}>
|
||||
<Stack direction="row" alignItems="flex-start" gap={1} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -640,7 +647,7 @@ export default function CvBuilderEditor() {
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} sx={{ flex: { xs: 1, sm: "none" } }} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
|
||||
<Tooltip title={exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}><Button size="small" variant="contained" aria-label={exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")} sx={{ minWidth: 44, minHeight: 44, gap: 1 }} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}><PictureAsPdfIcon fontSize="small" /><Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Box></Button></Tooltip>
|
||||
<Tooltip title={t("cvEditorMoreActions")}><IconButton size="small" aria-label={t("cvEditorMoreActions")} aria-haspopup="menu" aria-expanded={!!actionsAnchor} aria-controls={actionsAnchor ? "cv-document-actions" : undefined} onClick={(event) => setActionsAnchor(event.currentTarget)}><MoreHorizIcon /></IconButton></Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -649,33 +656,35 @@ export default function CvBuilderEditor() {
|
||||
<MenuItem disabled={publishing || exporting || !name.trim()} onClick={() => { setActionsAnchor(null); void togglePublic(); }}><PublicIcon fontSize="small" sx={{ mr: 1 }} />{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}</MenuItem>
|
||||
{isPublic && <MenuItem onClick={() => { setActionsAnchor(null); copyPublicLink(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorCopyLink")}</MenuItem>}
|
||||
</Menu>
|
||||
{compactEditor && <Stack direction="row" gap={0.5} sx={{ mt: 1.5 }}>
|
||||
<Button fullWidth size="small" aria-pressed={mobilePane === "edit"} variant={mobilePane === "edit" ? "contained" : "outlined"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
|
||||
<Button fullWidth size="small" aria-pressed={mobilePane === "preview"} variant={mobilePane === "preview" ? "contained" : "outlined"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
|
||||
{compactEditor && <Stack direction="row" gap={0.5} sx={{ mt: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
<Button fullWidth size="small" aria-pressed={mobilePane === "edit"} sx={{ minHeight: 44, borderBottom: "2px solid", borderColor: mobilePane === "edit" ? "primary.main" : "transparent", borderRadius: 0 }} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
|
||||
<Button fullWidth size="small" aria-pressed={mobilePane === "preview"} sx={{ minHeight: 44, borderBottom: "2px solid", borderColor: mobilePane === "preview" ? "primary.main" : "transparent", borderRadius: 0 }} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
|
||||
</Stack>}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box data-cv-studio-layout sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", "@media (min-width:1001px)": { gridTemplateColumns: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||
<Paper ref={editorRef} tabIndex={-1} variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", top: "var(--cv-pane-top)", minWidth: 0, "@media (min-width:1001px)": { position: "sticky", maxHeight: "calc(100dvh - var(--cv-pane-top) - 12px)", overflowY: "auto" }, scrollbarWidth: "thin" }}>
|
||||
<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>
|
||||
<Box ref={editorRef} tabIndex={-1} role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", top: "var(--cv-pane-top)", minWidth: 0, "@media (min-width:1001px)": { position: "sticky", maxHeight: "calc(100dvh - var(--cv-pane-top) - 12px)", overflowY: "auto" }, scrollbarWidth: "thin" }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1} sx={{ py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
{compactStudioNavigation ? <TextField select size="small" variant="standard" label={t("cvEditorStudioNavigation")} value={tab} sx={{ flex: 1, minWidth: 0 }} onChange={(event) => { const value = Number(event.target.value); setTab(value); if (value === 4) loadVersions(); }}>
|
||||
{[1, 0, 2, 3, 4].map((value) => <MenuItem key={value} value={value}>{t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])}</MenuItem>)}
|
||||
</TextField> : <Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>}
|
||||
<Stack direction="row">
|
||||
<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>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ px: 1.5 }}>{t("cvEditorUndoSessionHelp")}</Typography>
|
||||
<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" }}>
|
||||
{!compactStudioNavigation && <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" }}>
|
||||
{[1, 0, 2, 3, 4].map((value) => <Tab key={value} value={value} id={`cv-studio-tab-${value}`} aria-controls={`cv-studio-panel-${value}`} label={t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])} />)}
|
||||
</Tabs>
|
||||
<Box sx={{ p: { xs: 1, sm: 1.5 } }}>
|
||||
<Box role="tabpanel" id="cv-studio-panel-1" aria-labelledby="cv-studio-tab-1" hidden={tab !== 1}>
|
||||
</Tabs>}
|
||||
<Box sx={{ py: 1 }}>
|
||||
<Box role="tabpanel" id="cv-studio-panel-1" aria-label={compactStudioNavigation ? t("cvEditorContent") : undefined} aria-labelledby={compactStudioNavigation ? undefined : "cv-studio-tab-1"} hidden={tab !== 1}>
|
||||
<ContentTab settings={settings} update={update} outline={outline} onGenerate={generateAi} onAccept={acceptAi} showPreviewEntry={!compactEditor} onFocusPreview={() => {
|
||||
scrollRef.current?.focus({ preventScroll: true });
|
||||
scrollRef.current?.parentElement?.scrollIntoView?.({ block: "start", inline: "nearest" });
|
||||
}} />
|
||||
</Box>
|
||||
{[0, 2, 3, 4].map((value) => <Box key={value} role="tabpanel" id={`cv-studio-panel-${value}`} aria-labelledby={`cv-studio-tab-${value}`} hidden={tab !== value}>
|
||||
{[0, 2, 3, 4].map((value) => <Box key={value} role="tabpanel" id={`cv-studio-panel-${value}`} aria-label={compactStudioNavigation ? t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value]) : undefined} aria-labelledby={compactStudioNavigation ? undefined : `cv-studio-tab-${value}`} hidden={tab !== value}>
|
||||
{tab === value && <>
|
||||
{value === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{value === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
@@ -684,9 +693,9 @@ export default function CvBuilderEditor() {
|
||||
</>}
|
||||
</Box>)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" onFocusCapture={(event) => {
|
||||
<Box onFocusCapture={(event) => {
|
||||
setMobilePane("preview");
|
||||
// Moving a pointer-focused control here can cancel its pending click.
|
||||
if (event.target !== scrollRef.current) return;
|
||||
@@ -752,7 +761,7 @@ export default function CvBuilderEditor() {
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
@@ -963,7 +972,7 @@ function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallback
|
||||
</TextField>
|
||||
<Stack spacing={1}>
|
||||
{actions.map((action) => (
|
||||
<Paper key={action.key} variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Box key={action.key} sx={{ py: 1.5, borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<Box sx={{ color: "primary.main", pt: 0.25 }}>{action.icon}</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
@@ -974,11 +983,11 @@ function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallback
|
||||
{busy === action.key ? t("cvAiWorking") : t("cvDocumentAiReview")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
{suggestion && (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
|
||||
<Box component="section" aria-label={t("cvDocumentAiReviewTitle", { count: changed.length })} sx={{ py: 1.5 }}>
|
||||
<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> : (
|
||||
@@ -995,7 +1004,7 @@ function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallback
|
||||
<Button variant="contained" disabled={!!busy || !changed.length} onClick={() => void accept()}>{t("cvAiApply")}</Button>
|
||||
<Button disabled={busy === "apply"} onClick={discard}>{t("cvAiReject")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -1149,9 +1158,10 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
</Box>
|
||||
{showPreviewEntry && <Button size="small" onClick={onFocusPreview}>{t("cvEditorFocusPreview")}</Button>}
|
||||
</Stack>
|
||||
<Alert severity="info" sx={{ flexWrap: "wrap", "& .MuiAlert-message": { flex: "1 1 12rem", minWidth: 0, overflowWrap: "anywhere" }, "& .MuiAlert-action": { ml: "auto" } }} action={<Button component={RouterLink} to="/career?section=profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
|
||||
{t("cvEditorMasterProfileHelp")}
|
||||
</Alert>
|
||||
<Box component="aside">
|
||||
<Typography variant="body2" color="text.secondary">{t("cvEditorMasterProfileHelp")}</Typography>
|
||||
<Button component={RouterLink} to="/career?section=profile" size="small">{t("cvEditorEditMasterProfile")}</Button>
|
||||
</Box>
|
||||
<FormControlLabel control={<Checkbox checked={settings.includeProfileContact !== false}
|
||||
onChange={(_, checked) => update({ includeProfileContact: checked })} />} label={t("cvEditorIncludeProfileContact")} />
|
||||
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
|
||||
@@ -1279,14 +1289,14 @@ function SectionRow({
|
||||
const customText = customSection?.items.join("\n\n") ?? "";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
<Box
|
||||
{...dragProps}
|
||||
role="group" aria-label={sectionLabel} onFocusCapture={onSectionFocus}
|
||||
sx={{
|
||||
// Hidden in the document is not disabled in the editor: keep names and
|
||||
// controls readable. The visibility icon/label still conveys inclusion.
|
||||
opacity: dragging ? 0.4 : 1,
|
||||
borderBottom: "1px solid", borderColor: "divider",
|
||||
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
|
||||
transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease",
|
||||
minWidth: 0,
|
||||
@@ -1317,7 +1327,7 @@ function SectionRow({
|
||||
{t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")} · {t("cvEditorSectionEntryCount", { count: customSection?.items.length ?? (outlineSection?.kind === "entries" ? outlineSection.entries.length : editableItems.length) })} · {t(customSection ? "cvEditorCvOnly" : "cvEditorSourceProfile")}
|
||||
</Typography>
|
||||
<Collapse in={expanded} unmountOnExit>
|
||||
<Box ref={contentRef} sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
||||
<Box ref={contentRef} sx={{ py: 1.25 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
<span>{customSection ? t("cvEditorCvOnly") : t("cvEditorEntryScopeHelp")}</span> · <span>{t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}</span>
|
||||
</Typography>
|
||||
@@ -1374,7 +1384,7 @@ function SectionRow({
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user