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:
@@ -0,0 +1,64 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
category: string;
|
||||
severity: "info" | "warning" | "error" | "success";
|
||||
title: string;
|
||||
message: string | null;
|
||||
source_type: string | null;
|
||||
source_id: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
read_at: string | null;
|
||||
dismissed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: NotificationItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface UnreadCountResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MarkAllReadResponse {
|
||||
marked_count: number;
|
||||
}
|
||||
|
||||
export const getNotifications = async (): Promise<NotificationListResponse> => {
|
||||
const response =
|
||||
await apiClient.get<NotificationListResponse>("/notifications");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getUnreadCount = async (): Promise<number> => {
|
||||
const response = await apiClient.get<UnreadCountResponse>(
|
||||
"/notifications/unread",
|
||||
);
|
||||
return response.data.count;
|
||||
};
|
||||
|
||||
export const markNotificationRead = async (
|
||||
id: string,
|
||||
): Promise<NotificationItem> => {
|
||||
const response = await apiClient.patch<NotificationItem>(
|
||||
`/notifications/${id}/read`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const markAllNotificationsRead = async (): Promise<number> => {
|
||||
const response = await apiClient.post<MarkAllReadResponse>(
|
||||
"/notifications/mark-all-read",
|
||||
);
|
||||
return response.data.marked_count;
|
||||
};
|
||||
|
||||
export const dismissNotification = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/notifications/${id}`);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+157
-148
@@ -1,167 +1,176 @@
|
||||
import React from "react";
|
||||
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,
|
||||
DotsSixVertical,
|
||||
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,
|
||||
DotsSixVertical,
|
||||
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"
|
||||
| "drag";
|
||||
| "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"
|
||||
| "drag"
|
||||
| "bell";
|
||||
|
||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||
dashboard: House,
|
||||
projects: Folder,
|
||||
repositories: GitBranch,
|
||||
settings: Gear,
|
||||
profile: User,
|
||||
logout: SignOut,
|
||||
add: Plus,
|
||||
edit: PencilSimple,
|
||||
delete: Trash,
|
||||
save: FloppyDisk,
|
||||
cancel: X,
|
||||
refresh: ArrowsClockwise,
|
||||
copy: Copy,
|
||||
search: MagnifyingGlass,
|
||||
menu: List,
|
||||
close: X,
|
||||
success: Check,
|
||||
error: X,
|
||||
warning: Warning,
|
||||
info: Info,
|
||||
loading: Spinner,
|
||||
branch: GitBranch,
|
||||
commit: GitCommit,
|
||||
merge: GitMerge,
|
||||
history: ClockCounterClockwise,
|
||||
pull: ArrowDown,
|
||||
push: ArrowUp,
|
||||
fetch: ArrowsClockwise,
|
||||
file: File,
|
||||
folder: Folder,
|
||||
code: Code,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
binary: Binary,
|
||||
external: ArrowSquareOut,
|
||||
play: Play,
|
||||
stop: Stop,
|
||||
terminal: Terminal,
|
||||
"arrow-left": ArrowLeft,
|
||||
drag: DotsSixVertical,
|
||||
const iconMap: Record<
|
||||
IconName,
|
||||
React.ComponentType<{
|
||||
size?: number | string;
|
||||
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
||||
}>
|
||||
> = {
|
||||
dashboard: House,
|
||||
projects: Folder,
|
||||
repositories: GitBranch,
|
||||
settings: Gear,
|
||||
profile: User,
|
||||
logout: SignOut,
|
||||
add: Plus,
|
||||
edit: PencilSimple,
|
||||
delete: Trash,
|
||||
save: FloppyDisk,
|
||||
cancel: X,
|
||||
refresh: ArrowsClockwise,
|
||||
copy: Copy,
|
||||
search: MagnifyingGlass,
|
||||
menu: List,
|
||||
close: X,
|
||||
success: Check,
|
||||
error: X,
|
||||
warning: Warning,
|
||||
info: Info,
|
||||
loading: Spinner,
|
||||
branch: GitBranch,
|
||||
commit: GitCommit,
|
||||
merge: GitMerge,
|
||||
history: ClockCounterClockwise,
|
||||
pull: ArrowDown,
|
||||
push: ArrowUp,
|
||||
fetch: ArrowsClockwise,
|
||||
file: File,
|
||||
folder: Folder,
|
||||
code: Code,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
binary: Binary,
|
||||
external: ArrowSquareOut,
|
||||
play: Play,
|
||||
stop: Stop,
|
||||
terminal: Terminal,
|
||||
"arrow-left": ArrowLeft,
|
||||
drag: DotsSixVertical,
|
||||
bell: Bell,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
name: IconName;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
color?: string;
|
||||
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
name: IconName;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
color?: string;
|
||||
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const sizeMap: Record<NonNullable<IconProps["size"]>, number> = {
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
};
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({
|
||||
name,
|
||||
size = "md",
|
||||
color,
|
||||
weight = "regular",
|
||||
className,
|
||||
ariaLabel,
|
||||
name,
|
||||
size = "md",
|
||||
color,
|
||||
weight = "regular",
|
||||
className,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const IconComponent = iconMap[name];
|
||||
const sizeValue = sizeMap[size];
|
||||
const IconComponent = iconMap[name];
|
||||
const sizeValue = sizeMap[size];
|
||||
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
|
||||
style={{ color }}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={!ariaLabel}
|
||||
role="img"
|
||||
>
|
||||
<IconComponent size={sizeValue} weight={weight} />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
|
||||
style={{ color }}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={!ariaLabel}
|
||||
role="img"
|
||||
>
|
||||
<IconComponent size={sizeValue} weight={weight} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { NotificationCenter } from "./notification-center";
|
||||
import { NotificationProvider } from "../state/notifications";
|
||||
|
||||
vi.mock("../api/notifications", () => ({
|
||||
getNotifications: vi.fn(),
|
||||
getUnreadCount: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
dismissNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getNotifications, getUnreadCount } from "../api/notifications";
|
||||
|
||||
const mockedGetNotifications = vi.mocked(getNotifications);
|
||||
const mockedGetUnreadCount = vi.mocked(getUnreadCount);
|
||||
|
||||
const makeNotification = (id: string, overrides?: Record<string, unknown>) => ({
|
||||
id,
|
||||
user_id: "user-1",
|
||||
category: "instance",
|
||||
severity: "info" as const,
|
||||
title: `Notification ${id}`,
|
||||
message: null,
|
||||
source_type: null,
|
||||
source_id: null,
|
||||
metadata: {},
|
||||
read_at: null,
|
||||
dismissed_at: null,
|
||||
created_at: "2026-05-29T10:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <NotificationProvider>{children}</NotificationProvider>;
|
||||
}
|
||||
|
||||
describe("NotificationCenter", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders bell icon", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
expect(
|
||||
screen.getByRole("button", { name: /notifications/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows badge when unread count > 0", async () => {
|
||||
mockedGetUnreadCount.mockResolvedValue(3);
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides badge when unread count is 0", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens dropdown on bell click", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes dropdown on outside click", () => {
|
||||
render(
|
||||
<div>
|
||||
<div data-testid="outside">Outside</div>
|
||||
<NotificationCenter />
|
||||
</div>,
|
||||
{ wrapper },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId("outside"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes dropdown on escape", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when no notifications", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByText("No notifications")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders notification items", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1"), makeNotification("2")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(screen.getByText("Notification 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Notification 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls markAllRead on footer button click", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
fireEvent.click(screen.getByRole("button", { name: /mark all as read/i }));
|
||||
|
||||
const { markAllNotificationsRead: mockMarkAll } = await import(
|
||||
"../api/notifications"
|
||||
);
|
||||
expect(vi.mocked(mockMarkAll)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes list immediately on open", async () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(mockedGetNotifications).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNotifications } from "../hooks/use-notifications";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface NotificationCenterProps {
|
||||
isMobileTerminal?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationCenter({
|
||||
isMobileTerminal = false,
|
||||
}: NotificationCenterProps) {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markRead,
|
||||
markAllRead,
|
||||
dismiss,
|
||||
refreshList,
|
||||
isDropdownOpen,
|
||||
setIsDropdownOpen,
|
||||
} = useNotifications();
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isDropdownOpen, setIsDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen) {
|
||||
void refreshList();
|
||||
}
|
||||
}, [isDropdownOpen, refreshList]);
|
||||
|
||||
if (isMobileTerminal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const badgeText = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
|
||||
return (
|
||||
<div className="notification-center">
|
||||
<button
|
||||
type="button"
|
||||
className="notification-bell"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
aria-label="Notifications"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isDropdownOpen}
|
||||
>
|
||||
<Icon name="bell" size="md" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="nav-badge notification-badge">{badgeText}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
role="dialog"
|
||||
aria-label="Notifications"
|
||||
className="notification-dropdown"
|
||||
>
|
||||
<div className="notification-dropdown-header">
|
||||
<span>Notifications</span>
|
||||
</div>
|
||||
|
||||
<ul className="notification-list">
|
||||
{notifications.length === 0 ? (
|
||||
<li className="notification-empty">No notifications</li>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={markRead}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="notification-dropdown-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="notification-mark-all"
|
||||
onClick={() => {
|
||||
void markAllRead();
|
||||
}}
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeNotification = (overrides?: Record<string, unknown>) => ({
|
||||
id: "1",
|
||||
user_id: "user-1",
|
||||
category: "instance",
|
||||
severity: "info" as const,
|
||||
title: "Container started",
|
||||
message: null,
|
||||
source_type: null,
|
||||
source_id: null,
|
||||
metadata: {},
|
||||
read_at: null,
|
||||
dismissed_at: null,
|
||||
created_at: "2026-05-29T10:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("NotificationItem", () => {
|
||||
it("displays title and relative time", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification()}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Container started")).toBeInTheDocument();
|
||||
expect(screen.getByText(/ago|just now/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies unread styling when read_at is null", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification({ read_at: null })}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByRole("listitem");
|
||||
expect(row.className).toContain("notification-item--unread");
|
||||
});
|
||||
|
||||
it("applies read styling when read_at is set", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification({ read_at: "2026-05-29T10:01:00Z" })}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByRole("listitem");
|
||||
expect(row.className).toContain("notification-item--read");
|
||||
});
|
||||
|
||||
it("calls onMarkRead when mark read clicked", () => {
|
||||
const onMarkRead = vi.fn();
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification()}
|
||||
onMarkRead={onMarkRead}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /mark read/i }));
|
||||
expect(onMarkRead).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("calls onDismiss when dismiss clicked", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification()}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
expect(onDismiss).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("displays severity icon", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification({ severity: "error" })}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Icon } from "./icon";
|
||||
import { formatRelativeTime } from "../utils/time";
|
||||
import type { NotificationItem as NotificationItemType } from "../api/notifications";
|
||||
|
||||
export interface NotificationItemProps {
|
||||
notification: NotificationItemType;
|
||||
onMarkRead: (id: string) => void;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
const severityIconMap: Record<string, IconName> = {
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
success: "success",
|
||||
};
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
onMarkRead,
|
||||
onDismiss,
|
||||
}: NotificationItemProps) {
|
||||
const isUnread = notification.read_at === null;
|
||||
const iconName = severityIconMap[notification.severity] ?? "info";
|
||||
|
||||
return (
|
||||
<li
|
||||
role="listitem"
|
||||
className={`notification-item ${isUnread ? "notification-item--unread" : "notification-item--read"}`}
|
||||
>
|
||||
<div className="notification-item-icon">
|
||||
<Icon name={iconName} size="md" />
|
||||
</div>
|
||||
<div className="notification-item-content">
|
||||
<div className="notification-item-title">{notification.title}</div>
|
||||
<div className="notification-item-time">
|
||||
{formatRelativeTime(notification.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="notification-item-actions">
|
||||
{isUnread && (
|
||||
<button
|
||||
type="button"
|
||||
className="notification-item-action"
|
||||
onClick={() => onMarkRead(notification.id)}
|
||||
aria-label="Mark read"
|
||||
>
|
||||
Mark read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="notification-item-action"
|
||||
onClick={() => onDismiss(notification.id)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useNotifications } from "./use-notifications";
|
||||
import { NotificationProvider } from "../state/notifications";
|
||||
|
||||
vi.mock("../api/notifications", () => ({
|
||||
getNotifications: vi.fn(),
|
||||
getUnreadCount: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
dismissNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
dismissNotification,
|
||||
} from "../api/notifications";
|
||||
import type { NotificationItem } from "../api/notifications";
|
||||
|
||||
const mockedGetNotifications = vi.mocked(getNotifications);
|
||||
const mockedGetUnreadCount = vi.mocked(getUnreadCount);
|
||||
const mockedMarkNotificationRead = vi.mocked(markNotificationRead);
|
||||
const mockedMarkAllNotificationsRead = vi.mocked(markAllNotificationsRead);
|
||||
const mockedDismissNotification = vi.mocked(dismissNotification);
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <NotificationProvider>{children}</NotificationProvider>;
|
||||
}
|
||||
|
||||
const makeNotification = (id: string, overrides?: Record<string, unknown>) => ({
|
||||
id,
|
||||
user_id: "user-1",
|
||||
category: "instance",
|
||||
severity: "info" as const,
|
||||
title: "Test",
|
||||
message: null,
|
||||
source_type: null,
|
||||
source_id: null,
|
||||
metadata: {},
|
||||
read_at: null,
|
||||
dismissed_at: null,
|
||||
created_at: "2026-05-29T10:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("useNotifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(0);
|
||||
mockedMarkNotificationRead.mockResolvedValue(
|
||||
makeNotification("1", { read_at: "2026-05-29T10:01:00Z" }),
|
||||
);
|
||||
mockedMarkAllNotificationsRead.mockResolvedValue(1);
|
||||
mockedDismissNotification.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns notifications and unreadCount from provider", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(3);
|
||||
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
expect(result.current.unreadCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("optimistically updates on markRead", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1"), makeNotification("2")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(2);
|
||||
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.unreadCount).toBe(2));
|
||||
|
||||
let resolveApi:
|
||||
| ((value: NotificationItem | PromiseLike<NotificationItem>) => void)
|
||||
| undefined;
|
||||
mockedMarkNotificationRead.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveApi = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
void result.current.markRead("1");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const n = result.current.notifications.find((x) => x.id === "1");
|
||||
expect(n?.read_at).not.toBeNull();
|
||||
});
|
||||
expect(result.current.unreadCount).toBe(1);
|
||||
|
||||
act(() => {
|
||||
resolveApi?.(makeNotification("1", { read_at: "2026-05-29T10:01:00Z" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("reverts optimistic update on markRead failure", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(1);
|
||||
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => expect(result.current.unreadCount).toBe(1));
|
||||
|
||||
mockedMarkNotificationRead.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.markRead("1");
|
||||
});
|
||||
|
||||
const n = result.current.notifications.find((x) => x.id === "1");
|
||||
expect(n?.read_at).toBeNull();
|
||||
expect(result.current.unreadCount).toBe(1);
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("optimistically updates on dismiss", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1"), makeNotification("2")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(2);
|
||||
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => expect(result.current.notifications).toHaveLength(2));
|
||||
|
||||
mockedDismissNotification.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
act(() => {
|
||||
void result.current.dismiss("1");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
});
|
||||
expect(result.current.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it("reverts optimistic update on dismiss failure", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(1);
|
||||
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => expect(result.current.notifications).toHaveLength(1));
|
||||
|
||||
mockedDismissNotification.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismiss("1");
|
||||
});
|
||||
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
expect(result.current.unreadCount).toBe(1);
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("calls refreshList when invoked", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1"), makeNotification("2")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshList();
|
||||
});
|
||||
|
||||
expect(mockedGetNotifications).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() => expect(result.current.notifications).toHaveLength(2));
|
||||
});
|
||||
|
||||
it("stops polling on 401", async () => {
|
||||
mockedGetUnreadCount.mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
const callCountAfterFirst = mockedGetUnreadCount.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
expect(mockedGetUnreadCount.mock.calls.length).toBe(callCountAfterFirst);
|
||||
});
|
||||
|
||||
it("pauses polling when document hidden", async () => {
|
||||
renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
const callCountBefore = mockedGetUnreadCount.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
expect(mockedGetUnreadCount.mock.calls.length).toBe(callCountBefore);
|
||||
|
||||
act(() => {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockedGetUnreadCount.mock.calls.length).toBeGreaterThan(
|
||||
callCountBefore,
|
||||
);
|
||||
});
|
||||
|
||||
it("multiple markRead calls decrement correctly", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [
|
||||
makeNotification("1"),
|
||||
makeNotification("2"),
|
||||
makeNotification("3"),
|
||||
],
|
||||
total: 3,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(3);
|
||||
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => expect(result.current.unreadCount).toBe(3));
|
||||
|
||||
mockedMarkNotificationRead.mockResolvedValue(
|
||||
makeNotification("1", { read_at: "2026-05-29T10:01:00Z" }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.markRead("1");
|
||||
await result.current.markRead("2");
|
||||
await result.current.markRead("3");
|
||||
});
|
||||
|
||||
expect(result.current.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { NotificationContext } from "../state/notifications";
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useNotifications must be used within NotificationProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
dismissNotification,
|
||||
} from "../api/notifications";
|
||||
import type { NotificationItem } from "../api/notifications";
|
||||
|
||||
export interface NotificationContextValue {
|
||||
notifications: NotificationItem[];
|
||||
unreadCount: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
markRead: (id: string) => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
dismiss: (id: string) => Promise<void>;
|
||||
refreshList: () => Promise<void>;
|
||||
isDropdownOpen: boolean;
|
||||
setIsDropdownOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NotificationContext =
|
||||
createContext<NotificationContextValue | null>(null);
|
||||
|
||||
const UNREAD_POLL_MS = 15000;
|
||||
const LIST_POLL_MS = 30000;
|
||||
|
||||
export function NotificationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const stateRef = useRef({
|
||||
notifications,
|
||||
unreadCount,
|
||||
isDropdownOpen,
|
||||
stopped: false,
|
||||
});
|
||||
stateRef.current = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isDropdownOpen,
|
||||
stopped: false,
|
||||
};
|
||||
|
||||
const unreadIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const listIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
if (stateRef.current.stopped) return;
|
||||
try {
|
||||
const count = await getUnreadCount();
|
||||
if (!stateRef.current.stopped) {
|
||||
setUnreadCount(count);
|
||||
}
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
if (status === 401) {
|
||||
stateRef.current.stopped = true;
|
||||
if (unreadIntervalRef.current) {
|
||||
clearInterval(unreadIntervalRef.current);
|
||||
unreadIntervalRef.current = null;
|
||||
}
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
// Silently log other errors; next cycle proceeds
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Notification unread count poll failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
if (stateRef.current.stopped) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getNotifications();
|
||||
if (!stateRef.current.stopped) {
|
||||
setNotifications(data.items);
|
||||
}
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
if (status === 401) {
|
||||
stateRef.current.stopped = true;
|
||||
if (unreadIntervalRef.current) {
|
||||
clearInterval(unreadIntervalRef.current);
|
||||
unreadIntervalRef.current = null;
|
||||
}
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Notification list poll failed", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (stateRef.current.stopped) return;
|
||||
|
||||
if (!unreadIntervalRef.current) {
|
||||
void fetchUnreadCount();
|
||||
unreadIntervalRef.current = setInterval(() => {
|
||||
void fetchUnreadCount();
|
||||
}, UNREAD_POLL_MS);
|
||||
}
|
||||
|
||||
if (!listIntervalRef.current && !stateRef.current.isDropdownOpen) {
|
||||
void fetchList();
|
||||
listIntervalRef.current = setInterval(() => {
|
||||
if (!stateRef.current.isDropdownOpen) {
|
||||
void fetchList();
|
||||
}
|
||||
}, LIST_POLL_MS);
|
||||
}
|
||||
}, [fetchUnreadCount, fetchList]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (unreadIntervalRef.current) {
|
||||
clearInterval(unreadIntervalRef.current);
|
||||
unreadIntervalRef.current = null;
|
||||
}
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle visibility changes
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
// Start/stop polling based on dropdown state
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen) {
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
void fetchList();
|
||||
} else {
|
||||
if (
|
||||
!listIntervalRef.current &&
|
||||
!document.hidden &&
|
||||
unreadIntervalRef.current
|
||||
) {
|
||||
listIntervalRef.current = setInterval(() => {
|
||||
if (!stateRef.current.isDropdownOpen) {
|
||||
void fetchList();
|
||||
}
|
||||
}, LIST_POLL_MS);
|
||||
}
|
||||
}
|
||||
}, [isDropdownOpen, fetchList]);
|
||||
|
||||
// Initial start
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
const markRead = useCallback(async (id: string) => {
|
||||
const { notifications: currentNotifications, unreadCount: currentCount } =
|
||||
stateRef.current;
|
||||
const target = currentNotifications.find((n) => n.id === id);
|
||||
const wasUnread = target ? target.read_at === null : false;
|
||||
|
||||
setNotifications(
|
||||
currentNotifications.map((n) =>
|
||||
n.id === id ? { ...n, read_at: new Date().toISOString() } : n,
|
||||
),
|
||||
);
|
||||
if (wasUnread) {
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await markNotificationRead(id);
|
||||
} catch (err) {
|
||||
setNotifications(currentNotifications);
|
||||
setUnreadCount(currentCount);
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
const { notifications: currentNotifications, unreadCount: currentCount } =
|
||||
stateRef.current;
|
||||
|
||||
setNotifications(
|
||||
currentNotifications.map((n) =>
|
||||
n.read_at === null ? { ...n, read_at: new Date().toISOString() } : n,
|
||||
),
|
||||
);
|
||||
setUnreadCount(0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
} catch (err) {
|
||||
setNotifications(currentNotifications);
|
||||
setUnreadCount(currentCount);
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(async (id: string) => {
|
||||
const { notifications: currentNotifications, unreadCount: currentCount } =
|
||||
stateRef.current;
|
||||
const target = currentNotifications.find((n) => n.id === id);
|
||||
const wasUnread = target ? target.read_at === null : false;
|
||||
|
||||
setNotifications(currentNotifications.filter((n) => n.id !== id));
|
||||
if (wasUnread) {
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await dismissNotification(id);
|
||||
} catch (err) {
|
||||
setNotifications(currentNotifications);
|
||||
setUnreadCount(currentCount);
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
await fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const value: NotificationContextValue = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
error,
|
||||
markRead,
|
||||
markAllRead,
|
||||
dismiss,
|
||||
refreshList,
|
||||
isDropdownOpen,
|
||||
setIsDropdownOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -4510,6 +4510,208 @@ a:active,
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Notification Center */
|
||||
.notification-center {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.notification-bell:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notification-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: 480px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-dropdown-header {
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notification-dropdown-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-mark-all {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.notification-mark-all:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
/* Notification Item */
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.notification-item--unread {
|
||||
font-weight: 500;
|
||||
border-left: 3px solid var(--brand);
|
||||
padding-left: calc(1rem - 3px);
|
||||
background: color-mix(in srgb, var(--brand) 4%, var(--panel));
|
||||
}
|
||||
|
||||
.notification-item--read {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.notification-item-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notification-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.notification-item-title {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.notification-item--read .notification-item-title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.notification-item-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notification-item-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-item-action {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-item-action:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.notification-dropdown {
|
||||
width: calc(100vw - 2rem);
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
|
||||
+164
-164
@@ -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[],
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user