Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
9.9 kB · 322 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323"use client";
import { useCallback, useEffect, useState } from "react";import { Trash2 } from "lucide-react";import { toast } from "sonner";
import { toastError } from "@/lib/format";import { getServiceEntryXrpcs, updateServiceEntry, removeServiceEntryXrpcs, addServiceEntryXrpcs, deleteServiceEntry, type ServiceEntry,} from "@/lib/api";import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,} from "@/components/ui/alert-dialog";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Checkbox } from "@/components/ui/checkbox";import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table";
interface ServiceEntrySheetProps { entry: ServiceEntry; open: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void;}
export function ServiceEntrySheet({ entry, open, onOpenChange, onSaved,}: ServiceEntrySheetProps) { const [accessMode, setAccessMode] = useState<string>(entry.access_mode); const [xrpcs, setXrpcs] = useState<string[]>([]); const [selected, setSelected] = useState<Set<string>>(new Set()); const [newXrpc, setNewXrpc] = useState(""); const [adding, setAdding] = useState(false); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false);
const loadXrpcs = useCallback(async () => { if (accessMode !== "specific") return; try { const list = await getServiceEntryXrpcs(entry.id); setXrpcs(list); } catch (e: unknown) { toastError("Failed to load XRPC list", e); } }, [entry.id, accessMode]);
useEffect(() => { if (open) { setAccessMode(entry.access_mode); setSelected(new Set()); } }, [open, entry]);
useEffect(() => { if (open) { loadXrpcs(); } }, [open, loadXrpcs]);
function toggleSelectAll() { if (selected.size === xrpcs.length) { setSelected(new Set()); } else { setSelected(new Set(xrpcs)); } }
function toggleSelect(xrpc: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(xrpc)) { next.delete(xrpc); } else { next.add(xrpc); } return next; }); }
async function handleRemoveSelected() { if (selected.size === 0) return; try { await removeServiceEntryXrpcs(entry.id, Array.from(selected)); toast.success(`Removed ${selected.size} XRPC${selected.size > 1 ? "s" : ""}`); setSelected(new Set()); await loadXrpcs(); } catch (e: unknown) { toastError("Failed to remove XRPCs", e); } }
async function handleAddXrpc() { const value = newXrpc.trim(); if (!value) return; setAdding(true); try { await addServiceEntryXrpcs(entry.id, [value]); toast.success(`Added ${value}`); setNewXrpc(""); await loadXrpcs(); } catch (e: unknown) { toastError("Failed to add XRPC", e); } finally { setAdding(false); } }
async function handleSave() { setSaving(true); try { await updateServiceEntry(entry.id, { access_mode: accessMode }); toast.success("Service entry updated"); onSaved(); onOpenChange(false); } catch (e: unknown) { toastError("Failed to update service entry", e); } finally { setSaving(false); } }
async function handleDelete() { setDeleting(true); try { await deleteServiceEntry(entry.id); toast.success(`Deleted ${entry.fragment_id}`); onSaved(); onOpenChange(false); } catch (e: unknown) { toastError("Failed to delete service entry", e); } finally { setDeleting(false); } }
const allSelected = xrpcs.length > 0 && selected.size === xrpcs.length; const someSelected = selected.size > 0 && selected.size < xrpcs.length;
return ( <Sheet open={open} onOpenChange={onOpenChange}> <SheetContent className="flex flex-col gap-0 overflow-y-auto"> <SheetHeader className="border-b pb-4"> <SheetTitle className="font-mono">{entry.fragment_id}</SheetTitle> <SheetDescription>{entry.service_type}</SheetDescription> </SheetHeader>
<div className="flex flex-col gap-6 flex-1 p-4"> <div className="flex flex-col gap-2"> <p className="text-sm font-medium">XRPC Access</p> <div className="flex gap-2"> <Button variant={accessMode === "all" ? "default" : "outline"} size="sm" onClick={() => setAccessMode("all")} > All XRPCs </Button> <Button variant={accessMode === "specific" ? "default" : "outline"} size="sm" onClick={() => { setAccessMode("specific"); loadXrpcs(); }} > Specific XRPCs </Button> </div> </div>
{accessMode === "specific" && ( <div className="flex flex-col gap-3"> <div className="flex items-center justify-between"> <p className="text-sm font-medium">Allowed XRPCs</p> {selected.size > 0 && ( <Button variant="destructive" size="sm" onClick={handleRemoveSelected} > Remove Selected ({selected.size}) </Button> )} </div>
<div className="overflow-clip rounded-lg border"> <Table> <TableHeader> <TableRow> <TableHead className="w-10"> <Checkbox checked={allSelected || (someSelected && "indeterminate")} onCheckedChange={toggleSelectAll} aria-label="Select all" /> </TableHead> <TableHead>XRPC</TableHead> </TableRow> </TableHeader> <TableBody> {xrpcs.length === 0 && ( <TableRow> <TableCell colSpan={2} className="text-muted-foreground text-center text-sm" > No XRPCs configured. Add methods below that this service entry can access. </TableCell> </TableRow> )} {xrpcs.map((xrpc) => ( <TableRow key={xrpc}> <TableCell className="w-10"> <Checkbox checked={selected.has(xrpc)} onCheckedChange={() => toggleSelect(xrpc)} aria-label={`Select ${xrpc}`} /> </TableCell> <TableCell className="font-mono text-sm"> {xrpc} </TableCell> </TableRow> ))} </TableBody> </Table> </div>
<div className="flex gap-2"> <Input className="font-mono" placeholder="games.birb.chess.getGame" value={newXrpc} onChange={(e) => setNewXrpc(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleAddXrpc(); }} disabled={adding} /> <Button size="sm" onClick={handleAddXrpc} disabled={adding || !newXrpc.trim()} > {adding ? "Adding…" : "Add"} </Button> </div> </div> )} </div>
<SheetFooter className="border-t pt-4 flex-row justify-between"> <AlertDialog> <AlertDialogTrigger asChild> <Button variant="destructive" size="sm"> <Trash2 className="size-4 mr-2" /> Delete Service </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> Delete {entry.fragment_id}? </AlertDialogTitle> <AlertDialogDescription> This will permanently remove the service entry and its XRPC configuration. The change will take effect in the DID document after your next PLC sync. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction variant="destructive" onClick={handleDelete} disabled={deleting} > {deleting ? "Deleting…" : "Delete"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> <Button onClick={handleSave} disabled={saving} size="sm"> {saving ? "Saving…" : "Save"} </Button> </SheetFooter> </SheetContent> </Sheet> );}