sort dropdown on every column, auto-process checkbox stays visible during sorting
Build and Push Docker Image / build (push) Successful in 1m10s

- ColumnShell: new sort row below header border with sortOptions/sortValue/onSortChange
- inbox: ↓↑ scan time, ↓↑ name (dropdown moved from button row to sort row)
- review: classification (default), ↓↑ scan time, ↓↑ name
- queue/done: ↓↑ added, ↓↑ name (client-side sort on already-fetched arrays)
- auto-process checkbox stays visible during inbox processing, progress shows below it
- backend: unified GroupSort type replaces InboxSort, review bucket accepts sort param

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 09:36:20 +02:00
parent 789a9f7bfe
commit 7900f450a7
8 changed files with 201 additions and 115 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.04.21.4",
"version": "2026.04.21.5",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",
+23 -25
View File
@@ -633,19 +633,17 @@ type ReviewGroup =
seasons: Array<{ season: number | null; episodes: ReviewItemRow[] }>;
};
export type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
export type GroupSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc" | "class";
export interface BuildReviewGroupsOpts {
bucket: "inbox" | "review";
sort?: InboxSort;
sort?: GroupSort;
}
function orderClause(bucket: "inbox" | "review", sort?: InboxSort): string {
if (bucket === "review") {
function orderClause(sort?: GroupSort): string {
if (sort === "class")
return `CASE rp.auto_class WHEN 'auto_heuristic' THEN 0 WHEN 'manual' THEN 1 ELSE 2 END,
COALESCE(mi.series_name, mi.name),
mi.season_number, mi.episode_number`;
}
COALESCE(mi.series_name, mi.name), mi.season_number, mi.episode_number`;
if (sort === "scan_desc") return "mi.last_scanned_at DESC, mi.id DESC";
if (sort === "name_asc") return "COALESCE(mi.series_name, mi.name) ASC, mi.season_number, mi.episode_number";
if (sort === "name_desc") return "COALESCE(mi.series_name, mi.name) DESC, mi.season_number, mi.episode_number";
@@ -657,7 +655,8 @@ export function buildReviewGroups(
opts: BuildReviewGroupsOpts,
): { groups: ReviewGroup[]; totalItems: number } {
const sortedFilter = opts.bucket === "inbox" ? "rp.sorted = 0" : "rp.sorted = 1";
const order = orderClause(opts.bucket, opts.sort);
const defaultSort: GroupSort = opts.bucket === "inbox" ? "scan_asc" : "class";
const order = orderClause(opts.sort ?? defaultSort);
const rows = db
.prepare(`
SELECT rp.*, mi.name, mi.series_name, mi.series_key,
@@ -728,12 +727,21 @@ export function buildReviewGroups(
});
}
// For inbox, preserve the SQL order (scan time or name). For review,
// rank by auto_class so auto-approvable items float to the top.
const effectiveSort = opts.sort ?? (opts.bucket === "inbox" ? "scan_asc" : "class");
let allGroups: ReviewGroup[];
if (opts.bucket === "inbox") {
// Interleave movies and series in the order their first row appeared
// in the SQL result set so scan-time ordering stays intact.
if (effectiveSort === "class") {
// Class sort: rank by auto_class in JS so auto-approvable items float top.
allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => {
const rankA = a.kind === "movie" ? autoClassRank(a.item.auto_class) : a.readyCount > 0 ? 0 : 1;
const rankB = b.kind === "movie" ? autoClassRank(b.item.auto_class) : b.readyCount > 0 ? 0 : 1;
if (rankA !== rankB) return rankA - rankB;
const nameA = a.kind === "movie" ? a.item.name : a.seriesName;
const nameB = b.kind === "movie" ? b.item.name : b.seriesName;
return nameA.localeCompare(nameB);
});
} else {
// Scan-time / name sorts: interleave movies and series in SQL row order.
const groupOrder: ReviewGroup[] = [];
const seen = new Set<string>();
for (const row of rows) {
@@ -755,15 +763,6 @@ export function buildReviewGroups(
}
}
allGroups = groupOrder;
} else {
allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => {
const rankA = a.kind === "movie" ? autoClassRank(a.item.auto_class) : a.readyCount > 0 ? 0 : 1;
const rankB = b.kind === "movie" ? autoClassRank(b.item.auto_class) : b.readyCount > 0 ? 0 : 1;
if (rankA !== rankB) return rankA - rankB;
const nameA = a.kind === "movie" ? a.item.name : a.seriesName;
const nameB = b.kind === "movie" ? b.item.name : b.seriesName;
return nameA.localeCompare(nameB);
});
}
const totalItems =
@@ -784,9 +783,8 @@ app.get("/groups", (c) => {
const bucketParam = c.req.query("bucket") ?? "review";
const bucket = bucketParam === "inbox" ? "inbox" : "review";
const sortParam = c.req.query("sort") as InboxSort | undefined;
const sort = bucket === "inbox" ? (sortParam ?? "scan_asc") : undefined;
const { groups, totalItems } = buildReviewGroups(db, { bucket, sort });
const sortParam = c.req.query("sort") as GroupSort | undefined;
const { groups, totalItems } = buildReviewGroups(db, { bucket, sort: sortParam });
const page = groups.slice(offset, offset + limit);
const flat: EnrichableRow[] = [];
+36 -18
View File
@@ -9,14 +9,21 @@ export interface ColumnAction {
title?: string;
}
export interface SortOption<T extends string = string> {
value: T;
label: string;
}
interface ColumnShellProps {
title: string;
count: ReactNode;
subtitle?: ReactNode;
backward?: ColumnAction;
/** Middle slot: accepts a ColumnAction button or any ReactNode (e.g. a dropdown). */
middle?: ColumnAction | ReactNode;
forward?: ColumnAction;
sortOptions?: SortOption[];
sortValue?: string;
onSortChange?: (value: string) => void;
children: ReactNode;
}
@@ -45,18 +52,18 @@ function isColumnAction(v: unknown): v is ColumnAction {
return typeof v === "object" && v !== null && "label" in v && "onClick" in v;
}
/**
* Equal-width pipeline column with a fixed three-row header (title + count,
* subtitle, button row) and a scrolling body. All five pipeline columns share
* this shell so widths, spacing, and the left/middle/right button layout stay
* consistent — which in turn makes the pipeline direction readable at a glance.
*
* The subtitle and button rows are always reserved — when a column has no
* subtitle we render an invisible spacer to keep every column's header the
* same height; buttons passed as disabled still occupy their slot so the
* header never jumps between states.
*/
export function ColumnShell({ title, count, subtitle, backward, middle, forward, children }: ColumnShellProps) {
export function ColumnShell({
title,
count,
subtitle,
backward,
middle,
forward,
sortOptions,
sortValue,
onSortChange,
children,
}: ColumnShellProps) {
return (
<div className="flex flex-col flex-1 basis-0 min-w-80 min-h-0 bg-gray-50 rounded-lg">
<div className="flex flex-col gap-1.5 px-3 py-2 border-b">
@@ -64,11 +71,6 @@ export function ColumnShell({ title, count, subtitle, backward, middle, forward,
{title} <span className="text-gray-400 font-normal">({count})</span>
</span>
<div className="text-xs text-gray-500 min-w-0 h-8 flex items-center">{subtitle}</div>
{/*
auto|1fr|auto: left/right buttons take their natural width (no
wrapping on "← Back to inbox" / "Approve auto →"), the middle
column flexes and centers the skip button if present.
*/}
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-1 min-h-[1.375rem]">
<div className="flex justify-start">{backward && <ActionButton action={backward} />}</div>
<div className="flex justify-center">
@@ -77,6 +79,22 @@ export function ColumnShell({ title, count, subtitle, backward, middle, forward,
<div className="flex justify-end">{forward && <ActionButton action={forward} />}</div>
</div>
</div>
{sortOptions && sortOptions.length > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1 border-b text-[11px] text-gray-500">
<span>Sort</span>
<select
className="h-5 text-[11px] border border-gray-300 rounded px-1 bg-white flex-1 min-w-0"
value={sortValue}
onChange={(e) => onSortChange?.(e.target.value)}
>
{sortOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)}
<div className="flex-1 overflow-y-auto p-2 space-y-1">{children}</div>
</div>
);
+15 -2
View File
@@ -2,15 +2,25 @@ import { Link } from "@tanstack/react-router";
import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { ColumnShell, type SortOption } from "./ColumnShell";
import type { JobSort } from "./PipelinePage";
const DONE_SORT_OPTIONS: SortOption[] = [
{ value: "added_desc", label: "↑ Completed" },
{ value: "added_asc", label: "↓ Completed" },
{ value: "name_asc", label: "↓ Name" },
{ value: "name_desc", label: "↑ Name" },
];
interface DoneColumnProps {
items: PipelineJobItem[];
doneCount: number;
onMutate: () => void;
sort: JobSort;
onChangeSort: (next: JobSort) => void;
}
export function DoneColumn({ items, doneCount, onMutate }: DoneColumnProps) {
export function DoneColumn({ items, doneCount, onMutate, sort, onChangeSort }: DoneColumnProps) {
const clear = async () => {
await api.post("/api/execute/clear-completed");
onMutate();
@@ -47,6 +57,9 @@ export function DoneColumn({ items, doneCount, onMutate }: DoneColumnProps) {
subtitle={`${doneCount} in desired state`}
backward={backward}
forward={forward}
sortOptions={DONE_SORT_OPTIONS}
sortValue={sort}
onSortChange={(v) => onChangeSort(v as JobSort)}
>
{items.map((item) => (
<div key={item.id} className="group rounded border bg-white p-2">
+33 -52
View File
@@ -1,15 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/shared/lib/api";
import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { ColumnShell, type SortOption } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard";
import { SeriesCard } from "./SeriesCard";
const PAGE_SIZE = 25;
type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
export type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
const SORT_OPTIONS: { value: InboxSort; label: string }[] = [
export const INBOX_SORT_OPTIONS: SortOption[] = [
{ value: "scan_asc", label: "↓ Scan time" },
{ value: "scan_desc", label: "↑ Scan time" },
{ value: "name_asc", label: "↓ Name" },
@@ -42,9 +42,6 @@ export function InboxColumn({
const [loadingMore, setLoadingMore] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
// Optimistic mirror of the auto-process checkbox so a click flips the
// box immediately rather than waiting for the server roundtrip. See the
// matching rationale in ProcessingColumn.
const [localAutoProcessing, setLocalAutoProcessing] = useState(autoProcessing);
useEffect(() => {
setLocalAutoProcessing(autoProcessing);
@@ -94,43 +91,40 @@ export function InboxColumn({
onMutate();
};
// Progress bar fills the subtitle slot during an active sort so the user
// sees real work happening instead of a frozen button. The auto-process
// toggle hides while a sort runs — it can't be flipped meaningfully
// mid-pass and the progress deserves the full line of visual real estate.
const sorting = sortProgress !== null;
const pct =
sortProgress && sortProgress.total > 0 ? Math.round((sortProgress.processed / sortProgress.total) * 100) : 0;
const subtitle = sorting ? (
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center justify-between">
<span className="text-gray-600">
Processing {sortProgress.processed}/{sortProgress.total}
</span>
</div>
<div className="h-1.5 rounded bg-gray-200 overflow-hidden w-full">
<div className="h-full bg-blue-600 transition-[width]" style={{ width: `${pct}%` }} />
</div>
// Checkbox always visible. Progress bar shows below it when sorting.
const subtitle = (
<div className="flex flex-col gap-1 w-full">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
className="h-3 w-3"
checked={localAutoProcessing}
onChange={(e) => {
const next = e.target.checked;
setLocalAutoProcessing(next);
onToggleAutoProcessing(next);
}}
/>
<span>Auto-process Inbox</span>
</label>
{sorting && (
<div className="flex items-center gap-2 w-full">
<span className="text-gray-600 tabular-nums shrink-0">
{sortProgress.processed}/{sortProgress.total}
</span>
<div className="h-1.5 rounded bg-gray-200 overflow-hidden flex-1">
<div className="h-full bg-blue-600 transition-[width]" style={{ width: `${pct}%` }} />
</div>
</div>
)}
</div>
) : (
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
className="h-3 w-3"
checked={localAutoProcessing}
onChange={(e) => {
const next = e.target.checked;
setLocalAutoProcessing(next);
onToggleAutoProcessing(next);
}}
/>
<span>Auto-process Inbox</span>
</label>
);
const backward = sorting ? { label: "Stop Sorting", onClick: stopProcess, danger: true } : undefined;
// Sort dropdown + Process Inbox button share the forward slot
const forward = sorting
? undefined
: {
@@ -141,28 +135,16 @@ export function InboxColumn({
title: "Process inbox to Queue / Review",
};
const sortDropdown = !sorting ? (
<select
className="h-5 text-[11px] border border-gray-300 rounded px-1 bg-white"
value={sort}
onChange={(e) => onChangeSort(e.target.value as InboxSort)}
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : undefined;
return (
<ColumnShell
title="Inbox"
count={totalItems}
subtitle={subtitle}
backward={backward}
middle={sortDropdown}
forward={forward}
sortOptions={INBOX_SORT_OPTIONS}
sortValue={sort}
onSortChange={(v) => onChangeSort(v as InboxSort)}
>
<div className="space-y-2">
{groups.map((group) => {
@@ -186,7 +168,6 @@ export function InboxColumn({
originalLanguage={group.originalLanguage}
onMutate={onMutate}
onProcess={() => {
// Process all episodes in this series
const ids = group.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id));
Promise.all(ids.map((id) => api.post(`/api/review/${id}/process`))).then(() => onMutate());
}}
+38 -11
View File
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/shared/lib/api";
import type { PipelineData, ReviewGroupsResponse } from "~/shared/lib/types";
import type { PipelineData, PipelineJobItem, ReviewGroupsResponse } from "~/shared/lib/types";
import { DoneColumn } from "./DoneColumn";
import { InboxColumn } from "./InboxColumn";
import { type InboxSort, InboxColumn } from "./InboxColumn";
import { PipelineHeader } from "./PipelineHeader";
import { ProcessingColumn } from "./ProcessingColumn";
import { QueueColumn } from "./QueueColumn";
import { ReviewColumn } from "./ReviewColumn";
import { type ReviewSort, ReviewColumn } from "./ReviewColumn";
interface Progress {
id: number;
@@ -25,7 +25,15 @@ interface SortProgress {
total: number;
}
type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
export type JobSort = "name_asc" | "name_desc" | "added_asc" | "added_desc";
function sortJobs(items: PipelineJobItem[], sort: JobSort): PipelineJobItem[] {
const sorted = [...items];
if (sort === "name_asc") return sorted.sort((a, b) => a.name.localeCompare(b.name));
if (sort === "name_desc") return sorted.sort((a, b) => b.name.localeCompare(a.name));
if (sort === "added_desc") return sorted.reverse();
return sorted; // added_asc = default backend order
}
export function PipelinePage() {
const [data, setData] = useState<PipelineData | null>(null);
@@ -35,8 +43,14 @@ export function PipelinePage() {
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
const [sortProgress, setSortProgress] = useState<SortProgress | null>(null);
const [loading, setLoading] = useState(true);
// Sort state for all columns
const [inboxSort, setInboxSort] = useState<InboxSort>("scan_asc");
const inboxSortRef = useRef<InboxSort>("scan_asc");
const [reviewSort, setReviewSort] = useState<ReviewSort>("class");
const reviewSortRef = useRef<ReviewSort>("class");
const [queueSort, setQueueSort] = useState<JobSort>("added_asc");
const [doneSort, setDoneSort] = useState<JobSort>("added_desc");
const loadPipeline = useCallback(async () => {
const res = await api.get<PipelineData>("/api/review/pipeline");
@@ -44,10 +58,11 @@ export function PipelinePage() {
}, []);
const loadGroups = useCallback(async () => {
const sort = inboxSortRef.current;
const iSort = inboxSortRef.current;
const rSort = reviewSortRef.current;
const [inbox, review] = await Promise.all([
api.get<ReviewGroupsResponse>(`/api/review/groups?bucket=inbox&offset=0&limit=25&sort=${sort}`),
api.get<ReviewGroupsResponse>("/api/review/groups?bucket=review&offset=0&limit=25"),
api.get<ReviewGroupsResponse>(`/api/review/groups?bucket=inbox&offset=0&limit=25&sort=${iSort}`),
api.get<ReviewGroupsResponse>(`/api/review/groups?bucket=review&offset=0&limit=25&sort=${rSort}`),
]);
setInboxInitial(inbox);
setReviewInitial(review);
@@ -104,8 +119,6 @@ export function PipelinePage() {
} catch {
/* ignore malformed events */
}
// Refresh columns progressively so items appear in their destination
// as processing runs (throttled to avoid hammering the server).
schedulePipelineReload();
});
es.addEventListener("inbox_sorted", () => {
@@ -155,15 +168,29 @@ export function PipelinePage() {
readyCount={data.reviewReadyCount}
manualCount={data.reviewManualCount}
onMutate={loadAll}
sort={reviewSort}
onChangeSort={(next) => {
reviewSortRef.current = next;
setReviewSort(next);
loadGroups();
}}
/>
<QueueColumn
items={data.queued}
items={sortJobs(data.queued, queueSort)}
autoProcessQueue={data.autoProcessQueue}
onToggleAutoProcessQueue={toggleAutoProcessQueue}
onMutate={loadAll}
sort={queueSort}
onChangeSort={setQueueSort}
/>
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={loadAll} />
<DoneColumn items={data.done} doneCount={data.doneCount} onMutate={loadAll} />
<DoneColumn
items={sortJobs(data.done, doneSort)}
doneCount={data.doneCount}
onMutate={loadAll}
sort={doneSort}
onChangeSort={setDoneSort}
/>
</div>
</div>
);
+29 -3
View File
@@ -1,17 +1,34 @@
import { useEffect, useState } from "react";
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { ColumnShell, type SortOption } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard";
import type { JobSort } from "./PipelinePage";
const QUEUE_SORT_OPTIONS: SortOption[] = [
{ value: "added_asc", label: "↓ Added" },
{ value: "added_desc", label: "↑ Added" },
{ value: "name_asc", label: "↓ Name" },
{ value: "name_desc", label: "↑ Name" },
];
interface QueueColumnProps {
items: PipelineJobItem[];
autoProcessQueue: boolean;
onToggleAutoProcessQueue: (enabled: boolean) => void;
onMutate: () => void;
sort: JobSort;
onChangeSort: (next: JobSort) => void;
}
export function QueueColumn({ items, autoProcessQueue, onToggleAutoProcessQueue, onMutate }: QueueColumnProps) {
export function QueueColumn({
items,
autoProcessQueue,
onToggleAutoProcessQueue,
onMutate,
sort,
onChangeSort,
}: QueueColumnProps) {
const [localEnabled, setLocalEnabled] = useState(autoProcessQueue);
useEffect(() => {
setLocalEnabled(autoProcessQueue);
@@ -60,7 +77,16 @@ export function QueueColumn({ items, autoProcessQueue, onToggleAutoProcessQueue,
);
return (
<ColumnShell title="Queued" count={items.length} subtitle={subtitle} backward={backward} forward={forward}>
<ColumnShell
title="Queued"
count={items.length}
subtitle={subtitle}
backward={backward}
forward={forward}
sortOptions={QUEUE_SORT_OPTIONS}
sortValue={sort}
onSortChange={(v) => onChangeSort(v as JobSort)}
>
<div className="space-y-2">
{items.map((item) => (
<PipelineCard key={item.id} item={item} onUnapprove={() => unapprove(item.item_id)} />
+26 -3
View File
@@ -1,21 +1,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/shared/lib/api";
import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { ColumnShell, type SortOption } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard";
import { SeriesCard } from "./SeriesCard";
const PAGE_SIZE = 25;
export type ReviewSort = "class" | "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
export const REVIEW_SORT_OPTIONS: SortOption[] = [
{ value: "class", label: "Classification" },
{ value: "scan_asc", label: "↓ Scan time" },
{ value: "scan_desc", label: "↑ Scan time" },
{ value: "name_asc", label: "↓ Name" },
{ value: "name_desc", label: "↑ Name" },
];
interface ReviewColumnProps {
initialResponse: ReviewGroupsResponse;
totalItems: number;
readyCount: number;
manualCount: number;
onMutate: () => void;
sort: ReviewSort;
onChangeSort: (next: ReviewSort) => void;
}
export function ReviewColumn({ initialResponse, totalItems, readyCount, manualCount, onMutate }: ReviewColumnProps) {
export function ReviewColumn({
initialResponse,
totalItems,
readyCount,
manualCount,
onMutate,
sort,
onChangeSort,
}: ReviewColumnProps) {
const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
const [loadingMore, setLoadingMore] = useState(false);
@@ -31,7 +51,7 @@ export function ReviewColumn({ initialResponse, totalItems, readyCount, manualCo
setLoadingMore(true);
try {
const res = await api.get<ReviewGroupsResponse>(
`/api/review/groups?bucket=review&offset=${groups.length}&limit=${PAGE_SIZE}`,
`/api/review/groups?bucket=review&offset=${groups.length}&limit=${PAGE_SIZE}&sort=${sort}`,
);
setGroups((prev) => [...prev, ...res.groups]);
setHasMore(res.hasMore);
@@ -117,6 +137,9 @@ export function ReviewColumn({ initialResponse, totalItems, readyCount, manualCo
backward={backward}
middle={skip}
forward={forward}
sortOptions={REVIEW_SORT_OPTIONS}
sortValue={sort}
onSortChange={(v) => onChangeSort(v as ReviewSort)}
>
<div className="space-y-2">
{groups.map((group, index) => {