The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.
apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.
e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).
IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.
Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { ExternalLink, ImagePlus, Trash2, X } from "lucide-react";
|
||
import {
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type DragEvent,
|
||
} from "react";
|
||
import { Link } from "react-router-dom";
|
||
|
||
import { ResponsivePanel } from "@/components/BottomSheet";
|
||
import { Button, Spinner } from "@/components/ui";
|
||
import { api, apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
||
import { cn } from "@/lib/cn";
|
||
import {
|
||
e621ErrorMessage,
|
||
effectiveCredentials,
|
||
iqdbSearch,
|
||
RATING_LABELS,
|
||
type E621IqdbResult,
|
||
} from "@/lib/e621";
|
||
import { formatBytes, formatDate } from "@/lib/format";
|
||
import type { SimilarityCheck } from "@/lib/types";
|
||
import { useE621 } from "@/store/e621";
|
||
import { toast } from "@/store/toasts";
|
||
|
||
const VIDEO_RE = /\.(mp4|webm)$/i;
|
||
|
||
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",
|
||
};
|
||
|
||
export default function SimilarPage() {
|
||
const queryClient = useQueryClient();
|
||
const credentials = useE621((state) => state.credentials);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [check, setCheck] = useState<SimilarityCheck | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [percent, setPercent] = useState(0);
|
||
const [dragging, setDragging] = useState(false);
|
||
|
||
const [iqdb, setIqdb] = useState<E621IqdbResult[] | null>(null);
|
||
const [iqdbBusy, setIqdbBusy] = useState(false);
|
||
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
|
||
|
||
const configured = Boolean(credentials?.configured);
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: () =>
|
||
api(`/api/similarity/${check?.check_id}/`, { method: "DELETE" }),
|
||
onSuccess: () => {
|
||
setCheck(null);
|
||
setFile(null);
|
||
setIqdb(null);
|
||
setSelected(null);
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
toast.ok("Temporary copy deleted.");
|
||
},
|
||
onError: (error) => toast.error(errorMessage(error)),
|
||
});
|
||
|
||
async function startCheck(selectedFile: File) {
|
||
setFile(selectedFile);
|
||
setCheck(null);
|
||
setIqdb(null);
|
||
setSelected(null);
|
||
setPercent(0);
|
||
setUploading(true);
|
||
try {
|
||
const data = await uploadFile<SimilarityCheck>(
|
||
selectedFile,
|
||
{},
|
||
setPercent,
|
||
"/api/similarity/",
|
||
);
|
||
setCheck(data);
|
||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||
} catch (err) {
|
||
toast.error(errorMessage(err));
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
|
||
if (VIDEO_RE.test(selectedFile.name)) return;
|
||
if (!configured) return;
|
||
setIqdbBusy(true);
|
||
try {
|
||
const results = await iqdbSearch(
|
||
effectiveCredentials(credentials),
|
||
selectedFile,
|
||
selectedFile.name,
|
||
);
|
||
setIqdb(results);
|
||
} catch (err) {
|
||
toast.error(e621ErrorMessage(err));
|
||
} finally {
|
||
setIqdbBusy(false);
|
||
}
|
||
}
|
||
|
||
function handleInput(event: ChangeEvent<HTMLInputElement>) {
|
||
const chosen = event.target.files?.[0];
|
||
if (chosen) void startCheck(chosen);
|
||
}
|
||
|
||
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
||
event.preventDefault();
|
||
setDragging(false);
|
||
const dropped = event.dataTransfer.files?.[0];
|
||
if (dropped) void startCheck(dropped);
|
||
}
|
||
|
||
const isVideo = file ? VIDEO_RE.test(file.name) : false;
|
||
const exact = check?.results.exact ?? null;
|
||
const matches = check?.results.matches ?? [];
|
||
|
||
return (
|
||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||
<header>
|
||
<h1 className="text-lg font-semibold">Similarity check</h1>
|
||
<p className="mt-1 text-sm text-ctp-overlay0">
|
||
Drop a file to see whether it is already in the library and what it
|
||
looks like on e621. Nothing is added to the library — the temporary
|
||
copy is deleted automatically after a while and on the next server
|
||
start.
|
||
</p>
|
||
</header>
|
||
|
||
<div
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
setDragging(true);
|
||
}}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={handleDrop}
|
||
className={cn(
|
||
"flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-10 text-center transition",
|
||
dragging
|
||
? "border-ctp-mauve bg-ctp-mauve/10"
|
||
: "border-ctp-surface1 bg-ctp-base",
|
||
)}
|
||
>
|
||
<ImagePlus className="h-6 w-6 text-ctp-overlay0" />
|
||
<p className="text-sm text-ctp-subtext0">
|
||
Drag a file here, or{" "}
|
||
<button
|
||
type="button"
|
||
className="text-ctp-blue hover:underline"
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
choose one
|
||
</button>
|
||
</p>
|
||
<p className="text-[11px] text-ctp-overlay0">
|
||
Images get the full check (MD5, visual hashes, e621 IQDB); videos
|
||
only get the exact MD5 match.
|
||
</p>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
className="hidden"
|
||
onChange={handleInput}
|
||
/>
|
||
</div>
|
||
|
||
{uploading ? (
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-1.5 w-48 overflow-hidden rounded-full bg-ctp-surface0">
|
||
<div
|
||
className="h-full bg-ctp-mauve transition-all"
|
||
style={{ width: `${percent}%` }}
|
||
/>
|
||
</div>
|
||
<span className="font-mono text-xs text-ctp-overlay0">
|
||
uploading… {percent}%
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{check ? (
|
||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||
<ResponsivePanel title="File & check">
|
||
<section className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
|
||
<div className="flex max-h-72 items-center justify-center overflow-hidden bg-ctp-mantle">
|
||
{check.file_url ? (
|
||
isVideo ? (
|
||
<video
|
||
src={check.file_url}
|
||
controls
|
||
loop
|
||
muted
|
||
className="max-h-72 w-full object-contain"
|
||
/>
|
||
) : (
|
||
<img
|
||
src={check.file_url}
|
||
alt={check.original_filename}
|
||
className="max-h-72 w-full object-contain"
|
||
/>
|
||
)
|
||
) : null}
|
||
</div>
|
||
<div className="p-3">
|
||
<p
|
||
className="truncate font-mono text-xs text-ctp-subtext1"
|
||
title={check.original_filename}
|
||
>
|
||
{check.original_filename}
|
||
</p>
|
||
<p className="mt-1 truncate font-mono text-[10px] text-ctp-overlay0">
|
||
{check.md5}
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-ctp-overlay0">
|
||
{formatBytes(check.size)} · expires{" "}
|
||
{formatDate(check.expires_at)}
|
||
</p>
|
||
<div className="mt-3 flex items-center gap-2">
|
||
<Button
|
||
variant="danger"
|
||
className="px-2 py-1 text-xs"
|
||
disabled={deleteMutation.isPending}
|
||
onClick={() => deleteMutation.mutate()}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> Delete now
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
className="px-2 py-1 text-xs"
|
||
onClick={() => {
|
||
setCheck(null);
|
||
setFile(null);
|
||
setIqdb(null);
|
||
setSelected(null);
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
}}
|
||
>
|
||
<X className="h-3.5 w-3.5" /> Clear
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</ResponsivePanel>
|
||
|
||
<div className="flex min-w-0 flex-col gap-6">
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||
In the library (exact MD5)
|
||
</h2>
|
||
{exact ? (
|
||
<Link
|
||
to={`/detail/${exact.j_id}`}
|
||
className="mt-3 flex items-center gap-3 rounded-md border border-ctp-green/40 bg-ctp-green/10 p-2 transition hover:bg-ctp-green/15"
|
||
>
|
||
{exact.thumbnail_url ? (
|
||
<img
|
||
src={apiUrl(exact.thumbnail_url)}
|
||
alt={exact.j_id}
|
||
className="h-14 w-14 rounded object-cover"
|
||
/>
|
||
) : null}
|
||
<span className="min-w-0">
|
||
<span className="block font-mono text-sm text-ctp-text">
|
||
{exact.j_id}
|
||
</span>
|
||
<span className="block truncate text-xs text-ctp-overlay0">
|
||
{exact.filename} · {formatBytes(exact.size)} ·{" "}
|
||
{exact.location_count} cop
|
||
{exact.location_count === 1 ? "y" : "ies"}
|
||
{exact.e621_post_id
|
||
? ` · e621 #${exact.e621_post_id}`
|
||
: ""}
|
||
</span>
|
||
</span>
|
||
</Link>
|
||
) : (
|
||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||
Not in the library — no item has this MD5.
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||
Visually similar in the library{" "}
|
||
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||
{matches.length}
|
||
</span>
|
||
</h2>
|
||
{isVideo ? (
|
||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||
Visual hashing works on images only.
|
||
</p>
|
||
) : matches.length === 0 ? (
|
||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||
Nothing above the similarity threshold.
|
||
</p>
|
||
) : (
|
||
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
|
||
{matches.map((match) => (
|
||
<Link
|
||
key={match.j_id}
|
||
to={`/detail/${match.j_id}`}
|
||
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
|
||
>
|
||
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
|
||
{match.thumbnail_url ? (
|
||
<img
|
||
src={apiUrl(match.thumbnail_url)}
|
||
alt={match.j_id}
|
||
loading="lazy"
|
||
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
|
||
/>
|
||
) : null}
|
||
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] text-ctp-subtext0">
|
||
{match.similarity}%
|
||
</span>
|
||
</span>
|
||
<span className="block px-2 py-1.5 font-mono text-[11px] text-ctp-subtext0">
|
||
{match.j_id}
|
||
</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||
e621 IQDB
|
||
</h2>
|
||
{iqdbBusy ? (
|
||
<span className="flex items-center gap-1.5 text-[11px] text-ctp-overlay0">
|
||
<Spinner className="h-3 w-3" /> searching…
|
||
</span>
|
||
) : iqdb ? (
|
||
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||
{iqdb.length} candidate(s)
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{isVideo ? (
|
||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||
IQDB works on images only.
|
||
</p>
|
||
) : !configured ? (
|
||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||
<Link to="/account" className="text-ctp-blue hover:underline">
|
||
Add e621 credentials
|
||
</Link>{" "}
|
||
to reverse-search this image on e621.
|
||
</p>
|
||
) : iqdbBusy && !iqdb ? (
|
||
<div className="mt-3 flex justify-center py-4">
|
||
<Spinner className="h-4 w-4" />
|
||
</div>
|
||
) : iqdb && iqdb.length === 0 ? (
|
||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||
No similar posts found on e621.
|
||
</p>
|
||
) : iqdb ? (
|
||
<>
|
||
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 xl:grid-cols-6">
|
||
{iqdb.map((candidate) => {
|
||
const isExact =
|
||
candidate.md5 !== null &&
|
||
check.md5 === candidate.md5;
|
||
return (
|
||
<button
|
||
key={`${candidate.post_id}-${candidate.md5}`}
|
||
type="button"
|
||
disabled={candidate.post_id === null}
|
||
onClick={() => setSelected(candidate)}
|
||
className={cn(
|
||
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition",
|
||
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}
|
||
{isExact ? (
|
||
<span className="absolute right-1 top-1 rounded-full bg-ctp-green/90 px-1 py-0.5 font-mono text-[9px] font-semibold uppercase text-ctp-crust">
|
||
MD5
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<span className="block px-1 py-0.5 font-mono text-[10px] text-ctp-overlay0">
|
||
#{candidate.post_id}
|
||
{candidate.score !== null
|
||
? ` · ${Math.round(candidate.score)}%`
|
||
: ""}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{selected ? (
|
||
<div className="mt-3 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}`
|
||
: ""}
|
||
{selected.fav_count !== null
|
||
? ` · ${selected.fav_count} favs`
|
||
: ""}
|
||
{selected.width && selected.height
|
||
? ` · ${selected.width}×${selected.height}`
|
||
: ""}
|
||
</p>
|
||
{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">
|
||
<Link
|
||
to={`/detail/${selected.post_id}`}
|
||
className="inline-flex items-center gap-1.5 text-xs text-ctp-blue hover:underline"
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5" /> View post
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="mt-2 text-[11px] text-ctp-overlay0">
|
||
Select a candidate for its rating, score, favorites and
|
||
tags.
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||
Waiting for the library check…
|
||
</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|