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 = { 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 = { auto_md5: "auto-matched on e621", duplicate: "already in the library", linked: "linked to a post", custom: "custom metadata", }; const ratingPill: Record = { 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 { 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 (
{preview ? ( isVideo ? (

{temp.original_filename}

{formatBytes(temp.size)} · {temp.md5.slice(0, 10)}…

{temp.status === "completed" && temp.library_j_id ? (

{temp.library_j_id} {RESOLUTION_LABELS[temp.resolution] ?? temp.resolution}

) : null} {temp.status === "error" ? (

Something went wrong while indexing this file.

) : null} {temp.status === "pending" || temp.status === "visual_match" ? (
{temp.status === "pending" ? ( ) : null}
) : null}
); } 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(null); const [rating, setRating] = useState(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(null); const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename); async function run(task: () => Promise) { 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 (
event.stopPropagation()} >

Set metadata

{temp.original_filename}

{(temp.preview_url ?? temp.file_url) ? ( isVideo ? (
{tab === "link" ? (
{temp.visual_matches && temp.visual_matches.length > 0 ? (

Already in your library

{temp.visual_matches.map((match) => (
{match.j_id} {match.j_id} {match.filename} {match.similarity}%
))}

This upload looks like it is already in the library — you can discard it below.

) : null} {temp.iqdb_data && temp.iqdb_data.length > 0 ? (

IQDB candidates

{temp.iqdb_data.map((candidate) => ( ))}
{selected ? (

Post #{selected.post_id}

{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}` : ""}

{selected.tags_preview && selected.tags_preview.length > 0 ? (
{selected.tags_preview.map((tag) => ( {tag} ))}
) : null}
View post

Linking downloads the post's file into the library and drops this staged upload.

) : null}
) : (

No IQDB candidates yet — run "Check similarity" from the card, or enter a post ID below.

)}
setPostId(event.target.value)} />
) : (
{ratingChoices.map((choice) => ( ))}
setTags(event.target.value)} />