Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 204 additions & 10 deletions apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
'use client';

import { useState, useMemo } from 'react';
import { useQuery, useQueries } from '@tanstack/react-query';
import { useState, useMemo, useCallback } from 'react';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { useGastownTRPC } from '@/lib/gastown/trpc';
import { useDrawerStack } from '@/components/gastown/DrawerStack';
import { Hexagon, Search } from 'lucide-react';
import { Hexagon, Search, Trash2, X } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { formatDistanceToNow } from 'date-fns';
import { motion, AnimatePresence } from 'motion/react';
import type { GastownOutputs } from '@/lib/gastown/trpc';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

type Bead = GastownOutputs['gastown']['listBeads'][number];

Expand All @@ -23,11 +32,18 @@ const STATUS_DOT: Record<string, string> = {
failed: 'bg-red-400',
};

type DeleteConfirm =
| { kind: 'selected'; ids: string[]; rigId: string }
| { kind: 'all-failed'; count: number; rigIds: string[] };

export function BeadsPageClient({ townId }: BeadsPageClientProps) {
const trpc = useGastownTRPC();
const queryClient = useQueryClient();
const { open: openDrawer } = useDrawerStack();
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirm | null>(null);

const rigsQuery = useQuery(trpc.gastown.listRigs.queryOptions({ townId }));
const rigs = rigsQuery.data ?? [];
Expand Down Expand Up @@ -78,8 +94,92 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
return counts;
}, [allBeads]);

const failedBeads = useMemo(() => allBeads.filter(b => b.status === 'failed'), [allBeads]);

const isLoading = rigsQuery.isLoading || rigBeadQueries.some(q => q.isLoading);

const invalidateBeads = useCallback(() => {
for (const rig of rigs) {
void queryClient.invalidateQueries(trpc.gastown.listBeads.queryFilter({ rigId: rig.id }));
}
}, [queryClient, rigs, trpc.gastown.listBeads]);

const deleteBeadMutation = useMutation(
trpc.gastown.deleteBead.mutationOptions({
onSuccess: () => {
invalidateBeads();
setSelectedIds(new Set());
setDeleteConfirm(null);
},
})
);

const isDeleting = deleteBeadMutation.isPending;

// Build a map from bead_id -> rigId for lookups
const beadRigMap = useMemo(() => {
const map = new Map<string, string>();
for (const bead of allBeads) {
map.set(bead.bead_id, bead.rigId);
}
return map;
}, [allBeads]);

const allFilteredSelected =
filteredBeads.length > 0 && filteredBeads.every(b => selectedIds.has(b.bead_id));

const toggleSelectAll = () => {
if (allFilteredSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredBeads.map(b => b.bead_id)));
}
};

const toggleSelect = (beadId: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(beadId)) {
next.delete(beadId);
} else {
next.add(beadId);
}
return next;
});
};

const handleDeleteSelected = () => {
if (selectedIds.size === 0) return;
// Group by rigId — pick the first rig for simplicity (all selected beads share the same rig
// in most cases; if mixed, we use the first one and the mutation handles array input)
const selectedArr = [...selectedIds];
const firstRigId = beadRigMap.get(selectedArr[0] ?? '') ?? '';
setDeleteConfirm({ kind: 'selected', ids: selectedArr, rigId: firstRigId });
};

const handleDeleteAllFailed = () => {
if (failedBeads.length === 0) return;
const rigIds = [...new Set(failedBeads.map(b => b.rigId))];
setDeleteConfirm({ kind: 'all-failed', count: failedBeads.length, rigIds });
};

const handleConfirmDelete = () => {
if (!deleteConfirm) return;

if (deleteConfirm.kind === 'selected') {
const { ids, rigId } = deleteConfirm;
deleteBeadMutation.mutate({ rigId, beadId: ids, townId });
} else {
// Delete all failed beads — collect all IDs and bulk-delete via the array endpoint.
// Using the first rig's ID for ownership verification; the town DO deletes by IDs.
const { rigIds } = deleteConfirm;
const firstRigId = rigIds[0];
if (!firstRigId) return;
const failedIds = failedBeads.map(b => b.bead_id);
deleteBeadMutation.mutate({ rigId: firstRigId, beadId: failedIds, townId });
}
};

return (
<div className="flex h-full flex-col">
{/* Header */}
Expand All @@ -90,6 +190,17 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
<h1 className="text-lg font-semibold tracking-tight text-white/90">Beads</h1>
<span className="ml-1 font-mono text-xs text-white/30">{allBeads.length}</span>
</div>

{/* Delete all failed shortcut */}
{failedBeads.length > 0 && (
<button
onClick={handleDeleteAllFailed}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-red-400/70 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="size-3" />
Delete all failed ({failedBeads.length})
</button>
)}
</div>

{/* Filter bar */}
Expand Down Expand Up @@ -127,6 +238,39 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
</div>
</div>

{/* Bulk action bar */}
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex items-center gap-3 border-b border-white/[0.06] bg-red-500/[0.04] px-6 py-2">
<span className="text-xs text-white/50">
{selectedIds.size} selected
</span>
<button
onClick={handleDeleteSelected}
className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5 text-xs font-medium text-red-400 transition-colors hover:bg-red-500/20"
>
<Trash2 className="size-3" />
Delete selected
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-white/30 transition-colors hover:text-white/50"
>
<X className="size-3" />
Clear
</button>
</div>
</motion.div>
)}
</AnimatePresence>

{/* Bead list */}
<div className="flex-1 overflow-y-auto">
{isLoading && (
Expand All @@ -153,6 +297,20 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
</div>
)}

{/* Select-all header row */}
{!isLoading && filteredBeads.length > 0 && (
<div className="flex items-center gap-3 border-b border-white/[0.04] px-6 py-1.5">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={toggleSelectAll}
className="size-3.5 cursor-pointer accent-[oklch(95%_0.15_108)]"
aria-label="Select all beads"
/>
<span className="text-[10px] text-white/20">Select all ({filteredBeads.length})</span>
</div>
)}

<AnimatePresence mode="popLayout">
{filteredBeads.map((bead, i) => (
<motion.div
Expand All @@ -161,16 +319,28 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: Math.min(i * 0.02, 0.3), duration: 0.15 }}
onClick={() => {
const rigId = (bead as Bead & { rigId: string }).rigId;
openDrawer({ type: 'bead', beadId: bead.bead_id, rigId });
}}
className="group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02]"
className={`group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02] ${
selectedIds.has(bead.bead_id) ? 'bg-white/[0.03]' : ''
}`}
>
{/* Checkbox — stop propagation so clicking it doesn't open drawer */}
<input
type="checkbox"
checked={selectedIds.has(bead.bead_id)}
onChange={() => toggleSelect(bead.bead_id)}
onClick={e => e.stopPropagation()}
className="size-3.5 shrink-0 cursor-pointer accent-[oklch(95%_0.15_108)]"
aria-label={`Select bead ${bead.bead_id}`}
/>
<span
className={`size-2 shrink-0 rounded-full ${STATUS_DOT[bead.status] ?? 'bg-white/20'}`}
/>
<div className="min-w-0 flex-1">
<div
className="min-w-0 flex-1"
onClick={() => {
openDrawer({ type: 'bead', beadId: bead.bead_id, rigId: bead.rigId });
}}
>
<div className="flex items-center gap-2">
<span className="truncate text-sm text-white/80">{bead.title}</span>
<span className="shrink-0 rounded bg-white/[0.04] px-1.5 py-0.5 text-[9px] font-medium text-white/30">
Expand All @@ -180,7 +350,7 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-white/30">
<span className="font-mono">{bead.bead_id.slice(0, 8)}</span>
<span className="text-white/15">|</span>
<span>{(bead as Bead & { rigName: string }).rigName}</span>
<span>{bead.rigName}</span>
<span className="text-white/15">|</span>
<span>{formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })}</span>
</div>
Expand All @@ -191,6 +361,30 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
</AnimatePresence>
</div>

{/* Delete confirmation dialog */}
<Dialog open={!!deleteConfirm} onOpenChange={open => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteConfirm?.kind === 'all-failed' ? 'Delete all failed beads' : 'Delete beads'}
</DialogTitle>
<DialogDescription>
{deleteConfirm?.kind === 'all-failed'
? `Delete ${deleteConfirm.count} failed bead${deleteConfirm.count === 1 ? '' : 's'}? This cannot be undone.`
: `Delete ${deleteConfirm?.ids.length ?? 0} selected bead${(deleteConfirm?.ids.length ?? 0) === 1 ? '' : 's'}? This cannot be undone.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Drawers are rendered by the layout-level DrawerStackProvider */}
</div>
);
Expand Down
Loading