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.
486 lines
19 KiB
TypeScript
486 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 { 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>
|
||
);
|
||
}
|