mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
b1492fc32e
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.
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
/**
|
|
* Component: BlocklistTable
|
|
* Documentation: documentation/admin-features/release-blocklist.md
|
|
*
|
|
* Desktop = sortable table, mobile = stacked cards. Sortable columns clickable
|
|
* with explicit affordance (cursor + sort icon) — per zach.md UX rule on
|
|
* intentional affordances.
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useBlocklistUrlState } from '../hooks/useBlocklistUrlState';
|
|
import { BlockedReleaseRow, SortField } from '../types';
|
|
import { BlocklistRow } from './BlocklistRow';
|
|
|
|
interface BlocklistTableProps {
|
|
entries: BlockedReleaseRow[];
|
|
onUnblocked: (id: string) => void;
|
|
onUnblockFailed: (entry: BlockedReleaseRow, error: string) => void;
|
|
}
|
|
|
|
interface SortableHeaderProps {
|
|
field: SortField;
|
|
label: string;
|
|
className?: string;
|
|
}
|
|
|
|
function SortableHeader({ field, label, className = '' }: SortableHeaderProps) {
|
|
const { filters, setFilters } = useBlocklistUrlState();
|
|
const isActive = filters.sortBy === field;
|
|
const order = filters.sortOrder;
|
|
|
|
const handleClick = () => {
|
|
if (isActive) {
|
|
setFilters({ sortOrder: order === 'asc' ? 'desc' : 'asc' });
|
|
} else {
|
|
setFilters({ sortBy: field, sortOrder: 'desc' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<th
|
|
className={`px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${className}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={handleClick}
|
|
className="inline-flex items-center gap-1.5 hover:text-gray-900 dark:hover:text-gray-100 transition-colors uppercase tracking-wider font-medium"
|
|
aria-label={`Sort by ${label}`}
|
|
>
|
|
{label}
|
|
<SortGlyph active={isActive} order={order} />
|
|
</button>
|
|
</th>
|
|
);
|
|
}
|
|
|
|
function SortGlyph({ active, order }: { active: boolean; order: 'asc' | 'desc' }) {
|
|
if (!active) {
|
|
return (
|
|
<svg className="w-3.5 h-3.5 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
|
</svg>
|
|
);
|
|
}
|
|
return order === 'asc' ? (
|
|
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function BlocklistTable({ entries, onUnblocked, onUnblockFailed }: BlocklistTableProps) {
|
|
return (
|
|
<>
|
|
{/* Mobile cards */}
|
|
<div className="space-y-3 sm:hidden">
|
|
{entries.map((entry) => (
|
|
<BlocklistRow.Mobile
|
|
key={entry.id}
|
|
entry={entry}
|
|
onUnblocked={onUnblocked}
|
|
onUnblockFailed={onUnblockFailed}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Desktop table */}
|
|
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
|
<tr>
|
|
<SortableHeader field="releaseName" label="Release name" />
|
|
<SortableHeader field="reason" label="Reason" />
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Source
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Associated request
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Indexer
|
|
</th>
|
|
<SortableHeader field="createdAt" label="Blocked at" />
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{entries.map((entry) => (
|
|
<BlocklistRow.Desktop
|
|
key={entry.id}
|
|
entry={entry}
|
|
onUnblocked={onUnblocked}
|
|
onUnblockFailed={onUnblockFailed}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|