Phase 3: UI redesign (Gitea-issues aesthetic)
Top-nav Layout replaces side-nav: brand, primary nav, global search (debounced /search), notifications bell (Popover + unread badge), user avatar DropdownMenu. Mobile hamburger collapse. New pages: - /dashboard: analytics home (open-by-severity, age buckets, queue load, median resolution) - /tickets: Gitea-style list with status tabs, severity/assignee/CTI filters, server pagination (25/page), multi-select bulk bar (reassign/close/severity), saved views CRUD - /notifications: full list with mark-all-read - /settings: profile, notification prefs grid, API key (SERVICE role) - /admin/webhooks: CRUD + rotate-secret + active toggle, reveal-once secret dialog TicketDetail: inline Popover editing for Status/Severity/Assignee (replaces modal chain), AlertDialog delete confirmation, comment draft autosave to localStorage per ticket. Admin Users: window.confirm swapped for AlertDialog on delete + rotate API key with toast feedback. React Query hooks added for paged tickets, bulk actions, notifications, webhooks, saved views, analytics, notification prefs. ThemeProvider wired (v1.0 ships dark-only; toggle deferred). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
useMarkNotificationsRead,
|
||||
} from '../api/queries';
|
||||
import type { Notification } from '../../../shared/types';
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
ASSIGNED: 'assigned to you',
|
||||
MENTION: 'mentioned you',
|
||||
RESOLVED: 'was resolved',
|
||||
TICKET_CREATED: 'was created',
|
||||
STATUS_CHANGED: 'status changed',
|
||||
COMMENT: 'new comment',
|
||||
};
|
||||
|
||||
interface NotifData {
|
||||
displayId?: string;
|
||||
title?: string;
|
||||
byName?: string;
|
||||
}
|
||||
|
||||
function renderSummary(n: Notification): { label: string; href: string } {
|
||||
const data = (n.data ?? {}) as NotifData;
|
||||
const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase();
|
||||
const label = data.title
|
||||
? `${data.title} — ${action}`
|
||||
: `Ticket ${action}`;
|
||||
const href = data.displayId ? `/${data.displayId}` : '/notifications';
|
||||
return { label, href };
|
||||
}
|
||||
|
||||
export default function NotificationsBell() {
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const { data: notifications = [] } = useNotifications();
|
||||
const markRead = useMarkNotificationsRead();
|
||||
|
||||
const latest = useMemo(() => notifications.slice(0, 8), [notifications]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className="relative p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Bell size={18} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[1rem] h-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[10px] font-semibold flex items-center justify-center">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-sm font-semibold">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markRead.mutate({ all: true })}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{latest.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
You're all caught up
|
||||
</p>
|
||||
) : (
|
||||
latest.map((n) => {
|
||||
const { label, href } = renderSummary(n);
|
||||
const unread = !n.readAt;
|
||||
return (
|
||||
<Link
|
||||
key={n.id}
|
||||
to={href}
|
||||
onClick={() => unread && markRead.mutate({ ids: [n.id] })}
|
||||
className={`flex flex-col gap-1 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-accent transition-colors ${
|
||||
unread ? 'bg-primary/5' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="line-clamp-2">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/notifications"
|
||||
className="block px-3 py-2 text-center text-xs text-primary hover:underline border-t"
|
||||
>
|
||||
View all notifications
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user