feat(ui): refine global shell utilities

This commit is contained in:
cesnimda
2026-09-28 18:19:27 +02:00
parent 8e56ceabf5
commit 0f54007015
18 changed files with 322 additions and 63 deletions
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Alert,
@@ -34,59 +34,79 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const activeLoad = useRef<AbortController | null>(null);
const openGeneration = useRef(0);
const load = useCallback(async () => {
activeLoad.current?.abort();
const controller = new AbortController();
activeLoad.current = controller;
setLoading(true);
try {
const response = await api.get<UserNotification[]>("/notifications?limit=10");
const response = await api.get<UserNotification[]>("/notifications?limit=10", { signal: controller.signal });
if (controller.signal.aborted) return;
setNotifications(response.data ?? []);
setError(null);
} catch (requestError) {
setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
if (!controller.signal.aborted) setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
} finally {
setLoading(false);
if (activeLoad.current === controller) setLoading(false);
}
}, [t]);
useEffect(() => {
openGeneration.current += 1;
if (open) void load();
else {
activeLoad.current?.abort();
setNotifications([]);
setError(null);
setBusyId(null);
}
return () => activeLoad.current?.abort();
}, [load, open]);
const markRead = async (notification: UserNotification) => {
if (notification.readAtUtc || busyId) return;
const generation = openGeneration.current;
setBusyId(notification.id);
try {
await api.post(`/notifications/${notification.id}/read`);
if (generation !== openGeneration.current) return;
setNotifications((items) => items.map((item) => item.id === notification.id
? { ...item, readAtUtc: new Date().toISOString() }
: item));
setError(null);
onChanged();
} catch (requestError) {
setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
} finally {
setBusyId(null);
if (generation === openGeneration.current) setBusyId(null);
}
};
const dismiss = async (notification: UserNotification) => {
if (busyId) return;
const generation = openGeneration.current;
setBusyId(notification.id);
try {
await api.delete(`/notifications/${notification.id}`);
if (generation !== openGeneration.current) return;
setNotifications((items) => items.filter((item) => item.id !== notification.id));
setError(null);
onChanged();
} catch (requestError) {
setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
} finally {
setBusyId(null);
if (generation === openGeneration.current) setBusyId(null);
}
};
const openNotification = async (notification: UserNotification) => {
if (!notification.linkPath) return;
const generation = openGeneration.current;
if (!notification.readAtUtc) await markRead(notification);
if (generation !== openGeneration.current) return;
onClose();
onNavigate(notification.linkPath);
};
@@ -113,7 +133,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
<Box>
<Typography sx={{ fontWeight: 900 }}>{t("notifications")}</Typography>
<Typography component="h2" sx={{ fontWeight: 700 }}>{t("notifications")}</Typography>
<Typography variant="caption" color="text.secondary">{t("notificationsRecent")}</Typography>
</Box>
<Tooltip title={t("close")}>
@@ -123,7 +143,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
<Divider />
<Box sx={{ overflowY: "auto", maxHeight: "min(52vh, 450px)", p: 1 }}>
{loading ? <Box sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
{loading ? <Box role="status" sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
{error ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void load()}>{t("retry")}</Button>} sx={{ m: 0.5 }}>{error}</Alert> : null}
{!loading && !error && notifications.length === 0 ? (
<Box sx={{ py: 4, px: 2, textAlign: "center" }}>
@@ -131,7 +151,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
<Typography variant="body2" color="text.secondary">{t("notificationsEmptyBody")}</Typography>
</Box>
) : null}
{!loading ? notifications.map((notification) => (
{!loading && !error ? notifications.map((notification) => (
<Box
key={notification.id}
sx={{
@@ -144,7 +164,8 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
opacity: notification.readAtUtc ? 0.82 : 1,
}}
>
<Typography sx={{ fontWeight: notification.readAtUtc ? 700 : 900, overflowWrap: "anywhere" }}>{notification.title}</Typography>
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 700, overflowWrap: "anywhere" }}>{notification.title}</Typography>
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25, overflowWrap: "anywhere" }}>{notification.message}</Typography>
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc)}</Typography>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.75, flexWrap: "wrap" }}>
@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Alert,
Button,
Dialog,
DialogContent,
List,
@@ -47,13 +49,19 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
const [query, setQuery] = useState("");
const [jobs, setJobs] = useState<JobSearchItem[]>([]);
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
const [searchState, setSearchState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [searchAttempt, setSearchAttempt] = useState(0);
const [activeIndex, setActiveIndex] = useState(-1);
const searchInputRef = useRef<HTMLInputElement>(null);
const resultRefs = useRef<Array<HTMLElement | null>>([]);
useEffect(() => {
if (!open) {
setQuery("");
setJobs([]);
setCompanies([]);
setSearchState("idle");
setActiveIndex(-1);
return;
}
}, [open]);
@@ -64,25 +72,28 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
if (!q) {
setJobs([]);
setCompanies([]);
setSearchState("idle");
return;
}
const controller = new AbortController();
setJobs([]);
setCompanies([]);
setSearchState("loading");
const timeout = window.setTimeout(() => {
api
.get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 } })
.then((r) => setJobs(r.data?.items ?? []))
.catch(() => setJobs([]));
api
.get<CompanySearchItem[]>("/companies")
.then((r) => {
const matches = (r.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
setCompanies(matches);
Promise.all([
api.get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 }, signal: controller.signal }),
api.get<CompanySearchItem[]>("/companies", { signal: controller.signal }),
])
.then(([jobResponse, companyResponse]) => {
if (controller.signal.aborted) return;
setJobs(jobResponse.data?.items ?? []);
setCompanies((companyResponse.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8));
setSearchState("ready");
})
.catch(() => setCompanies([]));
.catch(() => { if (!controller.signal.aborted) setSearchState("error"); });
}, 250);
return () => window.clearTimeout(timeout);
}, [open, query]);
return () => { window.clearTimeout(timeout); controller.abort(); };
}, [open, query, searchAttempt]);
const commands = useMemo<CommandItem[]>(() => {
const base: CommandItem[] = [
@@ -99,25 +110,44 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q));
}, [onNavigate, onOpenAddJob, query, t]);
const allItems: CommandItem[] = [
...commands,
...jobs.slice(0, 6).map((job) => ({
const jobItems: CommandItem[] = jobs.slice(0, 6).map((job) => ({
id: `job-${job.id}`,
label: `${job.company?.name ?? t("company")} - ${job.jobTitle}`,
hint: t("openJobListAndSearchResult"),
action: () => onNavigate(`/jobs/${job.id}`),
})),
...companies.slice(0, 6).map((company) => ({
}));
const companyItems: CommandItem[] = companies.slice(0, 6).map((company) => ({
id: `company-${company.id}`,
label: company.name,
hint: t("openCompanies"),
action: () => onNavigate(`/companies?edit=${company.id}`),
})),
}));
const groups = [
{ label: t("commandActions"), items: commands },
{ label: t("commandJobs"), items: jobItems },
{ label: t("commandCompanies"), items: companyItems },
];
const allItems = groups.flatMap((group) => group.items);
const activate = (item: CommandItem) => { item.action(); onClose(); };
const onKeys = (event: React.KeyboardEvent) => {
if (event.nativeEvent.isComposing || event.keyCode === 229 || !allItems.length) return;
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const current = event.target === searchInputRef.current ? activeIndex : resultRefs.current.indexOf(event.target as HTMLElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? allItems.length - 1
: event.key === "ArrowDown" ? (current + 1) % allItems.length
: (current < 0 ? allItems.length - 1 : (current - 1 + allItems.length) % allItems.length);
setActiveIndex(next);
if (event.target !== searchInputRef.current) resultRefs.current[next]?.focus();
} else if (event.key === "Enter" && event.target === searchInputRef.current) {
event.preventDefault();
activate(allItems[activeIndex < 0 ? 0 : activeIndex]);
}
};
return (
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
<DialogContent sx={{ p: 1.5 }}>
<DialogContent onKeyDown={onKeys} sx={{ p: 1.5, maxHeight: "min(70vh, 620px)" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
<SearchIcon fontSize="small" />
<TextField
@@ -128,7 +158,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
placeholder={t("searchPlaceholder")}
slotProps={{ htmlInput: { "aria-label": t("quickSearch") } }}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); }}
InputProps={{ disableUnderline: true }}
/>
<Typography variant="caption" sx={{ color: "text.secondary", whiteSpace: "nowrap" }}>
@@ -136,27 +166,22 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
</Typography>
</Box>
<List sx={{ mt: 1 }}>
{allItems.length === 0 ? (
<ListItem sx={{ px: 2, py: 3 }}>
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
</ListItem>
) : (
allItems.map((item) => (
<ListItem key={item.id} disablePadding>
<ListItemButton
onClick={() => {
item.action();
onClose();
}}
sx={{ borderRadius: 2 }}
>
<ListItemText primary={item.label} secondary={item.hint} />
</ListItemButton>
</ListItem>
))
)}
</List>
<Box aria-live="polite" sx={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
{searchState === "loading" ? t("loading") : searchState === "ready" ? t("searchResultsCount", { count: allItems.length }) : ""}
{activeIndex >= 0 ? ` ${allItems[activeIndex]?.label ?? ""}` : ""}
</Box>
{searchState === "error" ? <Alert severity="error" sx={{ mt: 1 }} action={<Button color="inherit" size="small" onClick={() => setSearchAttempt((value) => value + 1)}>{t("retry")}</Button>}>{t("searchLoadFailed")}</Alert> : null}
{groups.map((group) => group.items.length ? <Box component="section" key={group.label} sx={{ mt: 1 }}>
<Typography component="h2" variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 700 }}>{group.label}</Typography>
<List dense aria-label={group.label}>
{group.items.map((item) => <ListItem key={item.id} disablePadding>
<ListItemButton ref={(node) => { resultRefs.current[allItems.indexOf(item)] = node; }} selected={activeIndex === allItems.indexOf(item)} onFocus={() => setActiveIndex(allItems.indexOf(item))} onClick={() => activate(item)} sx={{ borderRadius: 2 }}>
<ListItemText primary={item.label} secondary={item.hint} />
</ListItemButton>
</ListItem>)}
</List>
</Box> : null)}
{allItems.length === 0 && searchState !== "loading" && searchState !== "error" ? <Typography color="text.secondary" sx={{ px: 2, py: 3 }}>{t("noMatchingCommands")}</Typography> : null}
</DialogContent>
</Dialog>
);