/**
* 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 (
);
}