Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1bc984a
Add System Tasks page with a scheduled job registry
claude Oct 13, 2026
2187809
Add Activity History and Blocklist pages with a download event log
claude Oct 13, 2026
92ef7d3
Warn about archives left unextracted after an import
claude Oct 13, 2026
da98ec8
Remove PR screenshots
claude Oct 13, 2026
e3ec32f
Fix navigation icon mock and harden System Tasks
claude Oct 13, 2026
b5cce4b
Add search to History and System Tasks and an Unblock all button
claude Oct 13, 2026
f1b376d
Remove PR screenshots
claude Oct 13, 2026
8195842
Record a completed event for downloads inserted already completed
claude Oct 13, 2026
d06f2fd
Bound leftover archive scan and push its notification over Socket.IO
claude Oct 13, 2026
b55886c
Address CodeRabbit review: bounded directory reads, atomic status eve…
claude Oct 17, 2026
fd7de2e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 17, 2026
b59eea9
Keep field updates on same-status changes and make blocklist event be…
claude Oct 17, 2026
7bc2a20
Show an error with retry when the task list fails to load
claude Oct 17, 2026
9fc4680
Refresh System Tasks on socket reconnect, tailor the leftover-archive…
claude Oct 18, 2026
2639471
Cap archive sets per directory so the leftover scan stays linear in p…
claude Oct 18, 2026
b566cc6
Write download status and metadata in a single update
claude Oct 18, 2026
962ceae
Gate the download event on a single conditional status update
claude Oct 18, 2026
8738c15
Keep the same-status metadata update from overwriting a newer status
claude Oct 18, 2026
3845eb5
Order download events deterministically when timestamps tie
claude Oct 18, 2026
09478b8
Record a needs_review event when an orphaned download is relinked
claude Oct 18, 2026
25c5223
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 19, 2026
40180f8
Test that unblock-all keeps other users' blocklist entries
claude Oct 19, 2026
5b6bfdc
Merge main into the activity and system tasks branch
claude Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/__tests__/MobileBottomNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import MobileBottomNav from "../src/components/MobileBottomNav";
vi.mock("lucide-react", () => {
const Icon = () => <svg data-testid="icon" />;
return {
Ban: Icon,
ClipboardList: Icon,
History: Icon,
Timer: Icon,
MoreHorizontal: Icon,
Calendar: Icon,
Compass: Icon,
Expand Down
6 changes: 6 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const SetupPage = lazy(() => import("@/pages/auth/setup"));
const StatsPage = lazy(() => import("@/pages/stats"));
const LogsPage = lazy(() => import("@/pages/logs"));
const ImportHistoryPage = lazy(() => import("@/pages/import-history"));
const ActivityHistoryPage = lazy(() => import("@/pages/activity-history"));
const ActivityBlocklistPage = lazy(() => import("@/pages/activity-blocklist"));
const SystemTasksPage = lazy(() => import("@/pages/system-tasks"));
const PlayPage = lazy(() => import("@/pages/play"));

// Konami code: an undocumented shortcut into the /play easter egg.
Expand Down Expand Up @@ -139,6 +142,9 @@ function Router() {
<Route path={routePaths.stats} component={StatsPage} />
<Route path={routePaths.logs} component={LogsPage} />
<Route path={routePaths.importHistory} component={ImportHistoryPage} />
<Route path={routePaths.activityHistory} component={ActivityHistoryPage} />
<Route path={routePaths.activityBlocklist} component={ActivityBlocklistPage} />
<Route path={routePaths.systemTasks} component={SystemTasksPage} />
<Route component={NotFound} />
</Switch>
</Suspense>
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/navigation-items.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
Calendar,
ClipboardList,
Ban,
History,
Timer,
Compass,
Database,
Download,
Expand Down Expand Up @@ -65,7 +68,10 @@ export const managementNavigation: AppNavItem[] = [

export const activityNavigation: AppNavItem[] = [
{ title: "Logs", url: "/logs", icon: ScrollText },
{ title: "History", url: "/activity/history", icon: History },
{ title: "Blocklist", url: "/activity/blocklist", icon: Ban },
{ title: "Import History", url: "/activity/imports", icon: ClipboardList },
{ title: "System Tasks", url: "/system/tasks", icon: Timer },
];

export const mobileBottomNavigation: AppNavItem[] = [
Expand Down
6 changes: 6 additions & 0 deletions client/src/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const routePaths = {
stats: "/stats",
logs: "/logs",
importHistory: "/activity/imports",
activityHistory: "/activity/history",
activityBlocklist: "/activity/blocklist",
systemTasks: "/system/tasks",
} as const;

export const appRoutePaths = [
Expand All @@ -39,4 +42,7 @@ export const appRoutePaths = [
routePaths.stats,
routePaths.logs,
routePaths.importHistory,
routePaths.activityHistory,
routePaths.activityBlocklist,
routePaths.systemTasks,
] as const;
223 changes: 223 additions & 0 deletions client/src/pages/activity-blocklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Search, Trash2 } from "lucide-react";
import { Link } from "wouter";
import PageHeader from "@/components/PageHeader";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { routePaths } from "@/lib/routes";
import type { ReleaseBlacklist } from "@shared/schema";

type BlocklistEntry = ReleaseBlacklist & { gameTitle: string };

function formatDate(value: Date | string | null): string {
return value ? new Date(value).toLocaleDateString() : "—";
}

export default function ActivityBlocklistPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [filter, setFilter] = useState("");

const { data, isLoading, isError, refetch } = useQuery<BlocklistEntry[]>({
queryKey: ["/api/blacklist"],
});

const removeMutation = useMutation({
mutationFn: async ({ gameId, id }: { gameId: string; id: string }) => {
await apiRequest("DELETE", `/api/games/${gameId}/blacklist/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/blacklist"] });
toast({ description: "Release removed from the blocklist" });
},
onError: () => toast({ variant: "destructive", description: "Failed to remove the release" }),
});

const clearMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("DELETE", "/api/activity/blocklist");
return (await res.json()) as { removed: number };
},
onSuccess: ({ removed }) => {
queryClient.invalidateQueries({ queryKey: ["/api/blacklist"] });
toast({ description: `${removed} release${removed === 1 ? "" : "s"} unblocked` });
},
onError: () => toast({ variant: "destructive", description: "Failed to clear the blocklist" }),
});

const entries = useMemo(() => {
const needle = filter.trim().toLowerCase();
const all = data ?? [];
if (!needle) return all;
return all.filter(
(e) =>
e.releaseTitle.toLowerCase().includes(needle) ||
e.gameTitle.toLowerCase().includes(needle) ||
(e.indexerName ?? "").toLowerCase().includes(needle)
);
}, [data, filter]);

const remove = (entry: BlocklistEntry) =>
removeMutation.mutate({ gameId: entry.gameId, id: entry.id });

let body;
if (isLoading) {
body = <p className="text-sm text-muted-foreground">Loading...</p>;
} else if (isError) {
body = (
<div className="flex items-center gap-3 text-sm text-destructive">
<span>Could not load the blocklist.</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
);
} else if (entries.length === 0) {
body = (
<div className="flex h-48 flex-col items-center justify-center text-slate-500">
<Ban className="mb-3 h-10 w-10 opacity-30" aria-hidden="true" />
<p className="text-sm">
{data?.length ? "No release matches the filter" : "Nothing blocked"}
</p>
</div>
);
} else {
body = (
<>
<ul className="flex flex-col gap-3 md:hidden" aria-label="Blocked releases">
{entries.map((entry) => (
<li key={entry.id} className="rounded-lg border border-[#374151]/40 bg-card p-4">
<p className="break-words text-sm font-medium text-card-foreground">
{entry.releaseTitle}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{entry.gameTitle} · {entry.indexerName ?? "unknown indexer"} ·{" "}
{formatDate(entry.createdAt)}
</p>
<Button
variant="secondary"
className="mt-3 h-11 w-full"
disabled={removeMutation.isPending}
onClick={() => remove(entry)}
aria-label={`Remove ${entry.releaseTitle} from the blocklist`}
>
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
Unblock
</Button>
</li>
))}
</ul>
<div className="hidden overflow-hidden rounded-lg border border-[#374151]/40 md:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#374151]/40 bg-[#111827] text-left text-slate-400">
<th className="px-4 py-3 font-medium">Release</th>
<th className="px-4 py-3 font-medium">Game</th>
<th className="px-4 py-3 font-medium">Indexer</th>
<th className="px-4 py-3 font-medium">Blocked on</th>
<th className="px-4 py-3 text-right font-medium">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="border-b border-[#374151]/30 last:border-0">
<td className="break-all px-4 py-3 text-card-foreground">{entry.releaseTitle}</td>
<td className="px-4 py-3 text-slate-300">{entry.gameTitle}</td>
<td className="px-4 py-3 text-slate-300">{entry.indexerName ?? "—"}</td>
<td className="px-4 py-3 text-slate-300">{formatDate(entry.createdAt)}</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="secondary"
disabled={removeMutation.isPending}
onClick={() => remove(entry)}
aria-label={`Remove ${entry.releaseTitle} from the blocklist`}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Unblock
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

return (
<div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6">
<PageHeader
title="Blocklist"
description="Releases you blocked from a game's download results. They are skipped by search and auto-download."
actions={
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="h-11 justify-center sm:h-9"
disabled={!data?.length || clearMutation.isPending}
>
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
Unblock all
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unblock all releases?</AlertDialogTitle>
<AlertDialogDescription>
{data?.length ?? 0} blocked release{data?.length === 1 ? "" : "s"} will show up in
search results and can be auto-downloaded again.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => clearMutation.mutate()}>
Unblock all
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
<p className="mb-4 text-sm text-muted-foreground">
Release name terms that hide matching releases everywhere live in{" "}
<Link href={routePaths.settings} className="text-primary underline underline-offset-2">
Settings
</Link>{" "}
under Discovery &amp; Downloads.
</p>
<div className="relative mb-4 max-w-md">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter by release, game or indexer"
aria-label="Filter the blocklist"
className="h-11 pl-9 sm:h-10"
/>
</div>
{body}
</div>
);
}
Loading
Loading