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

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:
2026-04-13 11:20:57 +02:00
parent d12dd80209
commit e3b241bef3
9 changed files with 70 additions and 580 deletions

View File

@@ -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) => {

View File

@@ -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>
);
}

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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";

View File

@@ -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>

View File

@@ -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,
});

View File

@@ -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" });
},
});