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
+3 -33
View File
@@ -1,7 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { Search, X } from "lucide-react";
import { Search } from "lucide-react";
import { useState, useEffect, type FormEvent } from "react";
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
import { MediaCard } from "@/components/MediaCard";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api } from "@/lib/api";
@@ -44,8 +45,6 @@ export default function LibraryPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const sidebarOpen = useUi((state) => state.sidebarOpen);
const setSidebarOpen = useUi((state) => state.setSidebarOpen);
const ratings = useUi((state) => state.ratings);
const setRatings = useUi((state) => state.setRatings);
const perPage = useUi((state) => state.perPage);
@@ -199,36 +198,7 @@ export default function LibraryPage() {
return (
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{sidebarOpen ? (
<>
<div
className="fixed inset-0 z-40 bg-ctp-crust/60 backdrop-blur-sm lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col gap-5 overflow-y-auto border-r border-ctp-surface0 bg-ctp-base p-4",
"lg:sticky lg:inset-y-auto lg:top-20 lg:z-auto lg:w-64 lg:shrink-0 lg:overflow-visible lg:rounded-lg lg:border",
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-ctp-subtext1">
Filters
</span>
<button
type="button"
onClick={() => setSidebarOpen(false)}
title="Close filters"
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text lg:hidden"
>
<X className="h-4 w-4" />
</button>
</div>
{filterPanel}
</aside>
</>
) : null}
<CollapsibleSidebar>{filterPanel}</CollapsibleSidebar>
<section className="min-w-0 flex-1">
<div className="mb-4 flex items-center justify-between gap-3">
@@ -0,0 +1,393 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
CheckCircle2,
Download,
ExternalLink,
Heart,
HeartOff,
} from "lucide-react";
import { useMemo } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button, EmptyState, Spinner } from "@/components/ui";
import { TagChip } from "@/components/TagChip";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
effectiveCredentials,
fetchPost,
fetchPostsByIds,
RATING_LABELS,
setFavorite,
TAG_CATEGORY_LABELS,
TAG_CATEGORY_ORDER,
} from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format";
import { useE621 } from "@/store/e621";
const ratingText: Record<string, string> = {
s: "text-ctp-green",
q: "text-ctp-peach",
e: "text-ctp-red",
};
const relatedBorder: 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 linkButtonClass =
"inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1";
export default function OnlineDetailPage() {
const { id = "" } = useParams();
const postId = Number(id);
const navigate = useNavigate();
const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials);
const query = useQuery({
queryKey: ["e621", "post", postId, credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchPost(effectiveCredentials(credentials), postId, signal),
enabled: Number.isFinite(postId) && postId > 0,
});
const post = query.data;
const relatedIds = useMemo(() => {
if (!post) return [];
const ids: number[] = [];
if (post.relationships.parent_id) ids.push(post.relationships.parent_id);
for (const childId of post.relationships.children) {
if (ids.length >= 12) break;
ids.push(childId);
}
return ids;
}, [post]);
const relatedQuery = useQuery({
queryKey: [
"e621",
"posts-by-ids",
relatedIds.join(","),
credentials?.username ?? "",
],
queryFn: ({ signal }) =>
fetchPostsByIds(effectiveCredentials(credentials), relatedIds, signal),
enabled: relatedIds.length > 0,
});
const lookupQuery = useQuery({
queryKey: ["library-lookup", post?.file.md5 ?? ""],
queryFn: () =>
api<{ found: string[] }>("/api/files/lookup/", {
method: "POST",
json: { md5s: [post?.file.md5] },
}),
enabled: Boolean(post?.file.md5),
staleTime: 60_000,
});
const inLibrary = (lookupQuery.data?.found.length ?? 0) > 0;
const favoriteMutation = useMutation({
mutationFn: (favorite: boolean) =>
setFavorite(effectiveCredentials(credentials), postId, favorite),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["e621", "post", postId],
});
},
});
if (query.isPending) {
return (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
);
}
if (query.isError || !post) {
return (
<EmptyState
title="Could not load this post"
description={
query.isError ? (
e621ErrorMessage(query.error)
) : (
<Link to="/online" className="text-ctp-blue hover:underline">
Back to the online browser
</Link>
)
}
/>
);
}
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
const imageSrc =
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 text-sm">
<Link
to="/online"
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
>
<ArrowLeft className="h-4 w-4" /> Online
</Link>
<span className="text-ctp-surface1">/</span>
<span className="font-mono text-xs text-ctp-overlay0">
Post #{post.id}
</span>
</div>
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="order-2 flex flex-col gap-4 lg:order-1">
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2>
<div className="mt-3 flex flex-col gap-3">
{TAG_CATEGORY_ORDER.map((category) => {
const tags = post.tags[category] ?? [];
if (tags.length === 0) return null;
return (
<div key={category}>
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-ctp-overlay0">
{TAG_CATEGORY_LABELS[category] ?? category}
</p>
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<TagChip
key={tag}
tag={tag}
category={category}
onClick={() =>
navigate(`/online?tags=${encodeURIComponent(tag)}`)
}
/>
))}
</div>
</div>
);
})}
</div>
</section>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">Specs</h2>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<dt className="text-ctp-overlay0">Rating</dt>
<dd className={cn("font-medium", ratingText[post.rating])}>
{RATING_LABELS[post.rating] ?? post.rating}
</dd>
<dt className="text-ctp-overlay0">Score</dt>
<dd className="font-mono text-ctp-subtext0">
{post.score.up} ▲ / {post.score.down} ▼
</dd>
<dt className="text-ctp-overlay0">Favorites</dt>
<dd className="font-mono text-ctp-subtext0">{post.fav_count}</dd>
<dt className="text-ctp-overlay0">Comments</dt>
<dd className="font-mono text-ctp-subtext0">
{post.comment_count}
</dd>
<dt className="text-ctp-overlay0">File</dt>
<dd className="font-mono uppercase text-ctp-subtext0">
{post.file.ext} · {formatBytes(post.file.size)}
</dd>
<dt className="text-ctp-overlay0">Dimensions</dt>
<dd className="font-mono text-ctp-subtext0">
{post.file.width}×{post.file.height}
</dd>
<dt className="text-ctp-overlay0">MD5</dt>
<dd
className="truncate font-mono text-ctp-subtext0"
title={post.file.md5}
>
{post.file.md5}
</dd>
<dt className="text-ctp-overlay0">Posted</dt>
<dd className="text-ctp-subtext0">{formatDate(post.created_at)}</dd>
{post.uploader_name ? (
<>
<dt className="text-ctp-overlay0">Uploader</dt>
<dd className="text-ctp-subtext0">{post.uploader_name}</dd>
</>
) : null}
</dl>
{inLibrary ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-ctp-green">
<CheckCircle2 className="h-3.5 w-3.5" /> In your library
</p>
) : null}
</section>
</aside>
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={post.is_favorited ? "secondary" : "primary"}
disabled={!credentials?.configured || favoriteMutation.isPending}
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
title={
credentials?.configured
? undefined
: "Add e621 credentials in Account to favorite posts"
}
>
{post.is_favorited ? (
<HeartOff className="h-4 w-4" />
) : (
<Heart className="h-4 w-4" />
)}
{favoriteMutation.isPending
? "Saving…"
: post.is_favorited
? "Unfavorite"
: "Favorite"}
</Button>
<Button
variant="secondary"
disabled
title="Coming with the Download to Library update"
>
<Download className="h-4 w-4" /> Download to Library
</Button>
<a
href={`https://e621.net/posts/${post.id}`}
target="_blank"
rel="noreferrer"
className={linkButtonClass}
>
<ExternalLink className="h-4 w-4" /> View on e621
</a>
</div>
{favoriteMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(favoriteMutation.error)}
</p>
) : null}
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
{isVideo ? (
<video
key={post.id}
src={post.file.url ?? undefined}
controls
autoPlay
loop
muted
className="max-h-[75vh] w-full object-contain"
/>
) : (
<img
key={post.id}
src={imageSrc ?? undefined}
alt={`Post #${post.id}`}
className="max-h-[75vh] w-full object-contain"
/>
)}
</div>
{post.description.trim() ? (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<details>
<summary className="cursor-pointer text-sm font-semibold text-ctp-subtext1">
Description
</summary>
<p className="mt-2 whitespace-pre-line text-sm text-ctp-subtext0">
{post.description}
</p>
</details>
</section>
) : null}
{post.sources.length > 0 ? (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Sources
</h2>
<ul className="mt-2 flex flex-col gap-1">
{post.sources.slice(0, 6).map((source) => (
<li key={source} className="truncate">
<a
href={source}
target="_blank"
rel="noreferrer"
className="font-mono text-xs text-ctp-blue hover:underline"
title={source}
>
{source}
</a>
</li>
))}
</ul>
</section>
) : null}
{post.pools.length > 0 ? (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">Pools</h2>
<div className="mt-2 flex flex-wrap gap-1.5">
{post.pools.map((poolId) => (
<a
key={poolId}
href={`https://e621.net/pools/${poolId}`}
target="_blank"
rel="noreferrer"
className="rounded border border-ctp-surface1 px-1.5 py-0.5 font-mono text-[11px] text-ctp-subtext0 transition hover:bg-ctp-surface0"
>
Pool #{poolId}
</a>
))}
</div>
</section>
) : null}
{relatedIds.length > 0 ? (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Related posts
</h2>
{relatedQuery.isPending ? (
<Spinner className="mt-3 h-4 w-4" />
) : (
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-6">
{(relatedQuery.data ?? []).map((related) => (
<Link
key={related.id}
to={`/online/view/${related.id}`}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle transition",
relatedBorder[related.rating] ?? "border-ctp-surface0",
)}
>
<div className="aspect-square overflow-hidden">
{related.preview.url ? (
<img
src={related.preview.url}
alt={`Post #${related.id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
) : null}
</div>
<p className="px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
#{related.id}
</p>
</Link>
))}
</div>
)}
</section>
) : null}
</div>
</div>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
import { TagChip } from "@/components/TagChip";
import { Button, EmptyState, Spinner } from "@/components/ui";
import { PostCard } from "@/features/online/PostCard";
import { TagAutocomplete } from "@/features/online/TagAutocomplete";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
effectiveCredentials,
fetchPosts,
postIsBlacklisted,
type E621Post,
} from "@/lib/e621";
import { useE621 } from "@/store/e621";
const PAGE_SIZE = 48;
const EMPTY_POSTS: E621Post[] = [];
export default function OnlinePage() {
const [searchParams, setSearchParams] = useSearchParams();
const tags = searchParams.get("tags") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const credentials = useE621((state) => state.credentials);
const blacklist = useE621((state) => state.blacklist);
const [draft, setDraft] = useState<string | null>(null);
const tagInput = draft ?? tags;
const postsQuery = useQuery({
queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchPosts(effectiveCredentials(credentials), {
tags,
page,
limit: PAGE_SIZE,
signal,
}),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
const posts = postsQuery.data ?? EMPTY_POSTS;
const md5s = useMemo(() => posts.map((post) => post.file.md5), [posts]);
const lookupQuery = useQuery({
queryKey: ["library-lookup", md5s.join(",")],
queryFn: () =>
api<{ found: string[] }>("/api/files/lookup/", {
method: "POST",
json: { md5s },
}),
enabled: md5s.length > 0,
staleTime: 60_000,
});
const inLibrary = useMemo(
() => new Set(lookupQuery.data?.found ?? []),
[lookupQuery.data],
);
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
const { visiblePosts, blacklistCounts, removedCount } = useMemo(() => {
const counts = new Map<string, number>();
const visible: E621Post[] = [];
let removed = 0;
for (const post of posts) {
const matches = postIsBlacklisted(post, blacklistSet);
if (matches.length > 0) {
removed += 1;
for (const match of matches) {
counts.set(match, (counts.get(match) ?? 0) + 1);
}
} else {
visible.push(post);
}
}
return {
visiblePosts: visible,
blacklistCounts: [...counts.entries()].sort((a, b) => b[1] - a[1]),
removedCount: removed,
};
}, [posts, blacklistSet]);
const pageTags = useMemo(() => {
const counts = new Map<string, number>();
for (const post of visiblePosts) {
for (const list of Object.values(post.tags)) {
for (const tag of list) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40);
}, [visiblePosts]);
const hasNext = posts.length >= PAGE_SIZE;
function buildParams(tagValue: string, pageValue: number): URLSearchParams {
const params = new URLSearchParams();
if (tagValue.trim()) params.set("tags", tagValue.trim());
if (pageValue > 1) params.set("page", String(pageValue));
return params;
}
function applySearch() {
setDraft(null);
setSearchParams(buildParams(tagInput, 1));
}
function clearSearch() {
setDraft(null);
setSearchParams(new URLSearchParams());
}
function addTag(tag: string) {
const next = tags ? `${tags} ${tag}` : tag;
setDraft(null);
setSearchParams(buildParams(next, 1));
}
function goToPage(nextPage: number) {
setSearchParams(buildParams(tags, nextPage));
}
return (
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<CollapsibleSidebar>
<form
onSubmit={(event) => {
event.preventDefault();
applySearch();
}}
className="flex flex-col gap-2"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Tags
</span>
<TagAutocomplete value={tagInput} onChange={setDraft} />
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Search
</Button>
<Button type="button" variant="secondary" onClick={clearSearch}>
Clear
</Button>
</div>
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
e621 syntax: space separated, <span className="font-mono">-tag</span>{" "}
excludes, <span className="font-mono">~tag</span> ORs.
</p>
</form>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Tags on this page
</span>
<div className="flex flex-wrap gap-1.5">
{pageTags.map(([tag, count]) => (
<TagChip
key={tag}
tag={tag}
count={count}
onClick={() => addTag(tag)}
/>
))}
{pageTags.length === 0 && postsQuery.isPending ? (
<Spinner className="h-4 w-4" />
) : null}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Your blacklist
</span>
{blacklist.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blacklist.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
>
{tag}
</span>
))}
</div>
) : (
<p className="text-xs text-ctp-overlay0">No blacklisted tags.</p>
)}
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Blacklisted tags removed
</span>
{blacklistCounts.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blacklistCounts.map(([tag, count]) => (
<span
key={tag}
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
>
{tag} ({count})
</span>
))}
</div>
) : (
<p className="text-xs text-ctp-overlay0">
Nothing filtered on this page.
</p>
)}
</div>
</CollapsibleSidebar>
<section className="min-w-0 flex-1">
<div className="mb-4 flex items-center justify-between gap-3">
<p className="text-sm text-ctp-overlay0">
{postsQuery.data
? `${visiblePosts.length} post${visiblePosts.length === 1 ? "" : "s"} shown${
removedCount > 0
? ` · ${removedCount} hidden by blacklist`
: ""
}`
: "…"}
</p>
<p className="font-mono text-xs text-ctp-overlay0">Page {page}</p>
</div>
{credentials && !credentials.configured ? (
<p className="mb-4 rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-overlay0">
Browsing anonymously.{" "}
<Link to="/account" className="text-ctp-blue hover:underline">
Add e621 credentials
</Link>{" "}
for favorites and your personal blacklist.
</p>
) : null}
{postsQuery.isPending ? (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
) : postsQuery.isError ? (
<EmptyState
title="Could not load posts from e621"
description={e621ErrorMessage(postsQuery.error)}
/>
) : visiblePosts.length === 0 ? (
<EmptyState
title="No posts found"
description={
removedCount > 0
? "Everything on this page was blacklisted."
: "Try different tags."
}
/>
) : (
<div
className={cn(
"grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3 transition-opacity",
postsQuery.isPlaceholderData && "opacity-50",
)}
>
{visiblePosts.map((post) => (
<PostCard
key={post.id}
post={post}
inLibrary={inLibrary.has(post.file.md5)}
/>
))}
</div>
)}
<div className="mt-6 flex items-center justify-center gap-3">
<Button
variant="secondary"
disabled={page <= 1 || postsQuery.isFetching}
onClick={() => goToPage(page - 1)}
>
Previous
</Button>
<Button
variant="secondary"
disabled={!hasNext || postsQuery.isFetching}
onClick={() => goToPage(page + 1)}
>
Next
</Button>
</div>
</section>
</div>
);
}
+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>
);
}
@@ -0,0 +1,125 @@
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useState } from "react";
import { inputClass } from "@/components/ui";
import { cn } from "@/lib/cn";
import {
effectiveCredentials,
fetchTagSuggestions,
tagCategoryName,
type E621Tag,
} from "@/lib/e621";
import { useDebouncedValue } from "@/lib/hooks";
import { useE621 } from "@/store/e621";
const categoryDot: Record<string, string> = {
general: "bg-ctp-blue",
artist: "bg-ctp-yellow",
copyright: "bg-ctp-mauve",
character: "bg-ctp-green",
species: "bg-ctp-peach",
meta: "bg-ctp-subtext0",
lore: "bg-ctp-teal",
invalid: "bg-ctp-red",
contributor: "bg-ctp-lavender",
unknown: "bg-ctp-surface2",
};
export function TagAutocomplete({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const credentials = useE621((state) => state.credentials);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const lastWord = value.split(" ").pop() ?? "";
const debounced = useDebouncedValue(lastWord, 450);
const suggestionsQuery = useQuery({
queryKey: ["e621", "tags", debounced, credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchTagSuggestions(effectiveCredentials(credentials), debounced, signal),
enabled: debounced.trim().length >= 2,
staleTime: 60_000,
});
const suggestions = suggestionsQuery.data ?? [];
const visible = open && suggestions.length > 0;
function pick(tag: E621Tag) {
const prefix = value.slice(0, Math.max(0, value.length - lastWord.length));
onChange(`${prefix}${tag.name} `);
setOpen(false);
setActiveIndex(0);
}
return (
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ctp-overlay0" />
<input
className={cn(inputClass, "pl-8")}
placeholder="Tags: dragon solo -female"
value={value}
onChange={(event) => {
onChange(event.target.value);
setOpen(true);
setActiveIndex(0);
}}
onKeyDown={(event) => {
if (!visible) return;
const suggestion = suggestions[activeIndex];
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => Math.min(index + 1, suggestions.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => Math.max(index - 1, 0));
} else if (event.key === "Enter") {
event.preventDefault();
if (suggestion) pick(suggestion);
} else if (event.key === "Escape") {
setOpen(false);
}
}}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
/>
{visible ? (
<ul className="absolute left-0 right-0 top-full z-30 mt-1 overflow-hidden rounded-md border border-ctp-surface1 bg-ctp-surface0 py-1 shadow-[0_4px_12px_rgba(17,17,27,0.6)]">
{suggestions.map((tag, index) => (
<li key={tag.id}>
<button
type="button"
onMouseDown={(event) => {
event.preventDefault();
pick(tag);
}}
onMouseEnter={() => setActiveIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-xs transition",
index === activeIndex
? "bg-ctp-surface1 text-ctp-text"
: "text-ctp-subtext0",
)}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
categoryDot[tagCategoryName(tag.category)],
)}
/>
<span className="flex-1 truncate">{tag.name}</span>
<span className="text-[10px] text-ctp-overlay0">
{tag.post_count.toLocaleString()}
</span>
</button>
</li>
))}
</ul>
) : null}
</div>
);
}