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 { Button, Spinner } from "@/components/ui"; import { api, 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 = { 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(null); const [file, setFile] = useState(null); const [check, setCheck] = useState(null); const [uploading, setUploading] = useState(false); const [percent, setPercent] = useState(0); const [dragging, setDragging] = useState(false); const [iqdb, setIqdb] = useState(null); const [iqdbBusy, setIqdbBusy] = useState(false); const [selected, setSelected] = useState(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( 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) { const chosen = event.target.files?.[0]; if (chosen) void startCheck(chosen); } function handleDrop(event: DragEvent) { 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 (

Similarity check

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.

{ 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", )} >

Drag a file here, or{" "}

Images get the full check (MD5, visual hashes, e621 IQDB); videos only get the exact MD5 match.

{uploading ? (
uploading… {percent}%
) : null} {check ? (

In the library (exact MD5)

{exact ? ( {exact.thumbnail_url ? ( {exact.j_id} ) : null} {exact.j_id} {exact.filename} · {formatBytes(exact.size)} ·{" "} {exact.location_count} cop {exact.location_count === 1 ? "y" : "ies"} {exact.e621_post_id ? ` · e621 #${exact.e621_post_id}` : ""} ) : (

Not in the library — no item has this MD5.

)}

Visually similar in the library{" "} {matches.length}

{isVideo ? (

Visual hashing works on images only.

) : matches.length === 0 ? (

Nothing above the similarity threshold.

) : (
{matches.map((match) => ( {match.thumbnail_url ? ( {match.j_id} ) : null} {match.similarity}% {match.j_id} ))}
)}

e621 IQDB

{iqdbBusy ? ( searching… ) : iqdb ? ( {iqdb.length} candidate(s) ) : null}
{isVideo ? (

IQDB works on images only.

) : !configured ? (

Add e621 credentials {" "} to reverse-search this image on e621.

) : iqdbBusy && !iqdb ? (
) : iqdb && iqdb.length === 0 ? (

No similar posts found on e621.

) : iqdb ? ( <>
{iqdb.map((candidate) => { const isExact = candidate.md5 !== null && check.md5 === candidate.md5; return ( ); })}
{selected ? (

Post #{selected.post_id}

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

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

Select a candidate for its rating, score, favorites and tags.

)} ) : (

Waiting for the library check…

)}
) : null}
); }