import { getBrowserTabId, setClientAppState, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconAlertTriangle, IconChevronLeft, IconChevronRight, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { CreateFolderDialog } from "@/components/library/create-folder-dialog"; import { ShareRecordingDialog } from "@/components/player/share-dialog"; import { Button } from "@/components/ui/button"; import { useFolders, useRecordings, useRecordingsCount, useTrashRecording, useArchiveRecording, useRestoreRecording, useMoveRecording, type ListRecordingsArgs, type RecordingSummary, } from "@/hooks/use-library"; import { cn } from "@/lib/utils"; import { BulkActionToolbar, type BulkMoveTarget } from "./bulk-action-toolbar"; import { EmptyState } from "./empty-state"; import { FilterChips, type FilterChip } from "./filter-chips"; import { PageHeader } from "./page-header"; import { RecordingCard } from "./recording-card"; import { SearchBar } from "./search-bar"; import { SortMenu, type SortKey } from "./sort-menu"; interface LibraryGridProps { view: "library" | "shared" | "space" | "archive" | "trash" | "all"; folderId?: string | null; spaceId?: string | null; /** What empty-state illustration to render. Defaults from `view`. */ emptyKind?: "library" | "shared" | "folder" | "space" | "archive" | "trash"; title?: string; tagFilter?: string | null; onClearTag?: () => void; extraActions?: React.ReactNode; } function Skeleton() { return (
); } const PAGE_SIZE = 100; interface FolderTargetRow { id: string; parentId: string | null; name: string; } type CreateFolderTarget = | { kind: "single"; recording: RecordingSummary } | { kind: "bulk"; recordingIds: string[] }; function buildMoveTargets( folders: FolderTargetRow[], currentFolderId: string | null, rootLabel: string, ): BulkMoveTarget[] { const byParent = new Map(); for (const folder of folders) { const parentId = folder.parentId ?? null; byParent.set(parentId, [...(byParent.get(parentId) ?? []), folder]); } const targets: BulkMoveTarget[] = [{ id: null, name: rootLabel }]; const walk = (parentId: string | null, depth: number) => { for (const folder of byParent.get(parentId) ?? []) { targets.push({ id: folder.id, name: folder.name, depth, disabled: folder.id === currentFolderId, }); walk(folder.id, depth + 1); } }; walk(null, 0); return targets; } export function LibraryGrid({ view, folderId = null, spaceId = null, emptyKind, title, tagFilter, onClearTag, extraActions, }: LibraryGridProps) { const t = useT(); const [sort, setSort] = useState("recent"); const [selected, setSelected] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); const selectionMode = selected.size > 0; const [sharingRec, setSharingRec] = useState(null); const [createFolderTarget, setCreateFolderTarget] = useState(null); const [isBulkPending, setIsBulkPending] = useState(false); const [page, setPage] = useState(1); const selectionStateKey = useMemo(() => `selection:${getBrowserTabId()}`, []); useEffect(() => { setPage(1); setSelected(new Set()); setLastSelectedId(null); }, [view, folderId, spaceId, tagFilter, sort]); const countArgs = useMemo( () => ({ view, folderId: folderId ?? null, spaceId: spaceId ?? null, tag: tagFilter ?? null, }), [view, folderId, spaceId, tagFilter], ); const { data: totalCount } = useRecordingsCount(countArgs); const total = totalCount ?? 0; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); useEffect(() => { if (page > totalPages) setPage(totalPages); }, [page, totalPages]); const args: ListRecordingsArgs = useMemo( () => ({ view, folderId: folderId ?? null, spaceId: spaceId ?? null, tag: tagFilter ?? null, sort, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, }), [view, folderId, spaceId, tagFilter, sort, page], ); const { data, isLoading, isError, refetch, isRefetching } = useRecordings(args); const recordings = data?.recordings ?? []; const trashRecording = useTrashRecording(); const archiveRecording = useArchiveRecording(); const restoreRecording = useRestoreRecording(); const moveRecording = useMoveRecording(); const canManageRecordings = view !== "shared"; const canMoveSelection = view === "library" || view === "space"; const { data: scopedFolders } = useFolders( { spaceId: view === "space" ? (spaceId ?? null) : null, }, { enabled: canMoveSelection && (view !== "space" || Boolean(spaceId)), }, ); const selectedIds = useMemo(() => Array.from(selected), [selected]); const moveTargets = useMemo( () => canMoveSelection ? buildMoveTargets( ((scopedFolders?.folders ?? []) as FolderTargetRow[]).map( (folder) => ({ id: folder.id, parentId: folder.parentId ?? null, name: folder.name, }), ), folderId ?? null, view === "space" ? t("libraryGrid.spaceRoot") : t("libraryGrid.libraryRoot"), ) : [], [canMoveSelection, folderId, scopedFolders, t, view], ); useEffect(() => { const state = selectedIds.length > 0 ? { type: "recordings", recordingIds: selectedIds, view, folderId: folderId ?? null, spaceId: spaceId ?? null, } : null; void setClientAppState(selectionStateKey, state, { keepalive: true, requestSource: "clips-library-selection", }).catch(() => {}); }, [folderId, selectedIds, selectionStateKey, spaceId, view]); useEffect(() => { return () => { void setClientAppState(selectionStateKey, null, { keepalive: true, requestSource: "clips-library-selection", }).catch(() => {}); }; }, [selectionStateKey]); const handleToggleSelect = useCallback( (id: string, shiftKey = false) => { setSelected((prev) => { if (shiftKey && lastSelectedId && lastSelectedId !== id) { const ids = recordings.map((r) => r.id); const fromIndex = ids.indexOf(lastSelectedId); const toIndex = ids.indexOf(id); if (fromIndex !== -1 && toIndex !== -1) { const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]; const next = new Set(prev); for (let i = start; i <= end; i++) next.add(ids[i]); return next; } } const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); setLastSelectedId(id); }, [lastSelectedId, recordings], ); const allSelected = recordings.length > 0 && recordings.every(({ id }) => selected.has(id)); const toggleSelectAll = useCallback(() => { setSelected((prev) => { const next = new Set(prev); const visibleIds = recordings.map(({ id }) => id); const allVisibleSelected = visibleIds.every((id) => prev.has(id)); for (const id of visibleIds) { if (allVisibleSelected) next.delete(id); else next.add(id); } return next; }); setLastSelectedId(null); }, [recordings]); const clearSelection = () => { setSelected(new Set()); setLastSelectedId(null); }; const moveRecordings = async ( ids: string[], targetFolderId: string | null, ) => { if (ids.length === 0) return; setIsBulkPending(true); try { await moveRecording.mutateAsync({ ids, folderId: targetFolderId, }); toast.success(t("libraryGrid.clipsMoved", { count: ids.length })); clearSelection(); } catch (err: any) { toast.error(err?.message ?? t("libraryGrid.moveFailed")); } finally { setIsBulkPending(false); } }; const moveSelected = (targetFolderId: string | null) => moveRecordings(selectedIds, targetFolderId); const moveSingle = async ( rec: RecordingSummary, targetFolderId: string | null, ) => { try { await moveRecording.mutateAsync({ id: rec.id, folderId: targetFolderId, }); toast.success(t("libraryGrid.clipsMoved", { count: 1 })); } catch (err: any) { toast.error(err?.message ?? t("libraryGrid.moveFailed")); } }; const chips: FilterChip[] = []; if (tagFilter) { chips.push({ key: `tag:${tagFilter}`, label: `#${tagFilter}`, active: true, onRemove: onClearTag, }); } const resolvedEmptyKind = emptyKind ?? (view === "archive" ? "archive" : view === "trash" ? "trash" : view === "shared" ? "shared" : view === "space" ? "space" : folderId ? "folder" : "library"); return (
{/* Share dialog — programmatically opened from the card context menu */} {sharingRec && ( { if (!open) setSharingRec(null); }} /> )} { if (!open) setCreateFolderTarget(null); }} spaceId={view === "space" ? spaceId : null} parentId={folderId} onCreated={(folder) => { if (!createFolderTarget) return; if (createFolderTarget.kind === "single") { void moveSingle(createFolderTarget.recording, folder.id); } else { void moveRecordings(createFolderTarget.recordingIds, folder.id); } }} /> {/* Page header — rendered into the top app bar */}
{title && (

{title}

)}
{extraActions}
{chips.length > 0 && (
)} {/* Grid body */}
0 && "pb-20", )} >
{isLoading ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : isError && recordings.length === 0 ? (

{t("libraryGrid.loadFailedTitle")}

{t("libraryGrid.loadFailedBody")}

) : recordings.length === 0 ? ( ) : (
{recordings.map((r: RecordingSummary) => ( setSharingRec(rec)} moveTargets={moveTargets} onMove={canMoveSelection ? moveSingle : undefined} isMovePending={moveRecording.isPending} onCreateFolder={() => { setCreateFolderTarget({ kind: "single", recording: r }); }} onTrash={ canManageRecordings ? (rec) => { trashRecording.mutate( { id: rec.id }, { onSuccess: () => toast.success(t("libraryGrid.movedToTrash")), }, ); } : undefined } onArchive={ canManageRecordings ? (rec) => { if (rec.archivedAt) { restoreRecording.mutate( { id: rec.id }, { onSuccess: () => toast.success( t("libraryGrid.restoredFromArchive"), ), }, ); } else { archiveRecording.mutate( { id: rec.id }, { onSuccess: () => toast.success(t("libraryGrid.archived")), }, ); } } : undefined } readOnly={!canManageRecordings} /> ))}
)}
{!isLoading && recordings.length > 0 && totalPages > 1 && (
{t("libraryGrid.paginationRange", { start: (page - 1) * PAGE_SIZE + 1, end: (page - 1) * PAGE_SIZE + recordings.length, total, })}
{t("libraryGrid.paginationPage", { page, totalPages })}
)} {/* Keep selected-library actions visible while the recording list scrolls. */} {canManageRecordings && selected.size > 0 && (
{ setIsBulkPending(true); try { const ids = Array.from(selected); const results = await Promise.allSettled( ids.map((id) => archiveRecording.mutateAsync({ id })), ); const succeededIds = ids.filter( (_, i) => results[i].status === "fulfilled", ); const failed = ids.length - succeededIds.length; if (succeededIds.length > 0) { toast.success( t("libraryGrid.clipsArchived", { count: succeededIds.length, }), ); setSelected((prev) => { const next = new Set(prev); succeededIds.forEach((id) => next.delete(id)); return next; }); } if (failed > 0) { toast.error( t("libraryGrid.clipsArchiveFailed", { count: failed }), ); } } finally { setIsBulkPending(false); } }} onTrash={async () => { setIsBulkPending(true); try { const ids = Array.from(selected); const results = await Promise.allSettled( ids.map((id) => trashRecording.mutateAsync({ id })), ); const succeededIds = ids.filter( (_, i) => results[i].status === "fulfilled", ); const failed = ids.length - succeededIds.length; if (succeededIds.length > 0) { toast.success( t("libraryGrid.clipsMovedToTrash", { count: succeededIds.length, }), ); setSelected((prev) => { const next = new Set(prev); succeededIds.forEach((id) => next.delete(id)); return next; }); } if (failed > 0) { toast.error( t("libraryGrid.clipsTrashFailed", { count: failed }), ); } } finally { setIsBulkPending(false); } }} onMove={canMoveSelection ? moveSelected : undefined} onCreateFolder={() => { setCreateFolderTarget({ kind: "bulk", recordingIds: selectedIds, }); }} onClear={clearSelection} isPending={isBulkPending || moveRecording.isPending} />
)}
); }