sort dropdown on every column, auto-process checkbox stays visible during sorting
Build and Push Docker Image / build (push) Successful in 1m10s
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:
+1
-1
@@ -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
@@ -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[] = [];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user