- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
681 lines
24 KiB
TypeScript
681 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, Suspense, createContext, useContext } from "react";
|
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
import Link from "next/link";
|
|
import {
|
|
BellIcon,
|
|
CircleIcon,
|
|
LayoutGridIcon,
|
|
UserIcon,
|
|
UsersIcon,
|
|
InboxIcon,
|
|
ClockIcon,
|
|
SettingsIcon,
|
|
PanelLeftCloseIcon,
|
|
PanelLeftIcon,
|
|
CommandIcon,
|
|
} from "lucide-react";
|
|
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard, getUnreadCount, getNotifications, markNotificationRead, markAllNotificationsRead, getApiTokens, createApiToken, revokeApiToken } from "@/lib/api";
|
|
import type { Dashboard, Queue, SavedView, Team, User, Notification, ApiToken } from "@/lib/types";
|
|
import { CommandPalette } from "@/components/command-palette";
|
|
import { ThemeToggle } from "@/components/theme-toggle";
|
|
import { useAuth } from "@/lib/auth-context";
|
|
import { cn, formatTicketId } from "@/lib/utils";
|
|
|
|
const SidebarCollapsedContext = createContext(false);
|
|
|
|
function useSidebarCollapsed() {
|
|
return useContext(SidebarCollapsedContext);
|
|
}
|
|
|
|
interface ViewCounts {
|
|
all: number;
|
|
my: number;
|
|
unassigned: number;
|
|
recent: number;
|
|
}
|
|
|
|
function SidebarNavItem({
|
|
href,
|
|
icon: Icon,
|
|
label,
|
|
count,
|
|
active,
|
|
}: {
|
|
href: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
label: string;
|
|
count?: number;
|
|
active: boolean;
|
|
}) {
|
|
const collapsed = useSidebarCollapsed();
|
|
|
|
return (
|
|
<Link
|
|
href={href}
|
|
title={collapsed ? label : undefined}
|
|
className={cn(
|
|
"group flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
|
collapsed ? "justify-center w-full" : "justify-between",
|
|
active
|
|
? "bg-sidebar-accent text-sidebar-foreground font-medium"
|
|
: "text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 font-normal"
|
|
)}
|
|
>
|
|
<span className={cn("flex items-center min-w-0", collapsed ? "" : "gap-2.5")}>
|
|
<Icon className={cn("w-4 h-4 flex-shrink-0", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
|
|
{!collapsed && <span className="truncate">{label}</span>}
|
|
</span>
|
|
{!collapsed && count !== undefined && count > 0 && (
|
|
<span className="min-w-5 rounded px-1 text-right text-[11px] tabular-nums text-sidebar-foreground/35">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function SidebarNav() {
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const { user: authUser } = useAuth();
|
|
|
|
const [counts, setCounts] = useState<ViewCounts>({
|
|
all: 0,
|
|
my: 0,
|
|
unassigned: 0,
|
|
recent: 0,
|
|
});
|
|
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
|
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
|
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
|
const [newDashboardName, setNewDashboardName] = useState("");
|
|
const [addingDashboard, setAddingDashboard] = useState(false);
|
|
|
|
const currentUserId = authUser?.id ?? null;
|
|
|
|
useEffect(() => {
|
|
async function load() {
|
|
const myId = currentUserId;
|
|
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
|
const data = ticketRes.data;
|
|
|
|
if (data) {
|
|
const now = Date.now();
|
|
const week = 7 * 24 * 60 * 60 * 1000;
|
|
setCounts({
|
|
all: data.length,
|
|
my: myId ? data.filter((t) => t.owner_id === myId).length : 0,
|
|
unassigned: data.filter((t) => !t.owner_id).length,
|
|
recent: data.filter((t) => new Date(t.updated_at).getTime() > now - week).length,
|
|
});
|
|
}
|
|
|
|
// Queues
|
|
const queueRes = await getQueues();
|
|
if (queueRes.data) {
|
|
const qs = await Promise.all(
|
|
queueRes.data.map((q) =>
|
|
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
|
|
...q,
|
|
count: tickets?.length ?? 0,
|
|
}))
|
|
)
|
|
);
|
|
setQueues(qs);
|
|
}
|
|
|
|
// Views
|
|
const viewRes = await getViews();
|
|
if (viewRes.data) setSavedViews(viewRes.data);
|
|
|
|
// Dashboards scoped to user's teams
|
|
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
|
|
const allDashboards = dashRes.data ?? [];
|
|
const allTeams = teamRes.data ?? [];
|
|
const userTeams = myId
|
|
? allTeams.filter((t) => (t.members ?? []).some((m) => m.id === myId))
|
|
: [];
|
|
setMyTeamId(userTeams[0]?.id ?? null);
|
|
const teamIds = new Set(userTeams.map((t) => t.id));
|
|
const visible = allDashboards.filter((d) =>
|
|
!d.team_id || teamIds.has(d.team_id)
|
|
);
|
|
setDashboards(visible);
|
|
}
|
|
void load();
|
|
}, [currentUserId]);
|
|
|
|
const collapsed = useSidebarCollapsed();
|
|
|
|
const views = [
|
|
{
|
|
label: "All tickets",
|
|
href: "/?view=all",
|
|
param: "all",
|
|
count: counts.all,
|
|
icon: LayoutGridIcon,
|
|
},
|
|
{
|
|
label: "My tickets",
|
|
href: currentUserId ? `/?view=my&owner=${currentUserId}` : "/?view=my",
|
|
param: "my",
|
|
count: counts.my,
|
|
icon: UserIcon,
|
|
},
|
|
...(myTeamId ? [{
|
|
label: "My team's tickets",
|
|
href: `/?view=team&team_id=${myTeamId}`,
|
|
param: "team",
|
|
count: undefined as number | undefined,
|
|
icon: UsersIcon,
|
|
}] : []),
|
|
{
|
|
label: "Unassigned",
|
|
href: "/?view=unassigned",
|
|
param: "unassigned",
|
|
count: counts.unassigned,
|
|
icon: InboxIcon,
|
|
},
|
|
{
|
|
label: "Recently updated",
|
|
href: "/?view=recent",
|
|
param: "recent",
|
|
count: counts.recent,
|
|
icon: ClockIcon,
|
|
},
|
|
];
|
|
|
|
const currentView = searchParams.get("view");
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-4">
|
|
{views.map((view) => {
|
|
const active =
|
|
pathname === "/" &&
|
|
(view.param ? currentView === view.param : !currentView);
|
|
return (
|
|
<SidebarNavItem
|
|
key={view.label}
|
|
href={view.href}
|
|
icon={view.icon}
|
|
label={view.label}
|
|
count={view.count}
|
|
active={active}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{dashboards.length > 0 && (
|
|
<div className="mt-5">
|
|
{!collapsed && (
|
|
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
|
Dashboards
|
|
</div>
|
|
)}
|
|
{dashboards.length > 0 && (
|
|
<div className="px-2.5">
|
|
<select
|
|
value={dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? ""}
|
|
onChange={(e) => {
|
|
const id = e.target.value;
|
|
if (id === "_new") {
|
|
setAddingDashboard(true);
|
|
e.target.value = dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? "";
|
|
return;
|
|
}
|
|
if (id) window.location.href = `/dashboards/${id}`;
|
|
}}
|
|
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
|
|
>
|
|
{dashboards.map((dash) => (
|
|
<option key={dash.id} value={dash.id}>{dash.name}</option>
|
|
))}
|
|
<option value="_new">+ New dashboard</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
{addingDashboard && (
|
|
<div className="mt-1 px-2">
|
|
<input
|
|
value={newDashboardName}
|
|
onChange={(e) => setNewDashboardName(e.target.value)}
|
|
placeholder="Dashboard name"
|
|
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
|
|
autoFocus
|
|
onKeyDown={async (e) => {
|
|
if (e.key === "Enter" && newDashboardName.trim()) {
|
|
const { data } = await createDashboard({ name: newDashboardName.trim(), is_default: false });
|
|
if (data) {
|
|
setDashboards((prev) => [...prev, data]);
|
|
setNewDashboardName("");
|
|
setAddingDashboard(false);
|
|
window.location.href = `/dashboards/${data.id}`;
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
setNewDashboardName("");
|
|
setAddingDashboard(false);
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
if (!newDashboardName.trim()) {
|
|
setNewDashboardName("");
|
|
setAddingDashboard(false);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{savedViews.length > 0 && (
|
|
<div className="mt-5">
|
|
{!collapsed && (
|
|
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
|
Saved views
|
|
</div>
|
|
)}
|
|
{savedViews.map((view) => {
|
|
const active =
|
|
pathname === "/" && searchParams.get("view_id") === view.id;
|
|
return (
|
|
<SidebarNavItem
|
|
key={view.id}
|
|
href={`/?view_id=${view.id}`}
|
|
icon={ClockIcon}
|
|
label={view.name}
|
|
active={active}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{queues.length > 0 && (
|
|
<div className="mt-5">
|
|
{!collapsed && (
|
|
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
|
Queues
|
|
</div>
|
|
)}
|
|
{queues.map((queue) => {
|
|
const active =
|
|
pathname === "/" && searchParams.get("queue") === queue.id;
|
|
return (
|
|
<SidebarNavItem
|
|
key={queue.id}
|
|
href={`/?queue=${queue.id}`}
|
|
icon={CircleIcon}
|
|
label={queue.name}
|
|
count={queue.count}
|
|
active={active}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SidebarBottom() {
|
|
const pathname = usePathname();
|
|
const collapsed = useSidebarCollapsed();
|
|
const { user, logout, isAdmin } = useAuth();
|
|
const [tokenOpen, setTokenOpen] = useState(false);
|
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
|
const [newTokenName, setNewTokenName] = useState("");
|
|
const [newTokenValue, setNewTokenValue] = useState<string | null>(null);
|
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
|
|
const loadTokens = async () => {
|
|
const { data } = await getApiTokens();
|
|
if (data) setTokens(data);
|
|
};
|
|
|
|
useEffect(() => { if (tokenOpen) { void loadTokens(); } }, [tokenOpen]);
|
|
|
|
const handleCreateToken = async () => {
|
|
if (!newTokenName.trim()) return;
|
|
setTokenError(null);
|
|
const { data, error } = await createApiToken(newTokenName.trim());
|
|
if (error) { setTokenError(error); return; }
|
|
if (data) {
|
|
setNewTokenValue(data.token);
|
|
setNewTokenName("");
|
|
await loadTokens();
|
|
}
|
|
};
|
|
|
|
const handleRevoke = async (id: string) => {
|
|
await revokeApiToken(id);
|
|
await loadTokens();
|
|
};
|
|
|
|
return (
|
|
<div className="border-t border-sidebar-border/50 p-2 space-y-1">
|
|
{isAdmin && (
|
|
<SidebarNavItem
|
|
href="/admin"
|
|
icon={SettingsIcon}
|
|
label="Admin"
|
|
active={pathname === "/admin"}
|
|
/>
|
|
)}
|
|
{user ? (
|
|
<>
|
|
{!collapsed && (
|
|
<div className="px-2.5 py-1 text-[11px] text-sidebar-foreground/50 truncate">
|
|
{user.username}
|
|
{isAdmin && <span className="ml-1 text-[10px] text-sidebar-foreground/30">(admin)</span>}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => setTokenOpen(true)}
|
|
className={cn(
|
|
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
|
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
|
collapsed ? "justify-center" : ""
|
|
)}
|
|
>
|
|
<span className="opacity-50">API tokens</span>
|
|
</button>
|
|
<button
|
|
onClick={logout}
|
|
className={cn(
|
|
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
|
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
|
collapsed ? "justify-center" : ""
|
|
)}
|
|
>
|
|
<span className="opacity-50">Sign out</span>
|
|
</button>
|
|
|
|
{/* Token dialog */}
|
|
{tokenOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { setTokenOpen(false); setNewTokenValue(null); }} />
|
|
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
|
<div className="border-b border-border/50 px-4 py-3">
|
|
<h3 className="text-sm font-semibold text-foreground">API tokens</h3>
|
|
</div>
|
|
<div className="p-4 space-y-3">
|
|
{newTokenValue ? (
|
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
|
<p className="text-xs font-semibold text-foreground">Token created — copy it now:</p>
|
|
<pre className="mt-1.5 select-all rounded bg-background px-2 py-1.5 font-mono text-xs break-all">{newTokenValue}</pre>
|
|
<p className="mt-1 text-[10px] text-muted-foreground">This won't be shown again.</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
value={newTokenName}
|
|
onChange={(e) => setNewTokenName(e.target.value)}
|
|
placeholder="Token name..."
|
|
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateToken(); }}
|
|
/>
|
|
<button
|
|
onClick={handleCreateToken}
|
|
disabled={!newTokenName.trim()}
|
|
className="h-7 rounded-md bg-primary px-2.5 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
|
>
|
|
Create
|
|
</button>
|
|
</div>
|
|
)}
|
|
{tokenError && <p className="text-xs text-destructive">{tokenError}</p>}
|
|
{tokens.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{tokens.map((t) => (
|
|
<div key={t.id} className="flex items-center justify-between rounded-md border border-border/30 px-2.5 py-1.5">
|
|
<div>
|
|
<p className="text-xs font-medium">{t.name}</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Created {new Date(t.created_at).toLocaleDateString()}
|
|
{t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRevoke(t.id)}
|
|
className="text-[10px] text-muted-foreground hover:text-destructive"
|
|
>
|
|
Revoke
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">No API tokens yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<SidebarNavItem
|
|
href="/login"
|
|
icon={UserIcon}
|
|
label="Sign in"
|
|
active={pathname === "/login"}
|
|
/>
|
|
)}
|
|
<div className={cn("flex", collapsed ? "justify-center" : "px-1")}>
|
|
<ThemeToggle />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) {
|
|
const [unread, setUnread] = useState(0);
|
|
const [notifs, setNotifs] = useState<Notification[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const { user } = useAuth();
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
const load = async () => {
|
|
const [countRes, notifRes] = await Promise.all([getUnreadCount(), getNotifications()]);
|
|
if (countRes.data) setUnread(countRes.data.count);
|
|
if (notifRes.data) setNotifs(notifRes.data);
|
|
};
|
|
void load();
|
|
// Poll every 30s
|
|
const interval = setInterval(() => { void load(); }, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [user]);
|
|
|
|
if (!user) return null;
|
|
|
|
const handleMarkRead = async (id: string) => {
|
|
await markNotificationRead(id);
|
|
setUnread((c) => Math.max(0, c - 1));
|
|
setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
|
};
|
|
|
|
const handleMarkAll = async () => {
|
|
await markAllNotificationsRead();
|
|
setUnread(0);
|
|
setNotifs((prev) => prev.map((n) => ({ ...n, read: true })));
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="relative flex h-7 w-7 items-center justify-center rounded text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors"
|
|
>
|
|
<BellIcon className="h-4 w-4" />
|
|
{unread > 0 && (
|
|
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground">
|
|
{unread > 99 ? '99+' : unread}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<>
|
|
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
|
<div className="absolute right-0 top-full z-40 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg">
|
|
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
|
|
<span className="text-xs font-semibold text-foreground">Notifications</span>
|
|
{unread > 0 && (
|
|
<button onClick={handleMarkAll} className="text-[10px] text-muted-foreground hover:text-foreground">
|
|
Mark all read
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-80 overflow-auto">
|
|
{notifs.length === 0 ? (
|
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
|
No notifications yet.
|
|
</div>
|
|
) : (
|
|
notifs.slice(0, 20).map((n) => (
|
|
<button
|
|
key={n.id}
|
|
onClick={() => {
|
|
handleMarkRead(n.id);
|
|
if (n.ticket_id) window.location.href = `/tickets/${n.ticket_id}`;
|
|
}}
|
|
className={cn(
|
|
"w-full border-b border-border/30 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
|
|
!n.read && "bg-primary/5"
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className={cn(
|
|
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
|
n.read ? "bg-border" : "bg-primary"
|
|
)} />
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium text-foreground">{n.title}</p>
|
|
{n.body && (
|
|
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{n.body}</p>
|
|
)}
|
|
{n.ticket_id && (
|
|
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
|
|
{formatTicketId(n.ticket_id)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{!collapsed && (
|
|
<button
|
|
onClick={() => setCommandOpen(true)}
|
|
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
|
aria-label="Open command palette"
|
|
>
|
|
<CommandIcon className="h-3 w-3" />K
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
|
const [commandOpen, setCommandOpen] = useState(false);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const down = (e: KeyboardEvent) => {
|
|
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
|
|
if (
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
(e.target instanceof HTMLElement && e.target.isContentEditable)
|
|
) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
setCommandOpen((o) => !o);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", down);
|
|
return () => document.removeEventListener("keydown", down);
|
|
}, []);
|
|
|
|
return (
|
|
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
|
|
<div className="flex h-screen overflow-hidden bg-background">
|
|
{/* Sidebar */}
|
|
<aside
|
|
className={cn(
|
|
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border/50 transition-all duration-200",
|
|
sidebarCollapsed ? "w-[56px]" : "w-[232px]"
|
|
)}
|
|
>
|
|
{/* Brand */}
|
|
<div className="h-12 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border/50">
|
|
<Link href="/" className="flex items-center gap-2.5">
|
|
<div className="w-6 h-6 rounded-md bg-sidebar-primary flex items-center justify-center">
|
|
<span className="text-sidebar-primary-foreground text-[11px] font-bold">T</span>
|
|
</div>
|
|
{!sidebarCollapsed && (
|
|
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
|
)}
|
|
</Link>
|
|
<NotificationBell collapsed={sidebarCollapsed} setCommandOpen={setCommandOpen} />
|
|
</div>
|
|
|
|
{/* Nav */}
|
|
<nav className="flex-1 overflow-y-auto py-2.5 px-2">
|
|
<Suspense
|
|
fallback={
|
|
<div className="space-y-1.5 px-2">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="h-7 bg-sidebar-accent rounded-md animate-pulse"
|
|
/>
|
|
))}
|
|
</div>
|
|
}
|
|
>
|
|
<SidebarNav />
|
|
</Suspense>
|
|
</nav>
|
|
|
|
{/* Bottom */}
|
|
<SidebarBottom />
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<main className="flex-1 overflow-hidden bg-background/88">{children}</main>
|
|
|
|
{/* Command Palette */}
|
|
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
|
</div>
|
|
|
|
{/* Collapse toggle */}
|
|
<button
|
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-sidebar-border border-l-0 text-sidebar-foreground/55 hover:text-sidebar-foreground transition-all duration-150"
|
|
style={{ left: sidebarCollapsed ? 60 : 240 }}
|
|
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
>
|
|
{sidebarCollapsed ? (
|
|
<PanelLeftIcon className="w-3.5 h-3.5" />
|
|
) : (
|
|
<PanelLeftCloseIcon className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
</SidebarCollapsedContext.Provider>
|
|
);
|
|
}
|