feat(i18n): persist Bokmal preference globally

This commit is contained in:
cesnimda
2026-08-28 12:44:02 +02:00
parent b6dcc7c760
commit 5805c1a621
21 changed files with 359 additions and 66 deletions
@@ -66,6 +66,7 @@ export function ApplicationWorkspace({
const jobId = jobIdOverride ?? Number(id);
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n();
const { confirm } = useConfirm();
const [params, setParams] = useSearchParams();
const section = sectionOverride ?? workspaceSection(params.get("section"));
@@ -96,10 +97,10 @@ export function ApplicationWorkspace({
const blockedNavigation = blocker;
let active = true;
void confirm({
title: "Unsaved application changes",
message: "Leaving this section will discard changes that have not been saved.",
confirmLabel: "Discard and leave",
cancelLabel: "Keep editing",
title: t("workspaceUnsavedTitle"),
message: t("workspaceUnsavedMessage"),
confirmLabel: t("workspaceDiscardLeave"),
cancelLabel: t("workspaceKeepEditing"),
destructive: true,
}).then((approved) => {
if (!active) return;
@@ -107,21 +108,21 @@ export function ApplicationWorkspace({
else blockedNavigation.reset();
});
return () => { active = false; };
}, [blocker, confirm]);
}, [blocker, confirm, t]);
const load = useCallback(async () => {
if (!Number.isInteger(jobId) || jobId <= 0) {
setOverview(null);
setError("This application link is invalid.");
setError(t("workspaceInvalidLink"));
return;
}
try {
setError(null);
setOverview(await applicationWorkspaceApi.overview(jobId));
} catch (err) {
setError(getApiErrorMessage(err, "Could not open this application."));
setError(getApiErrorMessage(err, t("workspaceLoadFailed")));
}
}, [jobId]);
}, [jobId, t]);
useEffect(() => {
load();
@@ -144,7 +145,7 @@ export function ApplicationWorkspace({
if (error) {
return (
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={close}>Back to applications</Button>
<Button startIcon={<ArrowBackIcon />} onClick={close}>{t("workspaceBack")}</Button>
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
</Box>
);
@@ -154,23 +155,23 @@ export function ApplicationWorkspace({
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
<Tooltip title="Back to applications">
<IconButton size="small" aria-label="Back to applications" onClick={close}>
<Tooltip title={t("workspaceBack")}>
<IconButton size="small" aria-label={t("workspaceBack")} onClick={close}>
<ArrowBackIcon fontSize="small" />
</IconButton>
</Tooltip>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
Workspace
{t("workspace")}
</Typography>
{fullPageHref ? (
<Tooltip title="Open full-page workspace">
<Tooltip title={t("workspaceOpenFull")}>
<IconButton
component="a"
href={fullPageHref}
target="_blank"
rel="noopener noreferrer"
size="small"
aria-label="Open full-page workspace"
aria-label={t("workspaceOpenFull")}
sx={{ ml: "auto" }}
>
<OpenInNewIcon fontSize="small" />
@@ -184,10 +185,10 @@ export function ApplicationWorkspace({
onChange={(_, value: WorkspaceSectionKey) => go(value)}
variant="scrollable"
scrollButtons="auto"
aria-label="Workspace sections"
aria-label={t("workspaceSections")}
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
>
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={s.label} sx={{ minHeight: 46, fontWeight: 700 }} />)}
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
</Tabs>
</Paper>
@@ -238,16 +239,16 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Tooltip title="Edit application">
<IconButton size="small" aria-label="Edit application" onClick={onEdit}>
<Tooltip title={t("workspaceEditApplication")}>
<IconButton size="small" aria-label={t("workspaceEditApplication")} onClick={onEdit}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
{overview.jobUrl && (
<Tooltip title="Open original advert">
<IconButton size="small" aria-label="Open original advert" href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
<Tooltip title={t("workspaceOpenAdvert")}>
<IconButton size="small" aria-label={t("workspaceOpenAdvert")} href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
@@ -269,9 +270,9 @@ function ApplicationProgress({ status }: { status: string }) {
: PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage));
return (
<Box sx={{ mt: 2.5 }} aria-label={`Application progress: ${statusLabel(t, status)}`}>
<Box sx={{ mt: 2.5 }} aria-label={`${t("workspaceProgress")}: ${statusLabel(t, status)}`}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
Application progress
{t("workspaceProgress")}
</Typography>
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
{stages.map((stage, index) => {
@@ -301,6 +302,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
onReload: () => void;
onEdit: () => void;
}) {
const { t } = useI18n();
const stats = useMemo(() => overview ? [
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
@@ -317,7 +319,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
<Stack spacing={2}>
{overview.nextStep ? (
<Paper sx={{ p: 2.5, borderRadius: 3, borderLeft: "4px solid", borderLeftColor: "primary.main" }}>
<Typography variant="overline" color="text.secondary">Next recommended action</Typography>
<Typography variant="overline" color="text.secondary">{t("workspaceNextAction")}</Typography>
<Typography variant="h6" sx={{ fontWeight: 800 }}>{overview.nextStep.label}</Typography>
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{overview.nextStep.reason}</Typography>
<Button variant="contained" endIcon={<ArrowForwardIcon />}
@@ -327,7 +329,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
</Paper>
) : (
<Alert severity="success" sx={{ borderRadius: 3 }}>
Nothing outstanding this application is fully prepared.
{t("workspaceNothingOutstanding")}
</Alert>
)}
@@ -348,12 +350,12 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Recent activity</Typography>
<Button size="small" onClick={onReload}>Refresh</Button>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceRecentActivity")}</Typography>
<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>
</Stack>
<Divider sx={{ my: 1 }} />
{overview.recentActivity.length === 0 ? (
<Typography variant="body2" color="text.secondary">No activity recorded yet.</Typography>
<Typography variant="body2" color="text.secondary">{t("workspaceNoActivity")}</Typography>
) : (
<Stack spacing={0.75}>
{overview.recentActivity.map((a, i) => (
@@ -372,12 +374,13 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
}
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
const { t } = useI18n();
const panels = [
{ id: "details", title: "Job details", content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
{ id: "tasks", title: "Next actions and checklist", content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
{ id: "timeline", title: "Activity history", content: <ApplicationTimeline jobId={jobId} /> },
{ id: "documents", title: "Documents", content: <Attachments jobId={jobId} /> },
{ id: "communication", title: "Communication", content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
];
return (
<Box>
@@ -393,6 +396,17 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
);
}
function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectionKey): string {
const keys: Record<WorkspaceSectionKey, any> = {
overview: "workspaceOverview",
analysis: "workspaceAnalysis",
cv: "workspaceCv",
"cover-letter": "workspaceCoverLetter",
interview: "workspaceInterviewPrep",
};
return t(keys[section]);
}
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
if (!overview) return <Skeleton variant="rounded" height={200} />;
const rows: [string, string][] = [