drop audio list tab, move per-item actions onto pipeline cards
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
The pipeline tab fully replaces the audio list: same items, better workflow. What the old list contributed (per-item details + skip/approve) now lives inline on each pipeline card. - delete src/routes/review/audio/index.tsx + src/features/review/AudioListPage.tsx - /review/ now redirects to /pipeline (was /review/audio, which no longer exists) - AudioDetailPage back link goes to /pipeline - nav: drop the Audio link - PipelineCard: three buttons on every card — Details (TanStack Link to /review/audio/$id — the detail route stays, it's how you drill in), Skip (POST /api/review/:id/skip), Approve (POST /api/review/:id/approve). Remove the old 'Approve up to here' button (it was computing against frontend ordering we don't want to maintain, and it was broken). - SeriesCard: drop onApproveUpTo, pass new approve/skip handlers through to each expanded episode card - server: remove now-unused POST /api/review/approve-batch (no callers)
This commit is contained in:
@@ -734,48 +734,6 @@ app.post("/:id/rescan", async (c) => {
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Pipeline: approve a batch of plan IDs ──────────────────────────────────
|
||||
//
|
||||
// The pipeline UI groups episodes into series cards and interleaves them
|
||||
// with movies in a frontend-specific order, so we can't reconstruct
|
||||
// "up to here" by re-running an ORDER BY on the server. The client knows
|
||||
// exactly which plans are visually before (and including) the clicked card
|
||||
// and sends them as an explicit list.
|
||||
|
||||
app.post("/approve-batch", async (c) => {
|
||||
const body = await c.req.json<{ planIds: unknown }>().catch(() => ({ planIds: null }));
|
||||
if (!Array.isArray(body.planIds) || !body.planIds.every((id) => typeof id === "number" && id > 0)) {
|
||||
return c.json({ error: "planIds must be an array of positive integers" }, 400);
|
||||
}
|
||||
const planIds = body.planIds as number[];
|
||||
if (planIds.length === 0) return c.json({ approved: 0 });
|
||||
const db = getDb();
|
||||
const toApprove = planIds;
|
||||
|
||||
// Only approve plans that are still pending and not noop. Skip silently
|
||||
// if a plan was already approved/skipped or doesn't exist — keeps batch
|
||||
// idempotent under concurrent edits.
|
||||
let approved = 0;
|
||||
for (const planId of toApprove) {
|
||||
const planRow = db
|
||||
.prepare(
|
||||
"SELECT id, item_id, status, is_noop, job_type FROM review_plans WHERE id = ? AND status = 'pending' AND is_noop = 0",
|
||||
)
|
||||
.get(planId) as { id: number; item_id: number; job_type: string } | undefined;
|
||||
if (!planRow) continue;
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId);
|
||||
const detail = loadItemDetail(db, planRow.item_id);
|
||||
if (detail.item && detail.command) {
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')")
|
||||
.run(planRow.item_id, detail.command, planRow.job_type);
|
||||
approved++;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ approved });
|
||||
});
|
||||
|
||||
// ─── Pipeline: series language ───────────────────────────────────────────────
|
||||
|
||||
app.patch("/series/:seriesKey/language", async (c) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||
|
||||
@@ -5,10 +6,11 @@ interface PipelineCardProps {
|
||||
item: any;
|
||||
jellyfinUrl: string;
|
||||
onLanguageChange?: (lang: string) => void;
|
||||
onApproveUpTo?: () => void;
|
||||
onApprove?: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) {
|
||||
export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, onSkip }: PipelineCardProps) {
|
||||
const title =
|
||||
item.type === "Episode"
|
||||
? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")} — ${item.name}`
|
||||
@@ -19,8 +21,12 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
|
||||
const jellyfinLink =
|
||||
jellyfinUrl && item.jellyfin_id ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` : null;
|
||||
|
||||
// item.item_id is present in pipeline payloads; card can also be fed raw
|
||||
// media_item rows (no plan) in which case we fall back to item.id.
|
||||
const mediaItemId: number = item.item_id ?? item.id;
|
||||
|
||||
return (
|
||||
<div className={`group rounded-lg border p-3 ${confidenceColor}`}>
|
||||
<div className={`rounded-lg border p-3 ${confidenceColor}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
{jellyfinLink ? (
|
||||
@@ -65,14 +71,34 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onApproveUpTo && (
|
||||
<button
|
||||
onClick={onApproveUpTo}
|
||||
className="mt-2 w-full text-xs py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(mediaItemId) }}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-100 no-underline"
|
||||
>
|
||||
Approve up to here
|
||||
</button>
|
||||
)}
|
||||
Details
|
||||
</Link>
|
||||
{onSkip && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{onApprove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApprove}
|
||||
className="text-xs px-3 py-1 rounded bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
||||
await api.post("/api/review/skip-all");
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const approveItem = async (itemId: number) => {
|
||||
await api.post(`/api/review/${itemId}/approve`);
|
||||
onMutate();
|
||||
};
|
||||
const skipItem = async (itemId: number) => {
|
||||
await api.post(`/api/review/${itemId}/skip`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
// Group by series (movies are standalone)
|
||||
const movies = items.filter((i: any) => i.type === "Movie");
|
||||
const seriesMap = new Map<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>();
|
||||
@@ -40,25 +50,6 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
||||
})),
|
||||
].sort((a, b) => a.sortKey - b.sortKey);
|
||||
|
||||
/** All plan IDs the user can see, in visible order — the source of truth for "up to here". */
|
||||
const visiblePlanIds: number[] = allItems.flatMap((entry) =>
|
||||
entry.type === "movie" ? [entry.item.id] : entry.item.episodes.map((e: any) => e.id),
|
||||
);
|
||||
|
||||
/** Approve every visible plan from the top through (and including) the given index. */
|
||||
const approveUpToIndex = async (visibleIndex: number) => {
|
||||
const planIds = visiblePlanIds.slice(0, visibleIndex + 1);
|
||||
if (planIds.length === 0) return;
|
||||
await api.post("/api/review/approve-batch", { planIds });
|
||||
onMutate();
|
||||
};
|
||||
|
||||
/** Index of the last plan in this entry within the visible list — used as the "up to" boundary. */
|
||||
const lastVisibleIndex = (entry: (typeof allItems)[number]): number => {
|
||||
const lastId = entry.type === "movie" ? entry.item.id : entry.item.episodes[entry.item.episodes.length - 1]?.id;
|
||||
return visiblePlanIds.lastIndexOf(lastId);
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnShell
|
||||
title="Review"
|
||||
@@ -77,23 +68,22 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
||||
await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang });
|
||||
onMutate();
|
||||
}}
|
||||
onApproveUpTo={() => approveUpToIndex(lastVisibleIndex(entry))}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SeriesCard
|
||||
key={entry.item.key}
|
||||
seriesKey={entry.item.key}
|
||||
seriesName={entry.item.name}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
seriesJellyfinId={entry.item.jellyfinId}
|
||||
episodes={entry.item.episodes}
|
||||
onMutate={onMutate}
|
||||
onApproveUpTo={() => approveUpToIndex(lastVisibleIndex(entry))}
|
||||
onApprove={() => approveItem(entry.item.item_id)}
|
||||
onSkip={() => skipItem(entry.item.item_id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeriesCard
|
||||
key={entry.item.key}
|
||||
seriesKey={entry.item.key}
|
||||
seriesName={entry.item.name}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
seriesJellyfinId={entry.item.jellyfinId}
|
||||
episodes={entry.item.episodes}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
{truncated && (
|
||||
|
||||
@@ -10,7 +10,6 @@ interface SeriesCardProps {
|
||||
seriesJellyfinId: string | null;
|
||||
episodes: any[];
|
||||
onMutate: () => void;
|
||||
onApproveUpTo?: () => void;
|
||||
}
|
||||
|
||||
export function SeriesCard({
|
||||
@@ -20,7 +19,6 @@ export function SeriesCard({
|
||||
seriesJellyfinId,
|
||||
episodes,
|
||||
onMutate,
|
||||
onApproveUpTo,
|
||||
}: SeriesCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -97,17 +95,6 @@ export function SeriesCard({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{onApproveUpTo && (
|
||||
<div className="px-3 pb-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onApproveUpTo}
|
||||
className="w-full text-xs py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
||||
>
|
||||
Approve up to here
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t px-3 pb-3 space-y-2 pt-2">
|
||||
{episodes.map((ep: any) => (
|
||||
@@ -119,6 +106,14 @@ export function SeriesCard({
|
||||
await api.patch(`/api/review/${ep.item_id}/language`, { language: lang });
|
||||
onMutate();
|
||||
}}
|
||||
onApprove={async () => {
|
||||
await api.post(`/api/review/${ep.item_id}/approve`);
|
||||
onMutate();
|
||||
}}
|
||||
onSkip={async () => {
|
||||
await api.post(`/api/review/${ep.item_id}/skip`);
|
||||
onMutate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -266,8 +266,8 @@ export function AudioDetailPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">
|
||||
<Link to="/review/audio" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
|
||||
← Audio
|
||||
<Link to="/pipeline" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
|
||||
← Pipeline
|
||||
</Link>
|
||||
{item.name}
|
||||
</h1>
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { langName } from "~/shared/lib/lang";
|
||||
import type { MediaItem, ReviewPlan } from "~/shared/lib/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MovieRow {
|
||||
item: MediaItem;
|
||||
plan: ReviewPlan | null;
|
||||
removeCount: number;
|
||||
keepCount: number;
|
||||
}
|
||||
|
||||
interface SeriesGroup {
|
||||
series_key: string;
|
||||
series_name: string;
|
||||
original_language: string | null;
|
||||
season_count: number;
|
||||
episode_count: number;
|
||||
noop_count: number;
|
||||
needs_action_count: number;
|
||||
approved_count: number;
|
||||
skipped_count: number;
|
||||
done_count: number;
|
||||
error_count: number;
|
||||
manual_count: number;
|
||||
}
|
||||
|
||||
interface ReviewListData {
|
||||
movies: MovieRow[];
|
||||
series: SeriesGroup[];
|
||||
filter: string;
|
||||
totalCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
// ─── Filter tabs ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "needs_action", label: "Needs Action" },
|
||||
{ key: "noop", label: "No Change" },
|
||||
{ key: "manual", label: "Manual Review" },
|
||||
{ key: "approved", label: "Approved" },
|
||||
{ key: "skipped", label: "Skipped" },
|
||||
{ key: "done", label: "Done" },
|
||||
{ key: "error", label: "Error" },
|
||||
];
|
||||
|
||||
// ─── Status pills ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusPills({ g }: { g: SeriesGroup }) {
|
||||
return (
|
||||
<span className="inline-flex flex-wrap gap-1 items-center">
|
||||
{g.noop_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
|
||||
{g.noop_count} ok
|
||||
</span>
|
||||
)}
|
||||
{g.needs_action_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
|
||||
{g.needs_action_count} action
|
||||
</span>
|
||||
)}
|
||||
{g.approved_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">
|
||||
{g.approved_count} approved
|
||||
</span>
|
||||
)}
|
||||
{g.done_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-800">
|
||||
{g.done_count} done
|
||||
</span>
|
||||
)}
|
||||
{g.error_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
|
||||
{g.error_count} err
|
||||
</span>
|
||||
)}
|
||||
{g.skipped_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
|
||||
{g.skipped_count} skip
|
||||
</span>
|
||||
)}
|
||||
{g.manual_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">
|
||||
{g.manual_count} manual
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Th helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
const Th = ({ children }: { children: React.ReactNode }) => (
|
||||
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
|
||||
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
||||
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
|
||||
);
|
||||
|
||||
// ─── Series row (collapsible) ─────────────────────────────────────────────────
|
||||
|
||||
function SeriesRow({ g }: { g: SeriesGroup }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const urlKey = encodeURIComponent(g.series_key);
|
||||
|
||||
interface EpisodeItem {
|
||||
item: MediaItem;
|
||||
plan: ReviewPlan | null;
|
||||
removeCount: number;
|
||||
}
|
||||
interface SeasonGroup {
|
||||
season: number | null;
|
||||
episodes: EpisodeItem[];
|
||||
noopCount: number;
|
||||
actionCount: number;
|
||||
approvedCount: number;
|
||||
doneCount: number;
|
||||
}
|
||||
|
||||
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
|
||||
|
||||
const toggle = async () => {
|
||||
if (!open && seasons === null) {
|
||||
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/review/series/${urlKey}/episodes`);
|
||||
setSeasons(data.seasons);
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
const approveAll = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await api.post(`/api/review/series/${urlKey}/approve-all`);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const approveSeason = async (e: React.MouseEvent, season: number | null) => {
|
||||
e.stopPropagation();
|
||||
await api.post(`/api/review/season/${urlKey}/${season ?? 0}/approve-all`);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending"));
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? "rotate-90" : ""}`}
|
||||
>
|
||||
▶
|
||||
</span>{" "}
|
||||
<strong>{g.series_name}</strong>
|
||||
</td>
|
||||
<Td>{langName(g.original_language)}</Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
|
||||
<Td>
|
||||
<StatusPills g={g} />
|
||||
</Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
{g.needs_action_count > 0 && (
|
||||
<Button size="xs" onClick={approveAll}>
|
||||
Approve all
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{open && seasons && (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0 border-b border-gray-100">
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{seasons.map((s) => (
|
||||
<>
|
||||
<tr key={`season-${s.season}`} className="bg-gray-50">
|
||||
<td
|
||||
colSpan={4}
|
||||
className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100"
|
||||
>
|
||||
Season {s.season ?? "?"}
|
||||
<span className="ml-3 inline-flex gap-1">
|
||||
{s.noopCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">
|
||||
{s.noopCount} ok
|
||||
</span>
|
||||
)}
|
||||
{s.actionCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">
|
||||
{s.actionCount} action
|
||||
</span>
|
||||
)}
|
||||
{s.approvedCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">
|
||||
{s.approvedCount} approved
|
||||
</span>
|
||||
)}
|
||||
{s.doneCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-cyan-100 text-cyan-800 text-[0.7rem]">
|
||||
{s.doneCount} done
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{s.actionCount > 0 && (
|
||||
<Button size="xs" variant="secondary" className="ml-3" onClick={(e) => approveSeason(e, s.season)}>
|
||||
Approve season
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{s.episodes.map(({ item, plan, removeCount }) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
|
||||
<span className="text-gray-400 font-mono text-xs">
|
||||
E{String(item.episode_number ?? 0).padStart(2, "0")}
|
||||
</span>{" "}
|
||||
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
||||
{removeCount > 0 ? (
|
||||
<Badge variant="remove">−{removeCount}</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
||||
<Badge variant={statusKey(plan) as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>
|
||||
{plan?.is_noop ? "ok" : (plan?.status ?? "pending")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
||||
{plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} size="xs" />}
|
||||
{plan?.status === "pending" && <SkipBtn itemId={item.id} size="xs" />}
|
||||
{plan?.status === "skipped" && <UnskipBtn itemId={item.id} size="xs" />}
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(item.id) }}
|
||||
className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
|
||||
>
|
||||
Detail
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Action buttons ───────────────────────────────────────────────────────────
|
||||
|
||||
function ApproveBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||
const onClick = async () => {
|
||||
await api.post(`/api/review/${itemId}/approve`);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Button size={size ?? "xs"} onClick={onClick}>
|
||||
Approve
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||
const onClick = async () => {
|
||||
await api.post(`/api/review/${itemId}/skip`);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
|
||||
Skip
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function UnskipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||
const onClick = async () => {
|
||||
await api.post(`/api/review/${itemId}/unskip`);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
|
||||
Unskip
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const cache = new Map<string, ReviewListData>();
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AudioListPage() {
|
||||
const { filter } = useSearch({ from: "/review/audio/" });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<ReviewListData | null>(cache.get(filter) ?? null);
|
||||
const [loading, setLoading] = useState(!cache.has(filter));
|
||||
|
||||
useEffect(() => {
|
||||
const cached = cache.get(filter);
|
||||
if (cached) {
|
||||
setData(cached);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
api
|
||||
.get<ReviewListData>(`/api/review?filter=${filter}`)
|
||||
.then((d) => {
|
||||
cache.set(filter, d);
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [filter]);
|
||||
|
||||
const approveAll = async () => {
|
||||
await api.post("/api/review/approve-all");
|
||||
cache.clear();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <div className="text-red-600">Failed to load.</div>;
|
||||
|
||||
const { movies, series, totalCounts } = data;
|
||||
const hasPending = (totalCounts.needs_action ?? 0) > 0;
|
||||
const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending"));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-4">Audio Review</h1>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
|
||||
{hasPending ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">
|
||||
{totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? "s" : ""} need
|
||||
{totalCounts.needs_action === 1 ? "s" : ""} review
|
||||
</span>
|
||||
<Button size="sm" onClick={approveAll}>
|
||||
Approve all pending
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-medium">All items reviewed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterTabs
|
||||
tabs={FILTER_TABS}
|
||||
filter={filter}
|
||||
totalCounts={totalCounts}
|
||||
onFilterChange={(key) => navigate({ to: "/review/audio", search: { filter: key } as never })}
|
||||
/>
|
||||
|
||||
{movies.length === 0 && series.length === 0 && <p className="text-gray-500">No items match this filter.</p>}
|
||||
|
||||
{/* Movies */}
|
||||
{movies.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-5 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
|
||||
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Lang</Th>
|
||||
<Th>Remove</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Actions</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movies.map(({ item, plan, removeCount }) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<Td>
|
||||
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
{item.needs_review && !item.original_language ? (
|
||||
<Badge variant="manual">manual</Badge>
|
||||
) : (
|
||||
<span>{langName(item.original_language)}</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{removeCount > 0 ? <Badge variant="remove">−{removeCount}</Badge> : <span className="text-gray-400">—</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={statusKey(plan) as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>
|
||||
{plan?.is_noop ? "ok" : (plan?.status ?? "pending")}
|
||||
</Badge>
|
||||
</Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
||||
{plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} />}
|
||||
{plan?.status === "pending" && <SkipBtn itemId={item.id} />}
|
||||
{plan?.status === "skipped" && <UnskipBtn itemId={item.id} />}
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(item.id) }}
|
||||
className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
|
||||
>
|
||||
Detail
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TV Series */}
|
||||
{series.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${movies.length > 0 ? "mt-5" : "mt-0"}`}
|
||||
>
|
||||
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Series</Th>
|
||||
<Th>Lang</Th>
|
||||
<Th>S</Th>
|
||||
<Th>Ep</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Actions</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
{series.map((g) => (
|
||||
<SeriesRow key={g.series_key} g={g} />
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import type React from "react";
|
||||
@@ -69,7 +69,6 @@ function RootLayout() {
|
||||
</NavLink>
|
||||
<NavLink to="/scan">Scan</NavLink>
|
||||
<NavLink to="/pipeline">Pipeline</NavLink>
|
||||
<NavLink to="/review/audio">Audio</NavLink>
|
||||
<NavLink to="/review/subtitles">Subtitles</NavLink>
|
||||
<NavLink to="/execute">Jobs</NavLink>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { AudioListPage } from "~/features/review/AudioListPage";
|
||||
|
||||
export const Route = createFileRoute("/review/audio/")({
|
||||
validateSearch: z.object({
|
||||
filter: z.enum(["all", "needs_action", "noop", "manual", "approved", "skipped", "done", "error"]).default("all"),
|
||||
}),
|
||||
component: AudioListPage,
|
||||
});
|
||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/review/")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/review/audio" });
|
||||
throw redirect({ to: "/pipeline" });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user