Online browser + post detail: SPA talks to e621 directly

Backend:
- POST /api/files/lookup/ reports which MD5s are already in the library

Frontend:
- e621 client extended with post/tag/favorite types and helpers: post
  search, post detail, batch posts by id, tag autocomplete, toggle
  favorite
- /online: tag search with autocomplete, post grid with rating colors
  and in-library badges, numbered pagination, page tag cloud, blacklist
  panel and filtered counts from /users/me.json, anonymous hint
- /online/view/🆔 media viewer (sample or original, video support),
  taxonomy-colored tags by category, specs sheet, favorite/unfavorite,
  description, sources, pools and parent/children thumbnails
- Shared CollapsibleSidebar extracted from the library page; Online
  added to the nav, command palette and sidebar toggle
This commit is contained in:
2026-09-17 09:00:17 -05:00
parent b6409523e6
commit 04448bf615
14 changed files with 1273 additions and 41 deletions
+76
View File
@@ -0,0 +1,76 @@
import { CheckCircle2 } from "lucide-react";
import { Link } from "react-router-dom";
import { cn } from "@/lib/cn";
import type { E621Post } from "@/lib/e621";
import { formatBytes } from "@/lib/format";
const ratingBorder: Record<string, string> = {
s: "border-ctp-green/25 hover:border-ctp-green/60",
q: "border-ctp-peach/25 hover:border-ctp-peach/60",
e: "border-ctp-red/25 hover:border-ctp-red/60",
};
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 function PostCard({
post,
inLibrary,
}: {
post: E621Post;
inLibrary: boolean;
}) {
return (
<Link
to={`/online/view/${post.id}`}
className={cn(
"group overflow-hidden rounded-lg border bg-ctp-base transition",
ratingBorder[post.rating] ?? "border-ctp-surface0",
)}
>
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
{post.preview.url ? (
<img
src={post.preview.url}
alt={`Post #${post.id}`}
loading="lazy"
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-ctp-overlay0">
No preview
</div>
)}
<span
className={cn(
"absolute left-2 top-2 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase",
ratingPill[post.rating] ?? "bg-ctp-surface1 text-ctp-text",
)}
>
{post.rating}
</span>
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] uppercase text-ctp-overlay1">
{post.file.ext}
</span>
{inLibrary ? (
<span
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-full bg-ctp-green/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust"
title="Already in your library"
>
<CheckCircle2 className="h-3 w-3" /> IN
</span>
) : null}
</div>
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
<span className="font-mono text-xs text-ctp-subtext1">#{post.id}</span>
<span className="font-mono text-[11px] text-ctp-overlay0">
▲ {post.score.total} · {formatBytes(post.file.size)}
</span>
</div>
</Link>
);
}