Enrich IQDB candidates; link downloads the e621 original
- IQDB responses carry no preview/file data, so candidates only showed an ID; the SPA now enriches them with one batched posts lookup (preview, rating, score, favourites, dimensions, tag preview) - Candidate tiles are selectable instead of instantly resolving: picking one shows its info and an explicit 'Link selected post' button - Linking a post now fetches the e621 original into the library and drops the staged upload; when the staged file's MD5 already equals the post's file, the staged copy is moved instead (identical bytes) - Keep the file URL in stored e621 metadata; sanitize the new candidate fields server-side
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
fetchPost,
|
||||
fetchPostsByMd5,
|
||||
iqdbSearch,
|
||||
RATING_LABELS,
|
||||
} from "@/lib/e621";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type {
|
||||
@@ -45,6 +46,12 @@ const RESOLUTION_LABELS: Record<string, string> = {
|
||||
custom: "custom metadata",
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
||||
{
|
||||
value: "",
|
||||
@@ -205,6 +212,7 @@ function MetadataModal({
|
||||
const credentials = useE621((state) => state.credentials);
|
||||
const [tab, setTab] = useState<"link" | "custom">("link");
|
||||
const [postId, setPostId] = useState("");
|
||||
const [selected, setSelected] = useState<E621IqdbCandidate | null>(null);
|
||||
const [rating, setRating] = useState<Rating>(temp.custom_rating);
|
||||
const [tags, setTags] = useState(temp.custom_tags.join(", "));
|
||||
const [notes, setNotes] = useState(temp.custom_notes);
|
||||
@@ -354,17 +362,39 @@ function MetadataModal({
|
||||
key={`${candidate.post_id}-${candidate.md5}`}
|
||||
type="button"
|
||||
disabled={busy || !candidate.post_id}
|
||||
onClick={() => void linkCandidate(candidate)}
|
||||
className="overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle text-left transition hover:border-ctp-surface2 disabled:opacity-50"
|
||||
onClick={() => setSelected(candidate)}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50",
|
||||
selected?.post_id === candidate.post_id
|
||||
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
|
||||
: "border-ctp-surface0 hover:border-ctp-surface2",
|
||||
)}
|
||||
>
|
||||
{candidate.preview_url ? (
|
||||
<img
|
||||
src={candidate.preview_url}
|
||||
alt={`Post #${candidate.post_id}`}
|
||||
loading="lazy"
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<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}
|
||||
</span>
|
||||
<span className="block px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
|
||||
#{candidate.post_id}
|
||||
{candidate.score !== null
|
||||
@@ -374,6 +404,62 @@ function MetadataModal({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div className="mt-2 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 !== undefined
|
||||
? ` · ▲ ${selected.score_total}`
|
||||
: ""}
|
||||
{selected.fav_count !== null &&
|
||||
selected.fav_count !== undefined
|
||||
? ` · ${selected.fav_count} favs`
|
||||
: ""}
|
||||
{selected.width && selected.height
|
||||
? ` · ${selected.width}×${selected.height}`
|
||||
: ""}
|
||||
</p>
|
||||
{selected.tags_preview &&
|
||||
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
|
||||
onClick={() => void linkCandidate(selected)}
|
||||
disabled={busy || !selected.post_id}
|
||||
>
|
||||
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">
|
||||
Linking downloads the post's file into the library and
|
||||
drops this staged upload.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ctp-overlay0">
|
||||
|
||||
Reference in New Issue
Block a user