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
+164 -164
View File
@@ -1,180 +1,180 @@
import {
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
Bell,
} from "@phosphor-icons/react";
export type IconName =
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary"
| "external"
| "play"
| "stop"
| "terminal"
| "arrow-left";
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary"
| "external"
| "play"
| "stop"
| "terminal"
| "arrow-left"
| "bell";
export const iconRegistry: Record<
IconName,
React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>
IconName,
React.ComponentType<{
size?: number | string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
}>
> = {
// Navigation
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
// Navigation
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
// Actions
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
// Actions
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
// Status
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
// Status
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
// Git
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
// Git
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
// Files
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
// Files
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
// Instance actions
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
// Instance actions
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
bell: Bell,
};
export const iconCategories = {
navigation: [
"dashboard",
"projects",
"repositories",
"settings",
"profile",
"logout",
] as IconName[],
actions: [
"add",
"edit",
"delete",
"save",
"cancel",
"refresh",
"copy",
"search",
"menu",
"close",
] as IconName[],
status: [
"success",
"error",
"warning",
"info",
"loading",
] as IconName[],
git: [
"branch",
"commit",
"merge",
"history",
"pull",
"push",
"fetch",
] as IconName[],
files: [
"file",
"folder",
"code",
"document",
"image",
"binary",
] as IconName[],
navigation: [
"dashboard",
"projects",
"repositories",
"settings",
"profile",
"logout",
] as IconName[],
actions: [
"add",
"edit",
"delete",
"save",
"cancel",
"refresh",
"copy",
"search",
"menu",
"close",
] as IconName[],
status: ["success", "error", "warning", "info", "loading"] as IconName[],
git: [
"branch",
"commit",
"merge",
"history",
"pull",
"push",
"fetch",
] as IconName[],
files: [
"file",
"folder",
"code",
"document",
"image",
"binary",
] as IconName[],
};
+29
View File
@@ -0,0 +1,29 @@
const UNITS: { label: string; seconds: number }[] = [
{ label: "y", seconds: 31536000 },
{ label: "mo", seconds: 2592000 },
{ label: "w", seconds: 604800 },
{ label: "d", seconds: 86400 },
{ label: "h", seconds: 3600 },
{ label: "m", seconds: 60 },
{ label: "s", seconds: 1 },
];
export function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffSeconds = Math.max(
0,
Math.floor((now.getTime() - date.getTime()) / 1000),
);
if (diffSeconds < 5) return "just now";
for (const unit of UNITS) {
const count = Math.floor(diffSeconds / unit.seconds);
if (count >= 1) {
return `${count}${unit.label} ago`;
}
}
return "just now";
}