From 7c01906e708f1b8af56fa07e257d2ae5e3e76c76 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 19 Apr 2026 03:10:10 -0400 Subject: [PATCH] feat(ui): redesign user menu and notification panel (#691) * feat(ui): redesign user menu and notification panel per design audit Align both floating surfaces with the stack-view design language: cyan signal rails, identity headers, strip containers, day-banded streams, severity rails, Instrument Serif for hero words, and tracked-mono for metadata. Extract the notifications popover out of EditorLayout into its own component and introduce a reusable SegmentedControl primitive for 3-way radiogroups (Auto/Light/Dark theme, All/Unread/Alerts filter). * fix(ui): pin accessible names on avatar and bell buttons After the redesign the avatar button's text content became the user's initials and the bell button's text became the unread count, which displaced the title-based accessible names and broke getByRole('button', { name: /profile/i }). Set aria-label explicitly on both triggers and mark the bell badge aria-hidden so screen readers and role-based selectors see stable names regardless of dynamic content. --- frontend/src/components/EditorLayout.tsx | 83 +---- frontend/src/components/NotificationPanel.tsx | 324 ++++++++++++++++++ .../src/components/UserProfileDropdown.tsx | 288 ++++++++++------ .../src/components/ui/segmented-control.tsx | 115 +++++++ 4 files changed, 633 insertions(+), 177 deletions(-) create mode 100644 frontend/src/components/NotificationPanel.tsx create mode 100644 frontend/src/components/ui/segmented-control.tsx diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 8cff9f78..7fd24b6c 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -20,13 +20,13 @@ import { springs } from '@/lib/motion'; import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight'; import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; import { LabelAssignPopover } from './LabelAssignPopover'; import { UserProfileDropdown } from './UserProfileDropdown'; +import { NotificationPanel } from './NotificationPanel'; import { apiFetch, fetchForNode } from '@/lib/api'; import { isValidVersion } from '@/lib/version'; import { toast } from '@/components/ui/toast-store'; @@ -37,7 +37,6 @@ import { Checkbox } from './ui/checkbox'; import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields'; import { Skeleton } from './ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; -import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -2530,75 +2529,15 @@ export default function EditorLayout() { {/* RIGHT ZONE: Utilities */}
- {/* Notifications Popover */} - - - - - -
-

Notifications

-
- {notifications.filter(n => !n.is_read).length > 0 && ( - - )} - {notifications.length > 0 && ( - - )} -
-
- - {notifications.length === 0 ? ( -
No notifications
- ) : ( -
- {notifications.map((notif) => ( -
-
- - {notif.level} - - {nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && ( - - {notif.nodeName} - - )} - - {new Date(notif.timestamp).toLocaleString()} - -
-

{notif.message}

- -
- ))} -
- )} -
-
-
+ {/* Notifications */} + + {/* User Profile Dropdown */} = { + info: { icon: Info, iconClass: 'text-info', railClass: 'bg-info' }, + warning: { icon: AlertTriangle, iconClass: 'text-warning', railClass: 'bg-warning' }, + error: { icon: AlertOctagon, iconClass: 'text-destructive', railClass: 'bg-destructive' }, +}; + +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; +const MINUTE_MS = 60_000; + +type GroupLabel = 'Today' | 'Yesterday' | 'This week' | 'Earlier'; +const GROUP_ORDER: GroupLabel[] = ['Today', 'Yesterday', 'This week', 'Earlier']; + +function startOfDay(d: Date): number { + const c = new Date(d); + c.setHours(0, 0, 0, 0); + return c.getTime(); +} + +function groupByDay(items: NotificationItem[]): { label: GroupLabel; items: NotificationItem[] }[] { + const today = startOfDay(new Date()); + const yesterday = today - DAY_MS; + const weekStart = today - 6 * DAY_MS; + + const buckets: Record = { + Today: [], + Yesterday: [], + 'This week': [], + Earlier: [], + }; + + for (const item of items) { + const ts = item.timestamp; + if (ts >= today) buckets.Today.push(item); + else if (ts >= yesterday) buckets.Yesterday.push(item); + else if (ts >= weekStart) buckets['This week'].push(item); + else buckets.Earlier.push(item); + } + + return GROUP_ORDER.map((label) => ({ label, items: buckets[label] })).filter( + (g) => g.items.length > 0, + ); +} + +function formatRelative(ms: number): string { + const diff = Date.now() - ms; + if (diff < MINUTE_MS) return 'just now'; + if (diff < HOUR_MS) return `${Math.round(diff / MINUTE_MS)}m ago`; + if (diff < DAY_MS) return `${Math.round(diff / HOUR_MS)}h ago`; + if (diff < 2 * DAY_MS) return 'yesterday'; + return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function applyFilter(items: NotificationItem[], filter: NotifFilter): NotificationItem[] { + if (filter === 'unread') return items.filter((n) => !n.is_read); + if (filter === 'alerts') return items.filter((n) => n.level === 'warning' || n.level === 'error'); + return items; +} + +interface NotificationPanelProps { + notifications: NotificationItem[]; + nodes: Node[]; + onMarkAllRead: () => void; + onClearAll: () => void; + onDelete: (notif: NotificationItem) => void; +} + +export function NotificationPanel({ + notifications, + nodes, + onMarkAllRead, + onClearAll, + onDelete, +}: NotificationPanelProps) { + const [filter, setFilter] = useState('all'); + + const unreadCount = useMemo( + () => notifications.filter((n) => !n.is_read).length, + [notifications], + ); + + const remoteNodeIds = useMemo(() => { + const ids = new Set(); + for (const n of nodes) if (n.type === 'remote') ids.add(n.id); + return ids; + }, [nodes]); + + const filtered = useMemo(() => applyFilter(notifications, filter), [notifications, filter]); + const groups = useMemo(() => groupByDay(filtered), [filtered]); + + const filterOptions = useMemo( + () => [ + { value: 'all' as const, label: 'All' }, + { + value: 'unread' as const, + label: 'Unread', + badge: unreadCount > 0 ? unreadCount : undefined, + }, + { value: 'alerts' as const, label: 'Alerts' }, + ], + [unreadCount], + ); + + const bellBadge = + unreadCount > 0 ? ( + + ) : null; + + return ( + + + + + + {/* Masthead */} +
+
+
+
+
+ + Notifications + + {unreadCount > 0 ? ( + + {unreadCount} unread + + ) : null} +
+
+ {unreadCount > 0 ? ( + + ) : null} + {notifications.length > 0 ? ( + + ) : null} +
+
+
+ + {/* Filter segment */} +
+ +
+ + {/* Stream */} + {groups.length === 0 ? ( + 0} /> + ) : ( +
+ {groups.map((group) => ( +
+
+ {group.label} +
+ {group.items.map((notif) => ( + + ))} +
+ ))} +
+ )} + + + ); +} + +interface NotificationRowProps { + notif: NotificationItem; + showNodeName: boolean; + onDelete: (notif: NotificationItem) => void; +} + +function NotificationRow({ notif, showNodeName, onDelete }: NotificationRowProps) { + const config = LEVEL_CONFIG[notif.level]; + const Icon = config.icon; + const isUnread = !notif.is_read; + + return ( +
+
+ +
+

+ {notif.message} +

+
+ {showNodeName && notif.nodeName ? ( + <> + + {notif.nodeName} + + · + + ) : null} + {formatRelative(notif.timestamp)} +
+
+ +
+ ); +} + +interface EmptyStateProps { + filter: NotifFilter; + hasAny: boolean; +} + +function EmptyState({ filter, hasAny }: EmptyStateProps) { + let title = "You're all caught up"; + let subtitle = 'New notifications appear here in real time.'; + + if (hasAny && filter === 'unread') { + title = 'No unread notifications'; + subtitle = 'Everything in your feed has been read.'; + } else if (hasAny && filter === 'alerts') { + title = 'No active alerts'; + subtitle = 'Warnings and errors will surface here when they occur.'; + } + + return ( +
+ +

{title}

+

{subtitle}

+
+ ); +} diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index 8a380a8b..f435323e 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -1,12 +1,26 @@ import { useState } from 'react'; -import { Settings, LogOut, ExternalLink, Monitor, Sun, Moon, User, Loader2 } from 'lucide-react'; +import { + Settings, + LogOut, + ExternalLink, + Monitor, + Sun, + Moon, + User, + Loader2, + BookOpen, + MessageSquare, + CreditCard, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Separator } from '@/components/ui/separator'; +import { SegmentedControl } from '@/components/ui/segmented-control'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; import { TierBadge } from './TierBadge'; type Theme = 'light' | 'dark' | 'auto'; @@ -17,6 +31,23 @@ interface UserProfileDropdownProps { onOpenSettings: () => void; } +const THEME_OPTIONS = [ + { value: 'auto' as const, label: 'Auto', icon: Monitor }, + { value: 'light' as const, label: 'Light', icon: Sun }, + { value: 'dark' as const, label: 'Dark', icon: Moon }, +]; + +function getInitials(username: string | undefined): string { + if (!username) return ''; + const trimmed = username.trim(); + if (!trimmed) return ''; + const parts = trimmed.split(/[\s._-]+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return trimmed.slice(0, 2).toUpperCase(); +} + export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { const { logout, user, isAdmin } = useAuth(); const { license } = useLicense(); @@ -39,134 +70,181 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro } }; + const showBilling = license?.status === 'active' && !license?.isLifetime; + const initials = getInitials(user?.username); + const roleLabel = user?.role; + return ( - - - {/* User Info */} -
-
-
- -
-
-

{user?.username ?? 'admin'}

-
- - {user?.role ?? 'admin'} + + {/* Identity header */} +
+
+
+
+
+ {initials ? ( + + {initials} - · + ) : ( + + )} +
+
+

+ {user?.username ?? 'admin'} +

+
+ {roleLabel ? ( + + {roleLabel} + + ) : null}
- - - {/* Navigation Links */} -
- - {license?.status === 'active' && !license?.isLifetime && ( - - )} -
- - - - {/* Theme Toggle */} -
-

Theme

-
- - - -
-
- - - - {/* Documentation Links */} - - + {/* Appearance */} +
+ + Appearance + + +
{/* Logout */} -
- +
); } + +interface MenuRowProps { + icon: LucideIcon; + label: string; + onClick?: () => void; + href?: string; + external?: boolean; + disabled?: boolean; + loading?: boolean; + trailingIcon?: LucideIcon; +} + +function MenuRow({ + icon: Icon, + label, + onClick, + href, + external, + disabled, + loading, + trailingIcon, +}: MenuRowProps) { + const TrailingIcon = trailingIcon ?? (external ? ExternalLink : undefined); + const classes = cn( + 'flex w-full items-center gap-2.5 px-5 py-2.5 text-left text-sm text-stat-value transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none', + disabled && 'pointer-events-none opacity-50', + ); + + const leadingIcon = loading ? ( + + ) : ( + + ); + + const body = ( + <> + {leadingIcon} + {label} + {TrailingIcon ? ( + + ) : null} + + ); + + if (href) { + return ( + + {body} + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/ui/segmented-control.tsx b/frontend/src/components/ui/segmented-control.tsx new file mode 100644 index 00000000..b959b8be --- /dev/null +++ b/frontend/src/components/ui/segmented-control.tsx @@ -0,0 +1,115 @@ +import { useRef } from 'react'; +import type { KeyboardEvent } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface SegmentedControlOption { + value: T; + label: string; + icon?: LucideIcon; + badge?: string | number; +} + +interface SegmentedControlProps { + value: T; + options: SegmentedControlOption[]; + onChange: (next: T) => void; + ariaLabel?: string; + iconOnly?: boolean; + className?: string; +} + +export function SegmentedControl({ + value, + options, + onChange, + ariaLabel, + iconOnly, + className, +}: SegmentedControlProps) { + const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]); + + const focusIndex = (index: number) => { + const clamped = (index + options.length) % options.length; + const target = buttonsRef.current[clamped]; + const opt = options[clamped]; + if (target && opt) { + target.focus(); + onChange(opt.value); + } + }; + + const handleKeyDown = (event: KeyboardEvent, current: number) => { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault(); + focusIndex(current + 1); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault(); + focusIndex(current - 1); + } else if (event.key === 'Home') { + event.preventDefault(); + focusIndex(0); + } else if (event.key === 'End') { + event.preventDefault(); + focusIndex(options.length - 1); + } + }; + + return ( +
+ {options.map((opt, index) => { + const active = value === opt.value; + const Icon = opt.icon; + const hasBadge = opt.badge !== undefined && opt.badge !== ''; + const a11yLabel = iconOnly + ? opt.label + : hasBadge + ? `${opt.label}, ${opt.badge}` + : undefined; + return ( + + ); + })} +
+ ); +}