Files
J621/frontend/src/features/similar/SimilarPage.tsx
T
JakeBreath e97c3b9da0 Toast action results and confirm destructive actions in one dialog
Inline banners and per-row status text reported action results all over
the app; they are replaced by a small toast stack (bottom-right, Level 3
floating well styling) that only speaks for actions: successes fade,
errors stay until dismissed, and form-field validation stays inline.

Destructive actions no longer use bespoke inline confirm steps (the
library detail's Confirm delete button) or fire immediately (duplicate
copies/items, delete page selections, temp cleanup, upload discard, job
cancellation): they all go through one promise-based confirm dialog
(confirmAction) with a danger variant, Escape/backdrop to cancel.
2026-09-17 22:19:54 -05:00

486 lines
19 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 { 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<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)]">
<aside className="flex flex-col gap-4">
<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>
</aside>
<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={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={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>
);
}