Files
J621/frontend/src/features/library/IqdbCard.tsx
T
JakeBreath c55fa1fdca IQDB reverse search from the library detail
- New IQDB card on local item pages: fetches the signed raw file, POSTs it
  to e621's /iqdb_queries.json with the user's credentials, and lists
  candidates as tiles (thumbnail, rating, score%) with exact-MD5 markers.
- Selecting a candidate opens a detail panel (rating, score, favs, size,
  tag preview) and linking is an explicit confirm that reuses the e621
  match endpoint; videos show a note since IQDB is image-only.
- Guests don't see the card; linking follows the uploader/staff rule.
2026-09-17 13:53:23 -05:00

283 lines
9.6 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { ScanSearch } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
effectiveCredentials,
iqdbSearch,
RATING_LABELS,
type E621IqdbResult,
} from "@/lib/e621";
import type { MediaItem } from "@/lib/types";
import { useE621 } from "@/store/e621";
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",
};
type Notice = { kind: "ok" | "warn" | "error"; text: string } | null;
export function IqdbCard({
item,
canLink,
}: {
item: MediaItem;
canLink: boolean;
}) {
const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials);
const [busy, setBusy] = useState(false);
const [candidates, setCandidates] = useState<E621IqdbResult[] | null>(null);
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
const [notice, setNotice] = useState<Notice>(null);
const isImage = item.kind === "image";
const configured = Boolean(credentials?.configured);
async function search() {
setBusy(true);
setNotice(null);
setSelected(null);
setCandidates(null);
try {
const response = await fetch(item.raw_url);
if (!response.ok) {
throw new Error(`Could not load the file (HTTP ${response.status}).`);
}
const blob = await response.blob();
const results = await iqdbSearch(
effectiveCredentials(credentials),
blob,
item.filename,
);
setCandidates(results);
if (results.length === 0) {
setNotice({ kind: "warn", text: "No similar posts found on e621." });
}
} catch (error) {
setNotice({ kind: "error", text: e621ErrorMessage(error) });
} finally {
setBusy(false);
}
}
const linkMutation = useMutation({
mutationFn: (postId: number) =>
api<MediaItem & { md5_match?: boolean }>(
`/api/files/${item.j_id}/match/`,
{ method: "POST", json: { post_id: postId } },
),
onSuccess: (data) => {
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] });
setSelected(null);
setNotice(
data.md5_match
? {
kind: "ok",
text: `Linked to post #${data.e621_post_id} — MD5 matches.`,
}
: {
kind: "warn",
text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`,
},
);
},
onError: (error) =>
setNotice({ kind: "error", text: errorMessage(error) }),
});
return (
<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">IQDB search</h2>
{candidates && candidates.length > 0 ? (
<span className="font-mono text-[11px] text-ctp-overlay0">
{candidates.length} candidates
</span>
) : null}
</div>
<p className="mt-2 text-xs text-ctp-overlay0">
Reverse-search e621 with this file to find its original post.
</p>
<Button
variant="secondary"
className="mt-3 w-full"
disabled={busy || !isImage || !configured}
onClick={() => void search()}
>
{busy ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<ScanSearch className="h-4 w-4" />
)}
{busy ? "Searching…" : "Search e621"}
</Button>
{!configured ? (
<p className="mt-2 text-[11px] text-ctp-overlay0">
Configure e621 credentials in Account to use IQDB.
</p>
) : !isImage ? (
<p className="mt-2 text-[11px] text-ctp-overlay0">
IQDB works on images only.
</p>
) : null}
{notice ? (
<p
className={cn(
"mt-2 text-xs",
notice.kind === "ok"
? "text-ctp-green"
: notice.kind === "warn"
? "text-ctp-yellow"
: "text-ctp-red",
)}
>
{notice.text}
</p>
) : null}
{candidates && candidates.length > 0 ? (
<div className="mt-3 grid grid-cols-3 gap-1.5">
{candidates.map((candidate) => {
const exact = candidate.md5 !== null && candidate.md5 === item.md5;
return (
<button
key={`${candidate.post_id}-${candidate.md5}`}
type="button"
disabled={linkMutation.isPending || 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}
{exact ? (
<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>
) : null}
{selected ? (
<div className="mt-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-xs text-ctp-subtext1">
Post #{selected.post_id}
</p>
{selected.md5 === item.md5 ? (
<span className="rounded-full bg-ctp-green/15 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase text-ctp-green">
MD5 match
</span>
) : null}
</div>
<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">
<Button
disabled={
!canLink || linkMutation.isPending || selected.post_id === null
}
onClick={() =>
selected.post_id !== null &&
linkMutation.mutate(selected.post_id)
}
>
{linkMutation.isPending ? "Linking…" : "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">
{canLink
? "Linking stores this post's metadata on the item."
: "Only the uploader or staff can link posts."}
</p>
</div>
) : null}
{candidates && candidates.length > 0 && !selected ? (
<p className="mt-2 text-[11px] text-ctp-overlay0">
Select a candidate first, then confirm the link.
</p>
) : null}
</section>
);
}