Files
J621/frontend/src/features/upload/UploadPage.tsx
T
JakeBreath 39307cb141 Unpaginate staged uploads and make big upload batches visible
The upload board partitions /api/uploads/ into Pending / Visual similarity /
Auto-uploaded, but the endpoint was paginated at 48 — a 69-file batch
silently lost 21 entries, and the similarity sweep (which reads the same
list back after uploading) only ever saw the first page. The staged-upload
list is now unpaginated: it is a transient per-user set, still limited to
the caller's rows and the uploader role. The page takes a plain array.

Watching progress with dozens of files was also poor:
- the queue uploads three files at a time instead of strictly one at a time;
- the Uploads section now shows a batch bar and 'n/m uploaded · x%' next to
  the count, so the overall progress never scrolls out of sight;
- entries are ordered active-first (uploading, queued, failed, done) so the
  file being uploaded is always at the top of the grid;
- tiles are larger (4 columns at lg instead of 5);
- the header reads 'Uploading n/m…' and 'Checking n file(s) against IQDB…'
  instead of a bare spinner.

Tests: staged-upload list unpaginated past 48, per-user, uploader-only
(3 new; full suite 49 green). Live-checked the bare-array response.
2026-09-18 19:58:17 -05:00

1113 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
UploadCloud,
Video,
X,
} from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
type ReactNode,
} from "react";
import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
E621Error,
e621ErrorMessage,
effectiveCredentials,
fetchPost,
fetchPostsByMd5,
iqdbSearch,
RATING_LABELS,
} from "@/lib/e621";
import { formatBytes } from "@/lib/format";
import type { E621IqdbCandidate, Rating, TempUpload } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { confirmAction } from "@/store/confirm";
import { useE621 } from "@/store/e621";
import { toast } from "@/store/toasts";
type UploadStatus = "queued" | "uploading" | "done" | "error";
/** How many files upload at once; big batches were painfully serial. */
const UPLOAD_CONCURRENCY = 3;
/** Display order: the file being uploaded stays at the top of the grid. */
const STATUS_ORDER: Record<UploadStatus, number> = {
uploading: 0,
queued: 1,
error: 2,
done: 3,
};
interface UploadEntry {
id: string;
file: File;
progress: number;
status: UploadStatus;
previewUrl: string;
error?: string;
}
const RESOLUTION_LABELS: Record<string, string> = {
auto_md5: "auto-matched on e621",
duplicate: "already in the library",
linked: "linked to a post",
custom: "custom metadata",
};
const ratingPill: Record<string, string> = {
s: "bg-ctp-green text-ctp-crust",
q: "bg-ctp-peach text-ctp-crust",
e: "bg-ctp-red text-ctp-crust",
};
const ratingChoices: { value: Rating; label: string; active: string }[] = [
{
value: "",
label: "None",
active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-text",
},
{
value: "s",
label: "Safe",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
{
value: "q",
label: "Questionable",
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
},
{
value: "e",
label: "Explicit",
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
];
function describeError(error: unknown): string {
if (error instanceof E621Error) return e621ErrorMessage(error);
return errorMessage(error);
}
async function fetchTempBlob(url: string): Promise<Blob> {
const response = await fetch(url, { headers: authHeaders() });
if (!response.ok) {
throw new Error(`Could not read the staged file (${response.status}).`);
}
return response.blob();
}
function TempCard({
temp,
checking,
onOpen,
onCheck,
onDismiss,
}: {
temp: TempUpload;
checking: boolean;
onOpen: () => void;
onCheck: () => void;
onDismiss: () => void;
}) {
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
const preview = temp.preview_url ?? temp.file_url;
return (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-2.5">
<div className="relative aspect-video overflow-hidden rounded-md bg-ctp-mantle">
{preview ? (
isVideo ? (
<video
src={preview}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<img
src={preview}
alt={temp.original_filename}
loading="lazy"
className="h-full w-full object-cover"
/>
)
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-ctp-overlay0">
No preview
</div>
)}
{temp.status === "visual_match" &&
(temp.iqdb_data?.length ?? 0) + (temp.visual_matches?.length ?? 0) > 0 ? (
<span className="absolute left-2 top-2 rounded-full bg-ctp-peach/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust">
{(temp.iqdb_data?.length ?? 0) + (temp.visual_matches?.length ?? 0)}{" "}
similar
</span>
) : null}
</div>
<p
className="mt-2 truncate font-mono text-xs text-ctp-subtext1"
title={temp.original_filename}
>
{temp.original_filename}
</p>
<p className="font-mono text-[10px] text-ctp-overlay0">
{formatBytes(temp.size)} · {temp.md5.slice(0, 10)}…
</p>
{temp.status === "completed" && temp.library_j_id ? (
<p className="mt-2 flex items-center gap-1.5 text-xs text-ctp-green">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<Link
to={`/detail/${temp.library_j_id}`}
className="font-mono hover:underline"
>
{temp.library_j_id}
</Link>
<span className="truncate text-ctp-overlay0">
{RESOLUTION_LABELS[temp.resolution] ?? temp.resolution}
</span>
</p>
) : null}
{temp.status === "error" ? (
<p className="mt-2 text-xs text-ctp-red">
Something went wrong while indexing this file.
</p>
) : null}
{temp.status === "pending" || temp.status === "visual_match" ? (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Button className="px-2 py-1 text-xs" onClick={onOpen}>
Set metadata
</Button>
{temp.status === "pending" ? (
<Button
variant="secondary"
className="px-2 py-1 text-xs"
onClick={onCheck}
disabled={checking}
>
{checking ? "Checking…" : "Check similarity"}
</Button>
) : null}
</div>
) : null}
<button
type="button"
onClick={onDismiss}
title={
temp.status === "completed"
? "Dismiss this record"
: "Discard this staged upload"
}
className="mt-2 inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
<X className="h-3 w-3" />
{temp.status === "completed" ? "dismiss" : "discard"}
</button>
</div>
);
}
function MetadataModal({
temp,
onClose,
}: {
temp: TempUpload;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials);
const [tab, setTab] = useState<"link" | "custom">("link");
const [postId, setPostId] = useState("");
const [selected, setSelected] = useState<E621IqdbCandidate | null>(null);
const [rating, setRating] = useState<Rating>(temp.custom_rating);
const [tags, setTags] = useState(temp.custom_tags.join(", "));
const [notes, setNotes] = useState(temp.custom_notes);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
async function run(task: () => Promise<void>) {
setBusy(true);
setError(null);
try {
await task();
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
onClose();
} catch (err) {
toast.error(describeError(err));
setBusy(false);
}
}
function linkById() {
const parsed = Number(postId.trim());
if (!Number.isFinite(parsed) || parsed <= 0) {
setError("Enter a valid e621 post ID.");
return;
}
return run(async () => {
const post = await fetchPost(effectiveCredentials(credentials), parsed);
await api(`/api/uploads/${temp.temp_id}/resolve/`, {
method: "POST",
json: { mode: "link", post, rating, tags, notes },
});
});
}
function linkCandidate(candidate: E621IqdbCandidate) {
if (!candidate.post_id) return;
return run(async () => {
const post = await fetchPost(
effectiveCredentials(credentials),
candidate.post_id as number,
);
await api(`/api/uploads/${temp.temp_id}/resolve/`, {
method: "POST",
json: { mode: "link", post, rating, tags, notes },
});
});
}
function saveCustom() {
return run(async () => {
await api(`/api/uploads/${temp.temp_id}/resolve/`, {
method: "POST",
json: { mode: "custom", rating, tags, notes },
});
});
}
async function discardUpload() {
const confirmed = await confirmAction({
title: "Discard this upload?",
description: `${temp.original_filename} is deleted from the staging folder; it never entered the library.`,
confirmLabel: "Discard",
danger: true,
});
if (!confirmed) return;
try {
await api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" });
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
toast.ok("Upload discarded.");
onClose();
} catch (error) {
toast.error(errorMessage(error));
}
}
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-10 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-2xl rounded-lg border border-ctp-surface1 bg-ctp-base p-5"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Set metadata
</h2>
<p className="truncate font-mono text-xs text-ctp-overlay0">
{temp.original_filename}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="w-fit max-w-full overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
{(temp.preview_url ?? temp.file_url) ? (
isVideo ? (
<video
src={(temp.preview_url ?? temp.file_url) as string}
controls
muted
className="block h-auto max-h-[55vh] w-auto max-w-full"
/>
) : (
<img
src={(temp.preview_url ?? temp.file_url) as string}
alt={temp.original_filename}
className="block h-auto max-h-[55vh] w-auto max-w-full"
/>
)
) : (
<div className="flex h-40 w-40 items-center justify-center text-xs text-ctp-overlay0">
No preview
</div>
)}
</div>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setTab("link")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "link"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Link to e621 post
</button>
<button
type="button"
onClick={() => setTab("custom")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "custom"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Custom metadata
</button>
</div>
{tab === "link" ? (
<div className="flex flex-col gap-3">
{temp.visual_matches && temp.visual_matches.length > 0 ? (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Already in your library
</p>
<div className="mt-2 flex flex-col gap-2">
{temp.visual_matches.map((match) => (
<div
key={match.j_id}
className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2"
>
<img
src={apiUrl(
match.thumbnail_url ??
`/api/files/${match.j_id}/thumbnail/`,
)}
alt={match.j_id}
loading="lazy"
className="h-12 w-12 shrink-0 rounded object-cover"
/>
<span className="min-w-0 flex-1">
<Link
to={`/detail/${match.j_id}`}
className="font-mono text-[11px] text-ctp-blue hover:underline"
>
{match.j_id}
</Link>
<span className="block truncate font-mono text-[11px] text-ctp-subtext0" title={match.filename}>
{match.filename}
</span>
</span>
<span className="shrink-0 font-mono text-[11px] text-ctp-peach">
{match.similarity}%
</span>
</div>
))}
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
This upload looks like it is already in the library —
you can discard it below.
</p>
<button
type="button"
onClick={() => void discardUpload()}
className="mt-2 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-red transition hover:bg-ctp-red/15"
>
discard this upload
</button>
</div>
) : null}
{temp.iqdb_data && temp.iqdb_data.length > 0 ? (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
IQDB candidates
</p>
<div className="mt-2 grid grid-cols-3 gap-2">
{temp.iqdb_data.map((candidate) => (
<button
key={`${candidate.post_id}-${candidate.md5}`}
type="button"
disabled={busy || !candidate.post_id}
onClick={() => setSelected(candidate)}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50",
selected?.post_id === candidate.post_id
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<span className="relative block aspect-square overflow-hidden">
{candidate.preview_url ? (
<img
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{candidate.rating ? (
<span
className={cn(
"absolute left-1 top-1 rounded-full px-1 py-0.5 font-mono text-[9px] font-semibold uppercase",
ratingPill[candidate.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{candidate.rating}
</span>
) : null}
</span>
<span className="block px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
#{candidate.post_id}
{candidate.score !== null
? ` · ${Math.round(candidate.score)}%`
: ""}
</span>
</button>
))}
</div>
{selected ? (
<div className="mt-2 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<p className="font-mono text-xs text-ctp-subtext1">
Post #{selected.post_id}
</p>
<p className="mt-1 text-xs text-ctp-overlay0">
{selected.rating
? (RATING_LABELS[selected.rating] ?? selected.rating)
: "Unknown rating"}
{selected.score_total !== null &&
selected.score_total !== undefined
? ` · ▲ ${selected.score_total}`
: ""}
{selected.fav_count !== null &&
selected.fav_count !== undefined
? ` · ${selected.fav_count} favs`
: ""}
{selected.width && selected.height
? ` · ${selected.width}×${selected.height}`
: ""}
</p>
{selected.tags_preview &&
selected.tags_preview.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{selected.tags_preview.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-surface1 px-1 py-0.5 font-mono text-[10px] text-ctp-subtext0"
>
{tag}
</span>
))}
</div>
) : null}
<div className="mt-3 flex items-center gap-3">
<Button
onClick={() => void linkCandidate(selected)}
disabled={busy || !selected.post_id}
>
Link selected post
</Button>
<Link
to={`/detail/${selected.post_id}`}
target="_blank"
className="text-xs text-ctp-blue hover:underline"
>
View post
</Link>
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
Linking downloads the post's file into the library and
drops this staged upload.
</p>
</div>
) : null}
</div>
) : (
<p className="text-xs text-ctp-overlay0">
No IQDB candidates yet — run "Check similarity" from the
card, or enter a post ID below.
</p>
)}
<div className="flex gap-2">
<input
className={cn(inputClass, "font-mono")}
placeholder="e621 post ID"
value={postId}
onChange={(event) => setPostId(event.target.value)}
/>
<Button onClick={() => void linkById()} disabled={busy}>
Link
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
{ratingChoices.map((choice) => (
<button
key={choice.value || "none"}
type="button"
onClick={() => setRating(choice.value)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
rating === choice.value
? choice.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{choice.label}
</button>
))}
</div>
<input
className={inputClass}
placeholder="Tags, comma separated"
value={tags}
onChange={(event) => setTags(event.target.value)}
/>
<textarea
className={cn(inputClass, "min-h-16 resize-y")}
placeholder="Notes…"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<Button onClick={() => void saveCustom()} disabled={busy}>
Save custom metadata
</Button>
</div>
)}
{error ? (
<p className="text-xs text-ctp-red">{error}</p>
) : null}
{busy ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" /> Working…
</p>
) : null}
</div>
</div>
</div>
</div>
);
}
export default function UploadPage() {
const queryClient = useQueryClient();
const user = useAuth((state) => state.user);
const credentials = useE621((state) => state.credentials);
const inputRef = useRef<HTMLInputElement>(null);
const queueRef = useRef<UploadEntry[]>([]);
const runningRef = useRef(false);
const objectUrls = useRef<string[]>([]);
const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]);
const [busy, setBusy] = useState(false);
const [processing, setProcessing] = useState(false);
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
useEffect(() => {
const urls = objectUrls.current;
return () => {
for (const url of urls) URL.revokeObjectURL(url);
};
}, []);
const uploadsQuery = useQuery({
queryKey: ["uploads"],
// Unpaginated: the board partitions the complete staging list.
queryFn: () => api<TempUpload[]>("/api/uploads/"),
enabled: Boolean(user?.can_upload),
});
const uploads = uploadsQuery.data ?? [];
const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match");
const completed = uploads.filter(
(temp) => temp.status === "completed" || temp.status === "error",
);
// Active work first so a long batch never hides the file in progress.
const orderedEntries = useMemo(
() =>
[...entries].sort(
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status],
),
[entries],
);
const uploadedCount = entries.filter((entry) => entry.status === "done").length;
const failedCount = entries.filter((entry) => entry.status === "error").length;
const batchPercent = entries.length
? Math.round(
entries.reduce(
(total, entry) =>
total +
(entry.status === "done"
? 100
: entry.status === "queued"
? 0
: entry.progress),
0,
) / entries.length,
)
: 0;
function addFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const next: UploadEntry[] = Array.from(files).map((file) => {
const isVideo = /\.(mp4|webm)$/i.test(file.name);
const previewUrl = isVideo ? "" : URL.createObjectURL(file);
if (previewUrl) objectUrls.current.push(previewUrl);
return {
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()
.toString(36)
.slice(2, 8)}`,
file,
progress: 0,
status: "queued",
previewUrl,
};
});
setEntries((current) => [...current, ...next]);
queueRef.current.push(...next);
void drainQueue();
}
function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
setDragging(false);
addFiles(event.dataTransfer.files);
}
function updateEntry(id: string, patch: Partial<UploadEntry>) {
setEntries((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
);
}
function invalidateUploads() {
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
}
async function refreshUploads(): Promise<TempUpload[]> {
const fresh = await api<TempUpload[]>("/api/uploads/");
queryClient.setQueryData(["uploads"], fresh);
return fresh;
}
async function checkSimilarity(ids: string[]) {
if (ids.length === 0) return;
setProcessing(true);
setCheckingIds((current) => [...current, ...ids]);
try {
const fresh = await refreshUploads();
const targets = fresh.filter(
(temp) => ids.includes(temp.temp_id) && temp.status === "pending",
);
for (const temp of targets) {
if (!temp.file_url) continue;
try {
const blob = await fetchTempBlob(temp.file_url);
const results = await iqdbSearch(
effectiveCredentials(credentials),
blob,
temp.original_filename,
);
if (results.length > 0) {
await api(`/api/uploads/${temp.temp_id}/iqdb/`, {
method: "POST",
json: { results },
});
}
} catch {
// Skip this file; the card stays pending.
} finally {
setCheckingIds((current) =>
current.filter((value) => value !== temp.temp_id),
);
}
}
} finally {
setProcessing(false);
setCheckingIds([]);
invalidateUploads();
}
}
async function autoMatchAndCheck(created: TempUpload[]) {
const creds = effectiveCredentials(credentials);
const unresolved = created.filter((temp) => temp.status === "pending");
// 1) Auto-complete files whose MD5 matches an e621 post.
if (unresolved.length > 0) {
try {
for (let index = 0; index < unresolved.length; index += 20) {
const batch = unresolved.slice(index, index + 20);
const posts = await fetchPostsByMd5(
creds,
batch.map((temp) => temp.md5),
);
for (const post of posts) {
const match = batch.find((temp) => temp.md5 === post.file.md5);
if (!match) continue;
try {
await api(`/api/uploads/${match.temp_id}/resolve/`, {
method: "POST",
json: { mode: "link", post, auto: true },
});
} catch {
// Leave it pending.
}
}
}
} catch {
// e621 unavailable; leave everything pending.
}
invalidateUploads();
}
// 2) IQDB similarity for whatever is still pending.
const fresh = await refreshUploads();
const stillPending = fresh
.filter((temp) => temp.status === "pending")
.map((temp) => temp.temp_id);
void checkSimilarity(stillPending);
}
async function drainQueue() {
if (runningRef.current) return;
runningRef.current = true;
setBusy(true);
const created: TempUpload[] = [];
async function worker() {
while (queueRef.current.length > 0) {
const entry = queueRef.current.shift();
if (!entry) return;
updateEntry(entry.id, {
status: "uploading",
progress: 0,
error: undefined,
});
try {
const temp = await uploadFile(entry.file, {}, (percent) =>
updateEntry(entry.id, { progress: percent }),
);
updateEntry(entry.id, { status: "done", progress: 100 });
created.push(temp);
} catch (error) {
updateEntry(entry.id, {
status: "error",
error: errorMessage(error),
});
}
}
}
await Promise.all(
Array.from({ length: UPLOAD_CONCURRENCY }, () => worker()),
);
runningRef.current = false;
setBusy(false);
invalidateUploads();
if (created.length > 0) {
setProcessing(true);
void autoMatchAndCheck(created).finally(() => setProcessing(false));
}
}
function dismiss(temp: TempUpload) {
void api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }).then(() =>
invalidateUploads(),
);
}
function dismissAll(items: TempUpload[]) {
if (items.length === 0) return;
void Promise.allSettled(
items.map((temp) =>
api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }),
),
).then(() => invalidateUploads());
}
function clearFinished() {
const finished = entries.filter((entry) => entry.status === "done");
for (const entry of finished) {
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
}
setEntries((current) =>
current.filter((entry) => entry.status !== "done"),
);
}
if (user && !user.can_upload) {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Upload</h1>
</header>
<EmptyState
title="Uploads are restricted"
description="Your account doesn't have the uploader role yet. Ask an admin to grant it."
/>
</div>
);
}
const column = (
title: string,
items: TempUpload[],
emptyText: string,
action?: ReactNode,
) => (
<section className="flex min-w-0 flex-col gap-3">
<header className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">{title}</h2>
<span className="flex items-center gap-2">
{action}
<span className="font-mono text-[11px] text-ctp-overlay0">
{items.length}
</span>
</span>
</header>
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-ctp-surface1 px-3 py-6 text-center text-xs text-ctp-overlay0">
{emptyText}
</p>
) : (
<div className="flex flex-col gap-3">
{items.map((temp) => (
<TempCard
key={temp.temp_id}
temp={temp}
checking={checkingIds.includes(temp.temp_id)}
onOpen={() => setModalFor(temp)}
onCheck={() => void checkSimilarity([temp.temp_id])}
onDismiss={() => dismiss(temp)}
/>
))}
</div>
)}
</section>
);
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Upload</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Files are staged first, auto-matched by MD5, checked against IQDB,
then moved into the library once resolved.
</p>
</div>
{busy || processing ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" />
{busy
? `Uploading ${Math.min(uploadedCount + 1, entries.length)}/${entries.length}…`
: checkingIds.length > 0
? `Checking ${checkingIds.length} file(s) against IQDB…`
: "Processing uploads…"}
</p>
) : null}
</header>
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition",
dragging
? "border-ctp-mauve bg-ctp-mauve/5"
: "border-ctp-surface1 hover:border-ctp-surface2",
)}
>
<UploadCloud className="h-7 w-7 text-ctp-overlay0" />
<p className="text-sm font-medium text-ctp-subtext1">
Drag &amp; drop files here
</p>
<p className="text-xs text-ctp-overlay0">
or click to select — uploads start automatically
</p>
<p className="font-mono text-[10px] text-ctp-overlay0">
PNG · JPG · GIF · APNG · WEBP · MP4 · WEBM
</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
</div>
{entries.length > 0 ? (
<section className="flex flex-col gap-2">
<header className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Uploads
<span className="ml-2 font-mono text-[11px] text-ctp-overlay0">
{uploadedCount}/{entries.length} uploaded
{failedCount > 0 ? ` · ${failedCount} failed` : ""}
</span>
</h2>
<button
type="button"
onClick={clearFinished}
className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
clear finished
</button>
</header>
<div className="flex items-center gap-3">
<div className="h-1.5 min-w-32 flex-1 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-[width]"
style={{ width: `${batchPercent}%` }}
/>
</div>
<span className="shrink-0 font-mono text-[11px] text-ctp-overlay0">
{batchPercent}%
</span>
</div>
<div className="grid max-h-[46rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-4">
{orderedEntries.map((entry) => (
<div
key={entry.id}
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
>
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
{entry.previewUrl ? (
<img
src={entry.previewUrl}
alt={entry.file.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Video className="h-5 w-5 text-ctp-overlay0" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 h-1 bg-ctp-surface2/60">
<div
className={cn(
"h-full transition-[width] duration-150",
entry.status === "error"
? "bg-ctp-red"
: entry.status === "done"
? "bg-ctp-green"
: "bg-ctp-mauve",
)}
style={{
width: `${entry.status === "done" ? 100 : entry.progress}%`,
}}
/>
</div>
</div>
<div className="px-2 py-1.5">
<p
className="truncate font-mono text-[10px] text-ctp-subtext1"
title={entry.file.name}
>
{entry.file.name}
</p>
<p className="truncate font-mono text-[10px] text-ctp-overlay0">
{entry.status === "queued"
? "Queued"
: entry.status === "uploading"
? `${entry.progress}%`
: entry.status === "done"
? "Uploaded"
: (entry.error ?? "Failed")}
</p>
</div>
</div>
))}
</div>
</section>
) : null}
<div className="grid gap-6 lg:grid-cols-3">
{column("Pending & Unmatched", pending, "Nothing waiting.")}
{column(
"Visual Similarity Detected",
visual,
"No IQDB matches right now.",
)}
{column(
"Auto-uploaded & Indexed",
completed,
"Nothing indexed yet.",
completed.length > 0 ? (
<button
type="button"
onClick={() => dismissAll(completed)}
title="Dismiss every indexed record"
className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
dismiss all
</button>
) : null,
)}
</div>
{modalFor ? (
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
) : null}
</div>
);
}