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 column visibility and better scrolling to records table

Trezy ddb05c81 1b44ecb7

+149 -180
+123 -159
web/src/app/(dashboard)/records/page.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useCallback, useEffect, useMemo, useState } from "react" 3 + import { useCallback, useEffect, useMemo, useState } from "react"; 4 4 import { 5 5 type ColumnDef, 6 - flexRender, 6 + type VisibilityState, 7 7 getCoreRowModel, 8 8 useReactTable, 9 - } from "@tanstack/react-table" 9 + } from "@tanstack/react-table"; 10 10 11 - import { useAuth } from "@/lib/auth-context" 11 + import { useAuth } from "@/lib/auth-context"; 12 12 import { 13 13 getStats, 14 14 getAdminRecords, 15 15 type CollectionStat, 16 16 type AdminRecord, 17 - } from "@/lib/api" 18 - import { SiteHeader } from "@/components/site-header" 19 - import { Button } from "@/components/ui/button" 17 + } from "@/lib/api"; 18 + import { DataTable } from "@/components/data-table/data-table"; 19 + import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"; 20 + import { SiteHeader } from "@/components/site-header"; 21 + import { Button } from "@/components/ui/button"; 20 22 import { 21 23 Dialog, 22 24 DialogContent, 23 25 DialogHeader, 24 26 DialogTitle, 25 - } from "@/components/ui/dialog" 27 + } from "@/components/ui/dialog"; 26 28 import { 27 29 Select, 28 30 SelectContent, 29 31 SelectItem, 30 32 SelectTrigger, 31 33 SelectValue, 32 - } from "@/components/ui/select" 33 - import { 34 - Table, 35 - TableBody, 36 - TableCell, 37 - TableHead, 38 - TableHeader, 39 - TableRow, 40 - } from "@/components/ui/table" 34 + } from "@/components/ui/select"; 35 + import { ChevronLeft, ChevronRight } from "lucide-react"; 41 36 42 37 function parseAtUri(uri: string): { did: string; rkey: string } { 43 - const parts = uri.replace("at://", "").split("/") 44 - return { did: parts[0] ?? "", rkey: parts[2] ?? "" } 38 + const parts = uri.replace("at://", "").split("/"); 39 + return { did: parts[0] ?? "", rkey: parts[2] ?? "" }; 45 40 } 46 41 47 42 function formatCellValue(value: unknown): string { 48 - if (value === null || value === undefined) return "" 49 - if (typeof value === "string") return value 43 + if (value === null || value === undefined) return ""; 44 + if (typeof value === "string") return value; 50 45 if (typeof value === "number" || typeof value === "boolean") 51 - return String(value) 52 - return JSON.stringify(value) 46 + return String(value); 47 + return JSON.stringify(value); 53 48 } 54 49 55 50 export default function RecordsPage() { 56 - const { getToken } = useAuth() 57 - const [collections, setCollections] = useState<CollectionStat[]>([]) 58 - const [selectedCollection, setSelectedCollection] = useState<string>("") 59 - const [records, setRecords] = useState<AdminRecord[]>([]) 60 - const [cursorStack, setCursorStack] = useState<string[]>([]) 61 - const [nextCursor, setNextCursor] = useState<string | undefined>() 62 - const [loading, setLoading] = useState(false) 63 - const [error, setError] = useState<string | null>(null) 64 - const [viewRecord, setViewRecord] = useState<AdminRecord | null>(null) 51 + const { getToken } = useAuth(); 52 + const [collections, setCollections] = useState<CollectionStat[]>([]); 53 + const [selectedCollection, setSelectedCollection] = useState<string>(""); 54 + const [records, setRecords] = useState<AdminRecord[]>([]); 55 + const [cursorStack, setCursorStack] = useState<string[]>([]); 56 + const [nextCursor, setNextCursor] = useState<string | undefined>(); 57 + const [loading, setLoading] = useState(false); 58 + const [error, setError] = useState<string | null>(null); 59 + const [viewRecord, setViewRecord] = useState<AdminRecord | null>(null); 60 + 61 + const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); 65 62 66 63 useEffect(() => { 67 64 getStats(getToken) 68 65 .then((stats) => setCollections(stats.collections)) 69 - .catch((e) => setError(e.message)) 70 - }, [getToken]) 66 + .catch((e) => setError(e.message)); 67 + }, [getToken]); 71 68 72 69 const fetchRecords = useCallback( 73 70 async (collection: string, cursor?: string) => { 74 - setLoading(true) 75 - setError(null) 71 + setLoading(true); 72 + setError(null); 76 73 try { 77 - const data = await getAdminRecords(getToken, collection, 20, cursor) 78 - setRecords(data.records) 79 - setNextCursor(data.cursor) 74 + const data = await getAdminRecords(getToken, collection, 20, cursor); 75 + setRecords(data.records); 76 + setNextCursor(data.cursor); 80 77 } catch (e: unknown) { 81 - setError(e instanceof Error ? e.message : String(e)) 82 - setRecords([]) 83 - setNextCursor(undefined) 78 + setError(e instanceof Error ? e.message : String(e)); 79 + setRecords([]); 80 + setNextCursor(undefined); 84 81 } finally { 85 - setLoading(false) 82 + setLoading(false); 86 83 } 87 84 }, 88 - [getToken] 89 - ) 85 + [getToken], 86 + ); 90 87 91 88 // Build columns dynamically from the union of all record keys 92 89 const columns = useMemo<ColumnDef<AdminRecord>[]>(() => { 93 - const keySet = new Set<string>() 90 + const keySet = new Set<string>(); 94 91 for (const r of records) { 95 92 for (const key of Object.keys(r.record)) { 96 - keySet.add(key) 93 + keySet.add(key); 97 94 } 98 95 } 99 96 100 97 const cols: ColumnDef<AdminRecord>[] = [ 101 98 { 102 99 id: "did", 100 + accessorFn: (row) => parseAtUri(row.uri).did, 103 101 header: "DID", 104 - accessorFn: (row) => parseAtUri(row.uri).did, 105 102 cell: ({ getValue }) => ( 106 103 <span className="font-mono text-xs whitespace-nowrap"> 107 104 {getValue<string>()} 108 105 </span> 109 106 ), 107 + enableSorting: false, 108 + enableHiding: false, 109 + meta: { label: "DID" }, 110 110 }, 111 111 { 112 112 id: "rkey", 113 - header: "Rkey", 114 113 accessorFn: (row) => parseAtUri(row.uri).rkey, 114 + header: "Record Key", 115 115 cell: ({ getValue }) => ( 116 116 <span className="font-mono text-xs whitespace-nowrap"> 117 117 {getValue<string>()} 118 118 </span> 119 119 ), 120 + enableSorting: false, 121 + enableHiding: false, 122 + meta: { label: "Record Key" }, 120 123 }, 121 - ] 124 + ]; 122 125 123 126 for (const key of keySet) { 124 127 cols.push({ 125 128 id: key, 126 - header: key, 127 129 accessorFn: (row) => row.record[key], 130 + header: key, 131 + enableSorting: false, 128 132 cell: ({ getValue }) => { 129 - const val = getValue<unknown>() 130 - const str = formatCellValue(val) 133 + const val = getValue<unknown>(); 134 + const str = formatCellValue(val); 131 135 return ( 132 136 <span 133 137 className="font-mono text-xs block max-w-xs truncate" ··· 135 139 > 136 140 {str} 137 141 </span> 138 - ) 142 + ); 139 143 }, 140 - }) 144 + meta: { label: key }, 145 + }); 141 146 } 142 147 143 - return cols 144 - }, [records]) 148 + return cols; 149 + }, [records]); 145 150 146 151 const table = useReactTable({ 147 152 data: records, 148 153 columns, 154 + state: { 155 + columnVisibility, 156 + }, 157 + onColumnVisibilityChange: setColumnVisibility, 149 158 getCoreRowModel: getCoreRowModel(), 150 159 getRowId: (row) => row.uri, 151 - }) 160 + }); 152 161 153 162 function handleSelectCollection(collection: string) { 154 - setSelectedCollection(collection) 155 - setCursorStack([]) 156 - setNextCursor(undefined) 157 - fetchRecords(collection) 163 + setSelectedCollection(collection); 164 + setCursorStack([]); 165 + setNextCursor(undefined); 166 + setColumnVisibility({}); 167 + fetchRecords(collection); 158 168 } 159 169 160 170 function handleNext() { 161 - if (!nextCursor || !selectedCollection) return 162 - setCursorStack((prev) => [...prev, nextCursor]) 163 - fetchRecords(selectedCollection, nextCursor) 171 + if (!nextCursor || !selectedCollection) return; 172 + setCursorStack((prev) => [...prev, nextCursor]); 173 + fetchRecords(selectedCollection, nextCursor); 164 174 } 165 175 166 176 function handlePrevious() { 167 - if (cursorStack.length === 0 || !selectedCollection) return 168 - const stack = [...cursorStack] 169 - stack.pop() 170 - const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined 171 - setCursorStack(stack) 172 - fetchRecords(selectedCollection, prevCursor) 177 + if (cursorStack.length === 0 || !selectedCollection) return; 178 + const stack = [...cursorStack]; 179 + stack.pop(); 180 + const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined; 181 + setCursorStack(stack); 182 + fetchRecords(selectedCollection, prevCursor); 173 183 } 174 184 175 185 return ( ··· 178 188 <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 179 189 {error && <p className="text-destructive text-sm">{error}</p>} 180 190 181 - <div className="flex items-center gap-4"> 182 - <Select 183 - value={selectedCollection} 184 - onValueChange={handleSelectCollection} 185 - > 186 - <SelectTrigger className="w-80"> 187 - <SelectValue placeholder="Select a collection" /> 188 - </SelectTrigger> 189 - <SelectContent> 190 - {collections.map((col) => ( 191 - <SelectItem key={col.collection} value={col.collection}> 192 - {col.collection} ({col.count.toLocaleString()}) 193 - </SelectItem> 194 - ))} 195 - </SelectContent> 196 - </Select> 197 - </div> 191 + <DataTable 192 + table={table} 193 + showPagination={false} 194 + onRowClick={setViewRecord} 195 + > 196 + <div className="flex w-full items-center justify-between gap-2 p-1"> 197 + <Select 198 + value={selectedCollection} 199 + onValueChange={handleSelectCollection} 200 + > 201 + <SelectTrigger className="h-8 w-80 text-sm"> 202 + <SelectValue placeholder="Select a collection" /> 203 + </SelectTrigger> 204 + <SelectContent> 205 + {collections.map((col) => ( 206 + <SelectItem key={col.collection} value={col.collection}> 207 + {col.collection} ({col.count.toLocaleString()}) 208 + </SelectItem> 209 + ))} 210 + </SelectContent> 211 + </Select> 212 + <DataTableViewOptions table={table} /> 213 + </div> 214 + </DataTable> 198 215 199 216 {selectedCollection && ( 200 - <> 201 - <div className="overflow-x-auto rounded-lg border"> 202 - <Table> 203 - <TableHeader> 204 - {table.getHeaderGroups().map((headerGroup) => ( 205 - <TableRow key={headerGroup.id}> 206 - {headerGroup.headers.map((header) => ( 207 - <TableHead key={header.id} className="whitespace-nowrap"> 208 - {header.isPlaceholder 209 - ? null 210 - : flexRender( 211 - header.column.columnDef.header, 212 - header.getContext() 213 - )} 214 - </TableHead> 215 - ))} 216 - </TableRow> 217 - ))} 218 - </TableHeader> 219 - <TableBody> 220 - {loading && ( 221 - <TableRow> 222 - <TableCell 223 - colSpan={columns.length} 224 - className="text-muted-foreground text-center" 225 - > 226 - Loading... 227 - </TableCell> 228 - </TableRow> 229 - )} 230 - {!loading && table.getRowModel().rows.length === 0 && ( 231 - <TableRow> 232 - <TableCell 233 - colSpan={columns.length} 234 - className="text-muted-foreground text-center" 235 - > 236 - No records found. 237 - </TableCell> 238 - </TableRow> 239 - )} 240 - {!loading && 241 - table.getRowModel().rows.map((row) => ( 242 - <TableRow 243 - key={row.id} 244 - className="cursor-pointer" 245 - onClick={() => setViewRecord(row.original)} 246 - > 247 - {row.getVisibleCells().map((cell) => ( 248 - <TableCell key={cell.id}> 249 - {flexRender( 250 - cell.column.columnDef.cell, 251 - cell.getContext() 252 - )} 253 - </TableCell> 254 - ))} 255 - </TableRow> 256 - ))} 257 - </TableBody> 258 - </Table> 259 - </div> 260 - 261 - <div className="flex items-center justify-end gap-2"> 217 + <div className="flex w-full items-center justify-between gap-4 overflow-auto p-1"> 218 + <p className="text-muted-foreground flex-1 whitespace-nowrap text-sm"> 219 + {records.length} record(s) on this page. 220 + </p> 221 + <div className="flex items-center space-x-2"> 262 222 <Button 223 + aria-label="Go to previous page" 263 224 variant="outline" 264 - size="sm" 265 - disabled={cursorStack.length === 0} 225 + size="icon" 226 + className="size-8" 227 + disabled={cursorStack.length === 0 || loading} 266 228 onClick={handlePrevious} 267 229 > 268 - Previous 230 + <ChevronLeft /> 269 231 </Button> 270 232 <Button 233 + aria-label="Go to next page" 271 234 variant="outline" 272 - size="sm" 273 - disabled={!nextCursor} 235 + size="icon" 236 + className="size-8" 237 + disabled={!nextCursor || loading} 274 238 onClick={handleNext} 275 239 > 276 - Next 240 + <ChevronRight /> 277 241 </Button> 278 242 </div> 279 - </> 243 + </div> 280 244 )} 281 245 282 246 {viewRecord && ( ··· 295 259 )} 296 260 </div> 297 261 </> 298 - ) 262 + ); 299 263 }
+1 -4
web/src/components/data-table/data-table-toolbar.tsx
··· 25 25 }: DataTableToolbarProps<TData>) { 26 26 const isFiltered = table.getState().columnFilters.length > 0; 27 27 28 - const columns = React.useMemo( 29 - () => table.getAllColumns().filter((column) => column.getCanFilter()), 30 - [table], 31 - ); 28 + const columns = table.getAllColumns().filter((column) => column.getCanFilter()); 32 29 33 30 const onReset = React.useCallback(() => { 34 31 table.resetColumnFilters();
+6 -10
web/src/components/data-table/data-table-view-options.tsx
··· 31 31 disabled, 32 32 ...props 33 33 }: DataTableViewOptionsProps<TData>) { 34 - const columns = React.useMemo( 35 - () => 36 - table 37 - .getAllColumns() 38 - .filter( 39 - (column) => 40 - typeof column.accessorFn !== "undefined" && column.getCanHide(), 41 - ), 42 - [table], 43 - ); 34 + const columns = table 35 + .getAllColumns() 36 + .filter( 37 + (column) => 38 + typeof column.accessorFn !== "undefined" && column.getCanHide(), 39 + ); 44 40 45 41 return ( 46 42 <Popover>
+18 -6
web/src/components/data-table/data-table.tsx
··· 18 18 interface DataTableProps<TData> extends React.ComponentProps<"div"> { 19 19 table: TanstackTable<TData>; 20 20 actionBar?: React.ReactNode; 21 + showPagination?: boolean; 22 + onRowClick?: (row: TData) => void; 21 23 } 22 24 23 25 export function DataTable<TData>({ 24 26 table, 25 27 actionBar, 28 + showPagination = true, 29 + onRowClick, 26 30 children, 27 31 className, 28 32 ...props ··· 63 67 <TableRow 64 68 key={row.id} 65 69 data-state={row.getIsSelected() && "selected"} 70 + className={onRowClick ? "cursor-pointer" : undefined} 71 + onClick={ 72 + onRowClick 73 + ? () => onRowClick(row.original) 74 + : undefined 75 + } 66 76 > 67 77 {row.getVisibleCells().map((cell) => ( 68 78 <TableCell ··· 92 102 </TableBody> 93 103 </Table> 94 104 </div> 95 - <div className="flex flex-col gap-2.5"> 96 - <DataTablePagination table={table} /> 97 - {actionBar && 98 - table.getFilteredSelectedRowModel().rows.length > 0 && 99 - actionBar} 100 - </div> 105 + {showPagination && ( 106 + <div className="flex flex-col gap-2.5"> 107 + <DataTablePagination table={table} /> 108 + {actionBar && 109 + table.getFilteredSelectedRowModel().rows.length > 0 && 110 + actionBar} 111 + </div> 112 + )} 101 113 </div> 102 114 ); 103 115 }
+1 -1
web/src/components/ui/sidebar.tsx
··· 309 309 <main 310 310 data-slot="sidebar-inset" 311 311 className={cn( 312 - "bg-background relative flex w-full flex-1 flex-col", 312 + "bg-background relative flex min-w-0 w-full flex-1 flex-col", 313 313 "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", 314 314 className 315 315 )}