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.
1113 lines
39 KiB
TypeScript
1113 lines
39 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|