/** * Component: Admin Blocklist Page * Documentation: documentation/admin-features/release-blocklist.md * * Thin orchestrator: reads URL via useBlocklistUrlState, owns SWR + optimistic * row state, composes sub-components. Mirrors /admin/logs/page.tsx patterns. */ 'use client'; import { Suspense, useState, useEffect, useMemo } from 'react'; import useSWR from 'swr'; import { ToastProvider } from '@/components/ui/Toast'; import { authenticatedFetcher } from '@/lib/utils/api'; import { useBlocklistUrlState } from './hooks/useBlocklistUrlState'; import { BlockedReleaseRow, BlocklistData, buildBlocklistApiKey, computeEmptyState, hasActiveFilters, hasActiveSearch, ValidLimit, } from './types'; import { BlocklistToolbar } from './components/BlocklistToolbar'; import BlocklistFilters from './components/BlocklistFilters'; import BlocklistActiveFilterChips from './components/BlocklistActiveFilterChips'; import { BlocklistTable } from './components/BlocklistTable'; import { BlocklistPagination } from './components/BlocklistPagination'; import { BlocklistSkeleton } from './components/BlocklistSkeleton'; function EmptyState({ kind, onClearFilters, onClearSearch, searchValue, }: { kind: 'fresh' | 'filters-too-tight' | 'search-no-match'; onClearFilters: () => void; onClearSearch: () => void; searchValue: string; }) { if (kind === 'fresh') { return (

No blocked releases.

RMAB will add releases here automatically when downloads or imports fail.

); } if (kind === 'search-no-match') { return (

No matches for “{searchValue}”.

); } return (

No entries match your current filters.

); } function AdminBlocklistContent() { const { filters, setFilters, clearAll } = useBlocklistUrlState(); const key = buildBlocklistApiKey(filters); const { data, error, mutate } = useSWR(key, authenticatedFetcher, { keepPreviousData: true, }); // Optimistic-removal overlay: ids removed by the current session's Unblock // clicks. Once SWR returns fresh data, the next-render derivation drops any // ids that are no longer present anyway. const [optimisticRemoved, setOptimisticRemoved] = useState>(() => new Set()); // Reconcile optimistic state with server data: any id we removed that is // also absent from the new data can be forgotten. useEffect(() => { if (!data) return; setOptimisticRemoved((prev) => { if (prev.size === 0) return prev; const serverIds = new Set(data.entries.map((e) => e.id)); const next = new Set(); for (const id of prev) { if (serverIds.has(id)) next.add(id); } return next.size === prev.size ? prev : next; }); }, [data]); const visibleEntries = useMemo(() => { if (!data) return []; if (optimisticRemoved.size === 0) return data.entries; return data.entries.filter((e) => !optimisticRemoved.has(e.id)); }, [data, optimisticRemoved]); const handleUnblocked = (id: string) => { setOptimisticRemoved((prev) => { const next = new Set(prev); next.add(id); return next; }); }; const handleUnblockFailed = (entry: BlockedReleaseRow) => { // Roll back the optimistic removal. The next SWR cycle will re-fetch. setOptimisticRemoved((prev) => { if (!prev.has(entry.id)) return prev; const next = new Set(prev); next.delete(entry.id); return next; }); }; const handleBulkCleared = () => { // Drop optimistic state and refresh — bulk delete invalidates row mapping. setOptimisticRemoved(new Set()); mutate(); }; const showSkeleton = !data; const total = data?.pagination.total ?? 0; const pagination = data?.pagination ?? { page: filters.page, limit: filters.limit, total: 0, totalPages: 1, }; const emptyKind = computeEmptyState({ total: visibleEntries.length, hasFilters: hasActiveFilters(filters), hasSearch: hasActiveSearch(filters), }); return (
{error && (

Error Loading Blocklist

{error?.message || 'Failed to load blocklist'}

)} {showSkeleton ? ( ) : emptyKind ? ( setFilters({ search: '' })} searchValue={filters.search} /> ) : ( <> setFilters({ page })} onLimitChange={(limit: ValidLimit) => setFilters({ limit })} /> )}
); } export default function AdminBlocklistPage() { return ( ); }