"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 ( {!collapsed && {label}} {!collapsed && count !== undefined && count > 0 && ( {count} )} ); } function SidebarNav() { const pathname = usePathname(); const searchParams = useSearchParams(); const { user: authUser } = useAuth(); const [counts, setCounts] = useState({ all: 0, my: 0, unassigned: 0, recent: 0, }); const [queues, setQueues] = useState<(Queue & { count: number })[]>([]); const [savedViews, setSavedViews] = useState([]); const [dashboards, setDashboards] = useState([]); const [myTeamId, setMyTeamId] = useState(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 ( <>
{views.map((view) => { const active = pathname === "/" && (view.param ? currentView === view.param : !currentView); return ( ); })}
{dashboards.length > 0 && (
{!collapsed && (
Dashboards
)} {dashboards.length > 0 && (
)} {addingDashboard && (
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); } }} />
)}
)} {savedViews.length > 0 && (
{!collapsed && (
Saved views
)} {savedViews.map((view) => { const active = pathname === "/" && searchParams.get("view_id") === view.id; return ( ); })}
)} {queues.length > 0 && (
{!collapsed && (
Queues
)} {queues.map((queue) => { const active = pathname === "/" && searchParams.get("queue") === queue.id; return ( ); })}
)} ); } function SidebarBottom() { const pathname = usePathname(); const collapsed = useSidebarCollapsed(); const { user, logout, isAdmin } = useAuth(); const [tokenOpen, setTokenOpen] = useState(false); const [tokens, setTokens] = useState([]); const [newTokenName, setNewTokenName] = useState(""); const [newTokenValue, setNewTokenValue] = useState(null); const [tokenError, setTokenError] = useState(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 (
{isAdmin && ( )} {user ? ( <> {!collapsed && (
{user.username} {isAdmin && (admin)}
)} {/* Token dialog */} {tokenOpen && ( <>
{ setTokenOpen(false); setNewTokenValue(null); }} />

API tokens

{newTokenValue ? (

Token created — copy it now:

{newTokenValue}

This won't be shown again.

) : (
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(); }} />
)} {tokenError &&

{tokenError}

} {tokens.length > 0 ? (
{tokens.map((t) => (

{t.name}

Created {new Date(t.created_at).toLocaleDateString()} {t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}

))}
) : (

No API tokens yet.

)}
)} ) : ( )}
); } function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) { const [unread, setUnread] = useState(0); const [notifs, setNotifs] = useState([]); 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 (
{open && ( <>
setOpen(false)} />
Notifications {unread > 0 && ( )}
{notifs.length === 0 ? (
No notifications yet.
) : ( notifs.slice(0, 20).map((n) => ( )) )}
)} {!collapsed && ( )}
); } 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 (
{/* Sidebar */} {/* Main */}
{children}
{/* Command Palette */}
{/* Collapse toggle */}
); }