A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
13 kB · 450 lines
TSX
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451"use client";
import { useEffect, useState } from "react";import { IconDashboard, IconFileDescription, IconDatabase, IconDatabaseCog, IconTable, IconClipboardList, IconUsers, IconLogout, IconKey, IconVariable, IconTag, IconPuzzle, IconSettings, IconInfoCircle, IconApps, IconArrowUpCircle, IconArrowsShuffle, IconCode, IconSkull, IconFlask, IconFingerprint, IconPlayerPlay, IconLink, IconShieldLock, IconChartBar,} from "@tabler/icons-react";import Image from "next/image";import Link from "next/link";import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth-context";import { useConfig } from "@/lib/config-context";import { useCurrentUser } from "@/hooks/use-current-user";import { usePluginUpdates } from "@/components/plugin-update-provider";import { useRestart } from "@/lib/restart-context";import { getDbInfo } from "@/lib/api";import { Scroller } from "@/components/ui/scroller";import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator,} from "@/components/ui/sidebar";
type NavItem = { title: string; url: string; icon: React.ComponentType; requiredPermissions?: string[];};
const dataItems: NavItem[] = [ { title: "Lexicons", url: "/dashboard/lexicons", icon: IconFileDescription }, { title: "Records", url: "/dashboard/records", icon: IconTable }, { title: "Backfill", url: "/dashboard/backfill", icon: IconDatabase }, { title: "Jobs", url: "/dashboard/jobs", icon: IconPlayerPlay, requiredPermissions: ["jobs:read"], }, { title: "Dead Letters", url: "/dashboard/dead-letters", icon: IconSkull, requiredPermissions: ["dead-letters:read"], },];
const accessItems: NavItem[] = [ { title: "Users", url: "/dashboard/settings/users", icon: IconUsers, requiredPermissions: ["users:read"], }, { title: "Linked Repos", url: "/dashboard/settings/linked-repos", icon: IconLink, requiredPermissions: ["linked-repos:read"], }, { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"], }, { title: "API Clients", url: "/dashboard/settings/api-clients", icon: IconApps, requiredPermissions: ["api-clients:view"], },];
const integrationItems: NavItem[] = [ { title: "Scripts", url: "/dashboard/settings/scripts", icon: IconCode, requiredPermissions: ["scripts:read"], }, { title: "Plugins", url: "/dashboard/settings/plugins", icon: IconPuzzle, requiredPermissions: ["plugins:read"], }, { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"], },];
const systemItems: NavItem[] = [ { title: "General", url: "/dashboard/settings/general", icon: IconSettings, requiredPermissions: ["settings:manage"], }, { title: "Database", url: "/dashboard/settings/database", icon: IconDatabaseCog, requiredPermissions: ["settings:manage"], }, { title: "Telemetry", url: "/dashboard/settings/telemetry", icon: IconChartBar, requiredPermissions: ["settings:manage"], }, { title: "Service Identity", url: "/dashboard/settings/service-identity", icon: IconFingerprint, requiredPermissions: ["settings:manage"], }, { title: "OAuth Keys", url: "/dashboard/settings/oauth-keys", icon: IconShieldLock, requiredPermissions: ["settings:manage"], }, { title: "XRPC Proxy", url: "/dashboard/settings/xrpc-proxy", icon: IconArrowsShuffle, requiredPermissions: ["settings:manage"], }, { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"], }, { title: "Event Logs", url: "/dashboard/events", icon: IconClipboardList, requiredPermissions: ["events:read"], }, { title: "Experiments", url: "/dashboard/settings/experiments", icon: IconFlask, requiredPermissions: ["settings:manage"], },];
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { const pathname = usePathname(); const { logout } = useAuth(); const { app_name, logo_url } = useConfig(); const { hasPermission } = useCurrentUser(); const { hasUpdates } = usePluginUpdates(); const { addReason, removeReason } = useRestart();
const [deadLetterCount, setDeadLetterCount] = useState(0);
useEffect(() => { let cancelled = false; async function fetchCount() { try { const { getDeadLetterCount } = await import("@/lib/api"); const data = await getDeadLetterCount("false"); if (!cancelled) setDeadLetterCount(data.count); } catch { // sidebar badge is best-effort } } fetchCount(); const interval = setInterval(fetchCount, 30000); return () => { cancelled = true; clearInterval(interval); }; }, []);
useEffect(() => { if (!hasPermission("settings:manage")) return; let cancelled = false; getDbInfo() .then((info) => { if (!cancelled) { if (info.restart_recommended) { addReason( "backfill-pool", "Backfill pool is undersized for current concurrency settings.", ); } else { removeReason("backfill-pool"); } } }) .catch(() => {}); return () => { cancelled = true; }; }, [hasPermission, addReason, removeReason]);
function filterByPermission(items: NavItem[]) { return items.filter( (item) => !item.requiredPermissions || item.requiredPermissions.some((perm) => hasPermission(perm)), ); }
function isActive(url: string) { return url === "/dashboard" ? pathname === "/dashboard" : pathname.startsWith(url); }
const visibleData = filterByPermission(dataItems); const visibleAccess = filterByPermission(accessItems); const visibleIntegrations = filterByPermission(integrationItems); const visibleSystem = filterByPermission(systemItems);
return ( <Sidebar collapsible="offcanvas" {...props}> <SidebarHeader className="flex items-center justify-center p-4"> {logo_url ? ( <Image src={logo_url} alt={app_name ?? "HappyView"} width={140} height={48} className="object-contain" unoptimized /> ) : ( <> <Image src="/logo.light.png" alt="HappyView" width={140} height={48} className="block dark:hidden" /> <Image src="/logo.dark.png" alt="HappyView" width={140} height={48} className="hidden dark:block" /> </> )} </SidebarHeader> <SidebarSeparator className="!mx-0" /> <Scroller asChild hideScrollbar> <SidebarContent> <SidebarGroup> <SidebarGroupContent> <SidebarMenu> <SidebarMenuItem> <SidebarMenuButton asChild tooltip="Dashboard" isActive={isActive("/dashboard")} > <Link href="/dashboard"> <IconDashboard /> <span>Dashboard</span> </Link> </SidebarMenuButton> </SidebarMenuItem> </SidebarMenu> </SidebarGroupContent> </SidebarGroup>
{visibleData.length > 0 && ( <SidebarGroup> <SidebarGroupLabel>Data</SidebarGroupLabel> <SidebarGroupContent> <SidebarMenu> {visibleData.map((item) => { const showDeadLetterBadge = item.title === "Dead Letters" && deadLetterCount > 0; return ( <SidebarMenuItem key={item.title}> <SidebarMenuButton asChild tooltip={item.title} isActive={isActive(item.url)} > <Link href={item.url}> <item.icon /> <span>{item.title}</span> {showDeadLetterBadge && ( <span className="ml-auto rounded-full bg-destructive px-1.5 py-0.5 text-[10px] font-medium text-destructive-foreground"> {deadLetterCount} </span> )} </Link> </SidebarMenuButton> </SidebarMenuItem> ); })} </SidebarMenu> </SidebarGroupContent> </SidebarGroup> )}
{visibleAccess.length > 0 && ( <SidebarGroup> <SidebarGroupLabel>Access</SidebarGroupLabel> <SidebarGroupContent> <SidebarMenu> {visibleAccess.map((item) => ( <SidebarMenuItem key={item.title}> <SidebarMenuButton asChild tooltip={item.title} isActive={isActive(item.url)} > <Link href={item.url}> <item.icon /> <span>{item.title}</span> </Link> </SidebarMenuButton> </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroupContent> </SidebarGroup> )}
{visibleIntegrations.length > 0 && ( <SidebarGroup> <SidebarGroupLabel>Integrations</SidebarGroupLabel> <SidebarGroupContent> <SidebarMenu> {visibleIntegrations.map((item) => { const showUpdateBadge = item.title === "Plugins" && hasUpdates; return ( <SidebarMenuItem key={item.title}> <SidebarMenuButton asChild tooltip={item.title} isActive={isActive(item.url)} > <Link href={item.url}> <item.icon /> <span>{item.title}</span> {showUpdateBadge && ( <IconArrowUpCircle className="ml-auto size-4 text-primary" aria-label="Updates available" /> )} </Link> </SidebarMenuButton> </SidebarMenuItem> ); })} </SidebarMenu> </SidebarGroupContent> </SidebarGroup> )}
{visibleSystem.length > 0 && ( <SidebarGroup> <SidebarGroupLabel>System</SidebarGroupLabel> <SidebarGroupContent> <SidebarMenu> {visibleSystem.map((item) => ( <SidebarMenuItem key={item.title}> <SidebarMenuButton asChild tooltip={item.title} isActive={isActive(item.url)} > <Link href={item.url}> <item.icon /> <span>{item.title}</span> </Link> </SidebarMenuButton> </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroupContent> </SidebarGroup> )} </SidebarContent> </Scroller> <SidebarSeparator className="!mx-0" /> <SidebarFooter> <SidebarMenu> <SidebarMenuItem> <SidebarMenuButton asChild tooltip="About" isActive={pathname === "/dashboard/about"} > <Link href="/dashboard/about"> <IconInfoCircle /> <span>About</span> </Link> </SidebarMenuButton> </SidebarMenuItem> <SidebarMenuItem> <SidebarMenuButton onClick={logout} tooltip="Log out"> <IconLogout /> <span>Log out</span> </SidebarMenuButton> </SidebarMenuItem> </SidebarMenu> </SidebarFooter> </Sidebar> );}