A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add dashboard navigation with sidebar layout

Trezy fa129ab0 4958ec25

+239 -7
+39
web/src/app/(dashboard)/layout.tsx
··· 1 + "use client" 2 + 3 + import { useEffect } from "react" 4 + import { useRouter } from "next/navigation" 5 + 6 + import { useAuth } from "@/lib/auth-context" 7 + import { AppSidebar } from "@/components/app-sidebar" 8 + import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" 9 + 10 + export default function DashboardLayout({ 11 + children, 12 + }: { 13 + children: React.ReactNode 14 + }) { 15 + const { token } = useAuth() 16 + const router = useRouter() 17 + 18 + useEffect(() => { 19 + if (!token) { 20 + router.replace("/login") 21 + } 22 + }, [token, router]) 23 + 24 + if (!token) return null 25 + 26 + return ( 27 + <SidebarProvider 28 + style={ 29 + { 30 + "--sidebar-width": "calc(var(--spacing) * 72)", 31 + "--header-height": "calc(var(--spacing) * 12)", 32 + } as React.CSSProperties 33 + } 34 + > 35 + <AppSidebar variant="inset" /> 36 + <SidebarInset>{children}</SidebarInset> 37 + </SidebarProvider> 38 + ) 39 + }
+86
web/src/app/(dashboard)/page.tsx
··· 1 + "use client" 2 + 3 + import { useEffect, useState } from "react" 4 + 5 + import { useAuth } from "@/lib/auth-context" 6 + import { getStats, type StatsResponse } from "@/lib/api" 7 + import { SiteHeader } from "@/components/site-header" 8 + import { 9 + Card, 10 + CardDescription, 11 + CardHeader, 12 + CardTitle, 13 + } from "@/components/ui/card" 14 + import { 15 + Table, 16 + TableBody, 17 + TableCell, 18 + TableHead, 19 + TableHeader, 20 + TableRow, 21 + } from "@/components/ui/table" 22 + 23 + export default function DashboardPage() { 24 + const { token } = useAuth() 25 + const [stats, setStats] = useState<StatsResponse | null>(null) 26 + const [error, setError] = useState<string | null>(null) 27 + 28 + useEffect(() => { 29 + if (!token) return 30 + getStats(token).then(setStats).catch((e) => setError(e.message)) 31 + }, [token]) 32 + 33 + return ( 34 + <> 35 + <SiteHeader title="Dashboard" /> 36 + <div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6"> 37 + {error && ( 38 + <p className="text-destructive text-sm">{error}</p> 39 + )} 40 + <div className="grid grid-cols-1 gap-4 @xl:grid-cols-2 @3xl:grid-cols-3"> 41 + <Card> 42 + <CardHeader> 43 + <CardDescription>Total Records</CardDescription> 44 + <CardTitle className="text-2xl font-semibold tabular-nums"> 45 + {stats ? stats.total_records.toLocaleString() : "--"} 46 + </CardTitle> 47 + </CardHeader> 48 + </Card> 49 + <Card> 50 + <CardHeader> 51 + <CardDescription>Collections</CardDescription> 52 + <CardTitle className="text-2xl font-semibold tabular-nums"> 53 + {stats ? stats.collections.length : "--"} 54 + </CardTitle> 55 + </CardHeader> 56 + </Card> 57 + </div> 58 + 59 + {stats && stats.collections.length > 0 && ( 60 + <div className="rounded-lg border"> 61 + <Table> 62 + <TableHeader> 63 + <TableRow> 64 + <TableHead>Collection</TableHead> 65 + <TableHead className="text-right">Records</TableHead> 66 + </TableRow> 67 + </TableHeader> 68 + <TableBody> 69 + {stats.collections.map((col) => ( 70 + <TableRow key={col.collection}> 71 + <TableCell className="font-mono text-sm"> 72 + {col.collection} 73 + </TableCell> 74 + <TableCell className="text-right tabular-nums"> 75 + {col.count.toLocaleString()} 76 + </TableCell> 77 + </TableRow> 78 + ))} 79 + </TableBody> 80 + </Table> 81 + </div> 82 + )} 83 + </div> 84 + </> 85 + ) 86 + }
-7
web/src/app/page.tsx
··· 1 - export default function Home() { 2 - return ( 3 - <div className="flex min-h-svh items-center justify-center"> 4 - <h1 className="text-2xl font-semibold">HappyView Admin</h1> 5 - </div> 6 - ) 7 - }
+95
web/src/components/app-sidebar.tsx
··· 1 + "use client" 2 + 3 + import { 4 + IconDashboard, 5 + IconFileDescription, 6 + IconWorld, 7 + IconDatabase, 8 + IconUsers, 9 + IconLogout, 10 + } from "@tabler/icons-react" 11 + import Link from "next/link" 12 + import { usePathname } from "next/navigation" 13 + 14 + import { useAuth } from "@/lib/auth-context" 15 + import { 16 + Sidebar, 17 + SidebarContent, 18 + SidebarFooter, 19 + SidebarGroup, 20 + SidebarGroupContent, 21 + SidebarHeader, 22 + SidebarMenu, 23 + SidebarMenuButton, 24 + SidebarMenuItem, 25 + } from "@/components/ui/sidebar" 26 + 27 + const navItems = [ 28 + { title: "Dashboard", url: "/", icon: IconDashboard }, 29 + { title: "Lexicons", url: "/lexicons", icon: IconFileDescription }, 30 + { title: "Network Lexicons", url: "/network-lexicons", icon: IconWorld }, 31 + { title: "Backfill", url: "/backfill", icon: IconDatabase }, 32 + { title: "Admins", url: "/admins", icon: IconUsers }, 33 + ] 34 + 35 + export function AppSidebar({ 36 + ...props 37 + }: React.ComponentProps<typeof Sidebar>) { 38 + const pathname = usePathname() 39 + const { logout } = useAuth() 40 + 41 + return ( 42 + <Sidebar collapsible="offcanvas" {...props}> 43 + <SidebarHeader> 44 + <SidebarMenu> 45 + <SidebarMenuItem> 46 + <SidebarMenuButton 47 + asChild 48 + className="data-[slot=sidebar-menu-button]:!p-1.5" 49 + > 50 + <Link href="/"> 51 + <span className="text-base font-semibold">HappyView</span> 52 + </Link> 53 + </SidebarMenuButton> 54 + </SidebarMenuItem> 55 + </SidebarMenu> 56 + </SidebarHeader> 57 + <SidebarContent> 58 + <SidebarGroup> 59 + <SidebarGroupContent className="flex flex-col gap-2"> 60 + <SidebarMenu> 61 + {navItems.map((item) => ( 62 + <SidebarMenuItem key={item.title}> 63 + <SidebarMenuButton 64 + asChild 65 + tooltip={item.title} 66 + isActive={ 67 + item.url === "/" 68 + ? pathname === "/" 69 + : pathname.startsWith(item.url) 70 + } 71 + > 72 + <Link href={item.url}> 73 + <item.icon /> 74 + <span>{item.title}</span> 75 + </Link> 76 + </SidebarMenuButton> 77 + </SidebarMenuItem> 78 + ))} 79 + </SidebarMenu> 80 + </SidebarGroupContent> 81 + </SidebarGroup> 82 + </SidebarContent> 83 + <SidebarFooter> 84 + <SidebarMenu> 85 + <SidebarMenuItem> 86 + <SidebarMenuButton onClick={logout} tooltip="Log out"> 87 + <IconLogout /> 88 + <span>Log out</span> 89 + </SidebarMenuButton> 90 + </SidebarMenuItem> 91 + </SidebarMenu> 92 + </SidebarFooter> 93 + </Sidebar> 94 + ) 95 + }
+19
web/src/components/site-header.tsx
··· 1 + "use client" 2 + 3 + import { Separator } from "@/components/ui/separator" 4 + import { SidebarTrigger } from "@/components/ui/sidebar" 5 + 6 + export function SiteHeader({ title }: { title: string }) { 7 + return ( 8 + <header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"> 9 + <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6"> 10 + <SidebarTrigger className="-ml-1" /> 11 + <Separator 12 + orientation="vertical" 13 + className="mx-2 data-[orientation=vertical]:h-4" 14 + /> 15 + <h1 className="text-base font-medium">{title}</h1> 16 + </div> 17 + </header> 18 + ) 19 + }