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 ( + + ); + })} +
+ ); +}