mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
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.
This commit is contained in:
@@ -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 */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
{/* Notifications Popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg relative" title="Notifications">
|
||||
<Bell className="w-4 h-4" />
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h4 className="font-medium">Notifications</h4>
|
||||
<div className="flex gap-2">
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{notifications.map((notif) => (
|
||||
<div key={`${notif.nodeId}-${notif.id}`} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
|
||||
<div className="flex items-center gap-2 mb-1 pr-6">
|
||||
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
|
||||
{notif.level}
|
||||
</Badge>
|
||||
{nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{notif.nodeName}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(notif.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium pr-6">{notif.message}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNotification(notif);
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Notifications */}
|
||||
<NotificationPanel
|
||||
notifications={notifications}
|
||||
nodes={nodes}
|
||||
onMarkAllRead={markAllRead}
|
||||
onClearAll={clearAllNotifications}
|
||||
onDelete={deleteNotification}
|
||||
/>
|
||||
|
||||
|
||||
{/* User Profile Dropdown */}
|
||||
<UserProfileDropdown
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
X,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
type NotifFilter = 'all' | 'unread' | 'alerts';
|
||||
|
||||
type LevelConfig = {
|
||||
icon: LucideIcon;
|
||||
iconClass: string;
|
||||
railClass: string;
|
||||
};
|
||||
|
||||
const LEVEL_CONFIG: Record<NotificationItem['level'], LevelConfig> = {
|
||||
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<GroupLabel, NotificationItem[]> = {
|
||||
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<NotifFilter>('all');
|
||||
|
||||
const unreadCount = useMemo(
|
||||
() => notifications.filter((n) => !n.is_read).length,
|
||||
[notifications],
|
||||
);
|
||||
|
||||
const remoteNodeIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
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 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 flex min-h-[16px] min-w-[16px] items-center justify-center rounded-full bg-destructive px-1 font-mono text-[9px] font-semibold tabular-nums leading-none text-destructive-foreground"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-8 w-8 rounded-lg"
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="h-4 w-4" strokeWidth={1.5} />
|
||||
{bellBadge}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[360px] overflow-hidden rounded-md p-0"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* Masthead */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center justify-between px-5 py-3.5">
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||
Notifications
|
||||
</span>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.14em] tabular-nums text-brand">
|
||||
{unreadCount} unread
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{unreadCount > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMarkAllRead}
|
||||
className="h-7 px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle hover:text-stat-value"
|
||||
>
|
||||
Mark read
|
||||
</Button>
|
||||
) : null}
|
||||
{notifications.length > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClearAll}
|
||||
className="h-7 w-7 text-stat-subtitle hover:text-destructive"
|
||||
title="Clear all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter segment */}
|
||||
<div className="flex items-center justify-end border-t border-card-border/60 px-5 py-2.5">
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
options={filterOptions}
|
||||
onChange={setFilter}
|
||||
ariaLabel="Filter notifications"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stream */}
|
||||
{groups.length === 0 ? (
|
||||
<EmptyState filter={filter} hasAny={notifications.length > 0} />
|
||||
) : (
|
||||
<div className="max-h-[480px] overflow-y-auto border-t border-card-border/60">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="sticky top-0 z-10 border-b border-card-border/40 bg-popover/95 px-5 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((notif) => (
|
||||
<NotificationRow
|
||||
key={`${notif.nodeId ?? 'local'}:${notif.level}:${notif.id}`}
|
||||
notif={notif}
|
||||
showNodeName={
|
||||
notif.nodeId !== undefined && remoteNodeIds.has(notif.nodeId)
|
||||
}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="group relative flex items-start gap-3 border-b border-card-border/40 px-5 py-3 transition-colors last:border-b-0 hover:bg-accent/40">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 w-[3px] transition-opacity',
|
||||
config.railClass,
|
||||
isUnread ? 'opacity-100' : 'opacity-30',
|
||||
)}
|
||||
/>
|
||||
<Icon
|
||||
className={cn('mt-0.5 h-4 w-4 flex-shrink-0', config.iconClass)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
'break-words pr-6 text-sm leading-snug',
|
||||
isUnread ? 'text-stat-value' : 'text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{notif.message}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
{showNodeName && notif.nodeName ? (
|
||||
<>
|
||||
<span className="rounded-sm border border-card-border bg-muted/40 px-1.5 py-0.5 normal-case tracking-normal text-stat-subtitle">
|
||||
{notif.nodeName}
|
||||
</span>
|
||||
<span className="text-stat-icon">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="tabular-nums">{formatRelative(notif.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-2 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(notif);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-2 border-t border-card-border/60 px-5 py-12 text-center">
|
||||
<BellOff className="h-8 w-8 text-stat-icon" strokeWidth={1.5} />
|
||||
<p className="text-sm text-stat-value">{title}</p>
|
||||
<p className="font-mono text-[11px] text-stat-subtitle">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="rounded-full w-9 h-9" title="Profile">
|
||||
<User className="w-4 h-4" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full p-0 font-mono text-[11px] font-semibold uppercase tracking-wider"
|
||||
title="Profile"
|
||||
aria-label="Profile"
|
||||
>
|
||||
{initials ? initials : <User className="h-4 w-4" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0 rounded-xl" align="end" sideOffset={8}>
|
||||
{/* User Info */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{user?.username ?? 'admin'}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium uppercase ${isAdmin ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{user?.role ?? 'admin'}
|
||||
<PopoverContent
|
||||
className="w-72 overflow-hidden rounded-md p-0"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* Identity header */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center gap-3 px-5 py-4">
|
||||
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full border border-brand/25 bg-brand/10">
|
||||
{initials ? (
|
||||
<span className="font-display text-lg leading-none text-brand">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
) : (
|
||||
<User className="h-5 w-5 text-brand" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-stat-value">
|
||||
{user?.username ?? 'admin'}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
{roleLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-sm px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.14em]',
|
||||
isAdmin
|
||||
? 'bg-brand/10 text-brand'
|
||||
: 'bg-muted text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<TierBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<Settings className="w-4 h-4 text-muted-foreground" />
|
||||
Settings
|
||||
</button>
|
||||
{license?.status === 'active' && !license?.isLifetime && (
|
||||
<button
|
||||
{/* Navigation strip */}
|
||||
<div className="border-t border-card-border/60">
|
||||
<MenuRow icon={Settings} label="Settings" onClick={onOpenSettings} />
|
||||
{showBilling ? (
|
||||
<MenuRow
|
||||
icon={CreditCard}
|
||||
label="Billing"
|
||||
onClick={openBillingPortal}
|
||||
disabled={billingLoading}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors text-left disabled:opacity-50"
|
||||
>
|
||||
{billingLoading ? (
|
||||
<Loader2 className="w-4 h-4 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
Billing
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Theme</p>
|
||||
<div className="flex gap-1 bg-muted/50 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setTheme('auto')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-xs transition-colors ${
|
||||
theme === 'auto' ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-xs transition-colors ${
|
||||
theme === 'light' ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-xs transition-colors ${
|
||||
theme === 'dark' ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Documentation Links */}
|
||||
<div className="p-1">
|
||||
<a
|
||||
loading={billingLoading}
|
||||
trailingIcon={ExternalLink}
|
||||
/>
|
||||
) : null}
|
||||
<MenuRow
|
||||
icon={BookOpen}
|
||||
label="Documentation"
|
||||
href="https://docs.sencho.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||
Documentation
|
||||
</a>
|
||||
<a
|
||||
external
|
||||
/>
|
||||
<MenuRow
|
||||
icon={MessageSquare}
|
||||
label="Feedback"
|
||||
href="https://github.com/AnsoCode/Sencho/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||
Feedback
|
||||
</a>
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
{/* Appearance */}
|
||||
<div className="flex items-center justify-between gap-3 border-t border-card-border/60 px-5 py-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Appearance
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
options={THEME_OPTIONS}
|
||||
onChange={setTheme}
|
||||
iconOnly
|
||||
ariaLabel="Theme"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
<div className="border-t border-card-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2.5 px-5 py-3 text-left text-sm text-destructive transition-colors hover:bg-destructive/5 focus-visible:bg-destructive/5 focus-visible:outline-none"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
<LogOut className="h-4 w-4" strokeWidth={1.5} />
|
||||
Log Out
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-stat-icon" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Icon className="h-4 w-4 text-stat-icon" strokeWidth={1.5} />
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{leadingIcon}
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{TrailingIcon ? (
|
||||
<TrailingIcon className="h-3 w-3 text-stat-icon" strokeWidth={1.5} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className={classes}
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled} className={classes}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
badge?: string | number;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T;
|
||||
options: SegmentedControlOption<T>[];
|
||||
onChange: (next: T) => void;
|
||||
ariaLabel?: string;
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
iconOnly,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
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<HTMLButtonElement>, 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 (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border border-card-border bg-card p-0.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={opt.value}
|
||||
ref={(el) => {
|
||||
buttonsRef.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={a11yLabel}
|
||||
title={iconOnly ? opt.label : undefined}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(opt.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
active
|
||||
? 'bg-brand/10 text-brand'
|
||||
: 'text-stat-subtitle hover:text-stat-value',
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" strokeWidth={1.5} /> : null}
|
||||
{iconOnly ? null : <span>{opt.label}</span>}
|
||||
{hasBadge ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'ml-0.5 rounded-sm px-1 text-[9px] tabular-nums',
|
||||
active ? 'bg-brand/20 text-brand' : 'bg-muted text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{opt.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user