mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Add release blocklist feature
Introduce a per-request release blocklist to auto-block permanently failing releases and provide admin management. Changes include: - Database: add BlockedRelease model (blocked_releases) to Prisma schema with unique (requestId, releaseKey) and indexes; documented in backend database docs. - Service & utils: new blocklist.service, release-key and filter helpers for normalization and matching; processors updated to emit auto-blocks (monitor-download, organize-files, search processors, RSS). - HTTP API: add admin endpoints GET/DELETE /api/admin/blocklist, DELETE /api/admin/blocklist/[id], and GET /api/admin/blocklist/by-request/[requestId]. - Admin UI: new /admin/blocklist page and numerous React components (toolbar, filters, table, rows, pagination, skeleton, chips, date picker) with URL-driven state hook and per-row unblock UX. - Tests: add unit/integration tests for service, routes, utils, and updated processor tests. The blocklist is idempotent (upsert), filters search results before ranking (interactive search shows badges only), and admin-only APIs require auth. This commit wires docs, API, DB, frontend and tests for the new feature.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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 (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No blocked releases.
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
RMAB will add releases here automatically when downloads or imports fail.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'search-no-match') {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No matches for “{searchValue}”.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSearch}
|
||||
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No entries match your current filters.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearFilters}
|
||||
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminBlocklistContent() {
|
||||
const { filters, setFilters, clearAll } = useBlocklistUrlState();
|
||||
const key = buildBlocklistApiKey(filters);
|
||||
|
||||
const { data, error, mutate } = useSWR<BlocklistData>(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<Set<string>>(() => 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<string>();
|
||||
for (const id of prev) {
|
||||
if (serverIds.has(id)) next.add(id);
|
||||
}
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const visibleEntries = useMemo<BlockedReleaseRow[]>(() => {
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<BlocklistToolbar total={total} onCleared={handleBulkCleared} />
|
||||
<BlocklistFilters />
|
||||
<BlocklistActiveFilterChips />
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Blocklist
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load blocklist'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSkeleton ? (
|
||||
<BlocklistSkeleton />
|
||||
) : emptyKind ? (
|
||||
<EmptyState
|
||||
kind={emptyKind}
|
||||
onClearFilters={clearAll}
|
||||
onClearSearch={() => setFilters({ search: '' })}
|
||||
searchValue={filters.search}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<BlocklistTable
|
||||
entries={visibleEntries}
|
||||
onUnblocked={handleUnblocked}
|
||||
onUnblockFailed={handleUnblockFailed}
|
||||
/>
|
||||
<BlocklistPagination
|
||||
pagination={pagination}
|
||||
onPageChange={(page) => setFilters({ page })}
|
||||
onLimitChange={(limit: ValidLimit) => setFilters({ limit })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBlocklistPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ToastProvider>
|
||||
<AdminBlocklistContent />
|
||||
</ToastProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user