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 lexicons CRUD page

Trezy e7d9a653 fa129ab0

+257
+257
web/src/app/(dashboard)/lexicons/page.tsx
··· 1 + "use client" 2 + 3 + import { useCallback, useEffect, useState } from "react" 4 + 5 + import { useAuth } from "@/lib/auth-context" 6 + import { 7 + deleteLexicon, 8 + getLexicon, 9 + getLexicons, 10 + uploadLexicon, 11 + type LexiconDetail, 12 + type LexiconSummary, 13 + } from "@/lib/api" 14 + import { SiteHeader } from "@/components/site-header" 15 + import { Badge } from "@/components/ui/badge" 16 + import { Button } from "@/components/ui/button" 17 + import { 18 + Dialog, 19 + DialogClose, 20 + DialogContent, 21 + DialogDescription, 22 + DialogFooter, 23 + DialogHeader, 24 + DialogTitle, 25 + DialogTrigger, 26 + } from "@/components/ui/dialog" 27 + import { Input } from "@/components/ui/input" 28 + import { Label } from "@/components/ui/label" 29 + import { Switch } from "@/components/ui/switch" 30 + import { 31 + Table, 32 + TableBody, 33 + TableCell, 34 + TableHead, 35 + TableHeader, 36 + TableRow, 37 + } from "@/components/ui/table" 38 + import { Textarea } from "@/components/ui/textarea" 39 + 40 + export default function LexiconsPage() { 41 + const { token } = useAuth() 42 + const [lexicons, setLexicons] = useState<LexiconSummary[]>([]) 43 + const [error, setError] = useState<string | null>(null) 44 + const [viewLexicon, setViewLexicon] = useState<LexiconDetail | null>(null) 45 + 46 + const load = useCallback(() => { 47 + if (!token) return 48 + getLexicons(token).then(setLexicons).catch((e) => setError(e.message)) 49 + }, [token]) 50 + 51 + useEffect(() => { 52 + load() 53 + }, [load]) 54 + 55 + async function handleView(id: string) { 56 + if (!token) return 57 + try { 58 + const detail = await getLexicon(token, id) 59 + setViewLexicon(detail) 60 + } catch (e: unknown) { 61 + setError(e instanceof Error ? e.message : String(e)) 62 + } 63 + } 64 + 65 + async function handleDelete(id: string) { 66 + if (!token) return 67 + try { 68 + await deleteLexicon(token, id) 69 + load() 70 + } catch (e: unknown) { 71 + setError(e instanceof Error ? e.message : String(e)) 72 + } 73 + } 74 + 75 + return ( 76 + <> 77 + <SiteHeader title="Lexicons" /> 78 + <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 79 + {error && <p className="text-destructive text-sm">{error}</p>} 80 + 81 + <div className="flex items-center justify-between"> 82 + <h2 className="text-lg font-semibold">Uploaded Lexicons</h2> 83 + <UploadDialog token={token!} onSuccess={load} /> 84 + </div> 85 + 86 + <div className="rounded-lg border"> 87 + <Table> 88 + <TableHeader> 89 + <TableRow> 90 + <TableHead>ID</TableHead> 91 + <TableHead>Type</TableHead> 92 + <TableHead>Action</TableHead> 93 + <TableHead>Backfill</TableHead> 94 + <TableHead>Revision</TableHead> 95 + <TableHead className="text-right">Actions</TableHead> 96 + </TableRow> 97 + </TableHeader> 98 + <TableBody> 99 + {lexicons.length === 0 && ( 100 + <TableRow> 101 + <TableCell colSpan={6} className="text-muted-foreground text-center"> 102 + No lexicons uploaded yet. 103 + </TableCell> 104 + </TableRow> 105 + )} 106 + {lexicons.map((lex) => ( 107 + <TableRow key={lex.id}> 108 + <TableCell className="font-mono text-sm">{lex.id}</TableCell> 109 + <TableCell> 110 + <Badge variant="outline">{lex.lexicon_type}</Badge> 111 + </TableCell> 112 + <TableCell>{lex.action ?? "--"}</TableCell> 113 + <TableCell>{lex.backfill ? "Yes" : "No"}</TableCell> 114 + <TableCell className="tabular-nums">{lex.revision}</TableCell> 115 + <TableCell className="text-right"> 116 + <div className="flex justify-end gap-2"> 117 + <Button 118 + variant="outline" 119 + size="sm" 120 + onClick={() => handleView(lex.id)} 121 + > 122 + View 123 + </Button> 124 + <Button 125 + variant="destructive" 126 + size="sm" 127 + onClick={() => handleDelete(lex.id)} 128 + > 129 + Delete 130 + </Button> 131 + </div> 132 + </TableCell> 133 + </TableRow> 134 + ))} 135 + </TableBody> 136 + </Table> 137 + </div> 138 + 139 + {viewLexicon && ( 140 + <Dialog open onOpenChange={() => setViewLexicon(null)}> 141 + <DialogContent className="max-w-2xl"> 142 + <DialogHeader> 143 + <DialogTitle>{viewLexicon.id}</DialogTitle> 144 + <DialogDescription> 145 + Revision {viewLexicon.revision} &middot; {viewLexicon.lexicon_type} 146 + </DialogDescription> 147 + </DialogHeader> 148 + <pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs"> 149 + {JSON.stringify(viewLexicon.lexicon_json, null, 2)} 150 + </pre> 151 + </DialogContent> 152 + </Dialog> 153 + )} 154 + </div> 155 + </> 156 + ) 157 + } 158 + 159 + function UploadDialog({ 160 + token, 161 + onSuccess, 162 + }: { 163 + token: string 164 + onSuccess: () => void 165 + }) { 166 + const [json, setJson] = useState("") 167 + const [targetCollection, setTargetCollection] = useState("") 168 + const [action, setAction] = useState("") 169 + const [backfill, setBackfill] = useState(true) 170 + const [error, setError] = useState<string | null>(null) 171 + const [open, setOpen] = useState(false) 172 + 173 + async function handleUpload() { 174 + setError(null) 175 + try { 176 + const lexiconJson = JSON.parse(json) 177 + await uploadLexicon(token, { 178 + lexicon_json: lexiconJson, 179 + backfill, 180 + target_collection: targetCollection || undefined, 181 + action: action || undefined, 182 + }) 183 + setJson("") 184 + setTargetCollection("") 185 + setAction("") 186 + setBackfill(true) 187 + setOpen(false) 188 + onSuccess() 189 + } catch (e: unknown) { 190 + setError(e instanceof Error ? e.message : String(e)) 191 + } 192 + } 193 + 194 + return ( 195 + <Dialog open={open} onOpenChange={setOpen}> 196 + <DialogTrigger asChild> 197 + <Button>Upload Lexicon</Button> 198 + </DialogTrigger> 199 + <DialogContent className="max-w-2xl"> 200 + <DialogHeader> 201 + <DialogTitle>Upload Lexicon</DialogTitle> 202 + <DialogDescription> 203 + Paste the lexicon JSON document below. 204 + </DialogDescription> 205 + </DialogHeader> 206 + <div className="flex flex-col gap-4"> 207 + {error && <p className="text-destructive text-sm">{error}</p>} 208 + <div className="flex flex-col gap-2"> 209 + <Label htmlFor="lexicon-json">Lexicon JSON</Label> 210 + <Textarea 211 + id="lexicon-json" 212 + className="font-mono text-xs" 213 + rows={12} 214 + value={json} 215 + onChange={(e) => setJson(e.target.value)} 216 + placeholder='{"lexicon": 1, "id": "com.example.record", ...}' 217 + /> 218 + </div> 219 + <div className="flex flex-col gap-2"> 220 + <Label htmlFor="target-collection"> 221 + Target Collection (optional) 222 + </Label> 223 + <Input 224 + id="target-collection" 225 + value={targetCollection} 226 + onChange={(e) => setTargetCollection(e.target.value)} 227 + placeholder="com.example.record" 228 + /> 229 + </div> 230 + <div className="flex flex-col gap-2"> 231 + <Label htmlFor="action">Action (optional)</Label> 232 + <Input 233 + id="action" 234 + value={action} 235 + onChange={(e) => setAction(e.target.value)} 236 + placeholder="create, put, or leave empty for auto" 237 + /> 238 + </div> 239 + <div className="flex items-center gap-2"> 240 + <Switch 241 + id="backfill" 242 + checked={backfill} 243 + onCheckedChange={setBackfill} 244 + /> 245 + <Label htmlFor="backfill">Enable backfill</Label> 246 + </div> 247 + </div> 248 + <DialogFooter> 249 + <DialogClose asChild> 250 + <Button variant="outline">Cancel</Button> 251 + </DialogClose> 252 + <Button onClick={handleUpload}>Upload</Button> 253 + </DialogFooter> 254 + </DialogContent> 255 + </Dialog> 256 + ) 257 + }