feat: notification center frontend core (PR-3)

- Bell icon in icon registry (Phosphor Bell)
- NotificationProvider context with polling (15s unread / 30s list)
- useNotifications() hook with optimistic updates
- NotificationCenter component: bell + badge + dropdown panel
- NotificationItem component: severity icon, title, relative time, actions
- AppShell integration: mount in header-actions, hidden on mobile
- CSS styles: dropdown, items, unread/read states, empty state
- formatRelativeTime utility (custom, no new deps)
- 25 frontend tests (9 hook + 6 item + 10 center)

Quality gates: vitest 25 passed, tsc clean, eslint clean
This commit is contained in:
2026-05-29 13:14:50 +02:00
parent cbd3436ff7
commit 2bec205a30
15 changed files with 1974 additions and 402 deletions
+79 -72
View File
@@ -9,7 +9,9 @@ import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { EventProvider } from "../state/events";
import { ToastProvider } from "../state/toast";
import { NotificationProvider } from "../state/notifications";
import { EventToastBridge } from "./event-toast-bridge";
import { NotificationCenter } from "./notification-center";
import { Icon } from "./icon";
import { MobileNav } from "./mobile-nav";
import type { IconName } from "../utils/icons";
@@ -79,10 +81,12 @@ export const AppShell = () => {
return (
<EventProvider>
<ToastProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
<NotificationProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
</NotificationProvider>
</ToastProvider>
</EventProvider>
);
@@ -91,79 +95,82 @@ export const AppShell = () => {
return (
<EventProvider>
<ToastProvider>
<EventToastBridge />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<div className="header-actions">
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
<NotificationProvider>
<EventToastBridge />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
<div className="header-actions">
<NotificationCenter isMobileTerminal={isMobileTerminal} />
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
</div>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
</div>
</NotificationProvider>
</ToastProvider>
</EventProvider>
);