feat(ui): name controls and preserve modal state
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useId, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
|
||||
@@ -558,6 +558,7 @@ const COVER_LETTER_ACTIONS = [
|
||||
] as const;
|
||||
|
||||
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
|
||||
const labelId = useId();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const { language: uiLanguage, t } = useI18n();
|
||||
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
|
||||
@@ -607,14 +608,14 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
||||
)}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>{t("coverAiAction")}</InputLabel>
|
||||
<Select label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
|
||||
<InputLabel id={`${labelId}-coverAiAction`}>{t("coverAiAction")}</InputLabel>
|
||||
<Select labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
|
||||
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>{t("coverAiTone")}</InputLabel>
|
||||
<Select label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
|
||||
<InputLabel id={`${labelId}-coverAiTone`}>{t("coverAiTone")}</InputLabel>
|
||||
<Select labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
|
||||
{[
|
||||
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
|
||||
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
|
||||
@@ -622,8 +623,8 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel>{t("coverAiDocumentLanguage")}</InputLabel>
|
||||
<Select label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
||||
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
|
||||
<Select labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</Select>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -83,6 +83,7 @@ function purposeLabel(purpose: string | null | undefined, t: (key: any) => strin
|
||||
}
|
||||
|
||||
export default function Attachments({ jobId }: { jobId: number }) {
|
||||
const labelId = useId();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction, promptForValue } = useDialogActions();
|
||||
@@ -297,8 +298,8 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>{t("attachmentsPurpose")}</InputLabel>
|
||||
<Select value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
|
||||
<InputLabel id={`${labelId}-${a.id}`}>{t("attachmentsPurpose")}</InputLabel>
|
||||
<Select labelId={`${labelId}-${a.id}`} value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
|
||||
<MenuItem value="resume">{t("attachmentsPurposeResume")}</MenuItem>
|
||||
<MenuItem value="cover-letter">{t("attachmentsPurposeCoverLetter")}</MenuItem>
|
||||
<MenuItem value="portfolio">{t("attachmentsPurposePortfolio")}</MenuItem>
|
||||
@@ -310,7 +311,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Switch size="small" checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
|
||||
<Switch size="small" slotProps={{ input: { role: "switch", "aria-label": `${t("attachmentsAiUse")}: ${a.fileName}` } }} checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{a.useForAi ? t("attachmentsAiEnabled") : t("attachmentsAiDisabled")}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") } }} fullWidth maxWidth="sm">
|
||||
<DialogContent sx={{ p: 1.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
|
||||
<SearchIcon fontSize="small" />
|
||||
@@ -123,6 +123,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
variant="standard"
|
||||
fullWidth
|
||||
placeholder={t("searchPlaceholder")}
|
||||
slotProps={{ htmlInput: { "aria-label": t("quickSearch") } }}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
InputProps={{ disableUnderline: true }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
|
||||
import type { ViewResourceError } from "../hooks/useViewResource";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -18,22 +19,30 @@ type Props = {
|
||||
export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel, onRetry, compact = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const resolvedRetryLabel = retryLabel ?? t("retry");
|
||||
const [observedLoading, setObservedLoading] = useState(loading);
|
||||
useEffect(() => { if (loading) setObservedLoading(true); }, [loading]);
|
||||
|
||||
// Keep one live region mounted through the read. An idle/error-only consumer
|
||||
// has not completed a load; unrelated form edits must not announce anything.
|
||||
const status = <Box component="span" role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
|
||||
{loading ? t("loading") : !error && observedLoading ? t("contentLoaded") : ""}
|
||||
</Box>;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box role="status" aria-live="polite" sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
|
||||
<>{status}<Box sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress size={compact ? 24 : 28} aria-label={t("loading")} />
|
||||
</Box>
|
||||
</Box></>
|
||||
);
|
||||
}
|
||||
|
||||
if (!error) return null;
|
||||
if (!error) return <>{status}</>;
|
||||
|
||||
const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error";
|
||||
const detail = error.message !== title && error.message !== description ? error.message : null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
<>{status}<Alert
|
||||
severity={severity}
|
||||
sx={{
|
||||
mb: compact ? 1.5 : 2,
|
||||
@@ -45,6 +54,6 @@ export default function ViewStateNotice({ loading = false, error = null, title,
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography>
|
||||
{description ? <Typography variant="body2">{description}</Typography> : null}
|
||||
{detail ? <Typography variant="body2" sx={{ mt: 0.5 }}>{detail}</Typography> : null}
|
||||
</Alert>
|
||||
</Alert></>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useId } from "react";
|
||||
import {
|
||||
Box, Button, Divider, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Paper, Select, Slider, Stack, Switch, TextField, Typography,
|
||||
} from "@mui/material";
|
||||
@@ -27,6 +27,7 @@ type Props = {
|
||||
|
||||
export default function CvCustomizeTab({ mode, settings, update, themes, sectionLabel }: Props) {
|
||||
const { t } = useI18n();
|
||||
const labelId = useId();
|
||||
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
|
||||
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
||||
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
||||
@@ -58,14 +59,14 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
|
||||
|
||||
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>{t("cvEditorTypography")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingFont")}</InputLabel><Select label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorBodyFont")}</InputLabel><Select label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingFont`}>{t("cvEditorHeadingFont")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingFont`} label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorBodyFont`}>{t("cvEditorBodyFont")}</InputLabel><Select labelId={`${labelId}-cvEditorBodyFont`} label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
|
||||
</Box>
|
||||
<ControlSlider label={t("cvEditorBodySize")} value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
||||
<ControlSlider label={t("cvEditorHeadingSize")} value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
||||
<ControlSlider label={t("cvEditorLineHeight")} value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
|
||||
{supports("heading") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingTreatment")}</InputLabel><Select label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
|
||||
{supports("header") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeaderTreatment")}</InputLabel><Select label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
|
||||
{supports("heading") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingTreatment`}>{t("cvEditorHeadingTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingTreatment`} label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
|
||||
{supports("header") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeaderTreatment`}>{t("cvEditorHeaderTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeaderTreatment`} label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
|
||||
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>{t("cvEditorSkillsPresentation")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorSkillsPresentation") }} label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? "tags"} onChange={(event) => update({ skillsStyle: event.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">{t("cvEditorTags")}</MenuItem><MenuItem value="text">{t("cvEditorCompactText")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="grouped">{t("cvEditorGroupedSkills")}</MenuItem></Select></FormControl>
|
||||
{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) }; }) })} />}</>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user