Add a Pools browser backed by e621's /pools endpoint

- /pools: search by name, category/active filters, sort options and
  pagination per the OpenAPI spec, with covers taken from each pool's
  first post in one batched post call; blacklisted covers fall back to a
  placeholder and deleted pools get an archive marker.
- /pools/<id>: DText description, post grid kept in the pool's own order
  with chunked loading, in-library badges, a blacklist reveal toggle and
  a Follow pool button wired into the follows API.
- e621 client gains fetchPools/fetchPool; Pools nav entry added.
This commit is contained in:
2026-09-17 14:26:20 -05:00
parent c396c368dc
commit 0a7dc5a991
7 changed files with 817 additions and 0 deletions
@@ -0,0 +1,309 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Archive, Eye, EyeOff } from "lucide-react";
import { useMemo, useState } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import { Button, EmptyState, Spinner } from "@/components/ui";
import { PostCard } from "@/features/online/PostCard";
import { PoolFollowButton } from "@/features/follows/PoolFollowButton";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext";
import {
e621ErrorMessage,
effectiveCredentials,
fetchPool,
fetchPostsByIds,
postIsBlacklisted,
type E621Pool,
type E621Post,
} from "@/lib/e621";
import { formatDate } from "@/lib/format";
import { useE621 } from "@/store/e621";
const CHUNK = 100;
async function fetchPostsChunked(
credentials: ReturnType<typeof effectiveCredentials>,
ids: number[],
signal?: AbortSignal,
): Promise<E621Post[]> {
const posts: E621Post[] = [];
for (let index = 0; index < ids.length; index += CHUNK) {
const chunk = ids.slice(index, index + CHUNK);
posts.push(...(await fetchPostsByIds(credentials, chunk, signal)));
}
return posts;
}
const categoryBadge: Record<string, string> = {
series: "border-ctp-mauve/40 bg-ctp-mauve/15 text-ctp-mauve",
collection: "border-ctp-blue/40 bg-ctp-blue/15 text-ctp-blue",
};
export default function PoolDetailPage() {
const { poolId = "" } = useParams();
const id = Number(poolId);
const location = useLocation();
const credentials = useE621((state) => state.credentials);
const blacklist = useE621((state) => state.blacklist);
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
const [visibleCount, setVisibleCount] = useState(CHUNK);
const [showBlacklisted, setShowBlacklisted] = useState(false);
const poolQuery = useQuery({
queryKey: ["e621", "pool", id, credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchPool(effectiveCredentials(credentials), id, signal),
enabled: Number.isFinite(id) && id > 0,
staleTime: 5 * 60_000,
});
const pool: E621Pool | undefined = poolQuery.data;
const postIds = useMemo(
() => (pool?.post_ids ?? []).slice(0, visibleCount),
[pool, visibleCount],
);
const postsQuery = useQuery({
queryKey: [
"e621",
"pool-posts",
id,
postIds.join(","),
credentials?.username ?? "",
],
queryFn: ({ signal }) =>
fetchPostsChunked(
effectiveCredentials(credentials),
postIds,
signal,
),
enabled: postIds.length > 0,
staleTime: 5 * 60_000,
});
// Keep the pool's own post order, dropping ids e621 no longer serves.
const orderedPosts = useMemo(() => {
const byId = new Map<number, E621Post>();
for (const post of postsQuery.data ?? []) byId.set(post.id, post);
return postIds
.map((postIdValue) => byId.get(postIdValue))
.filter((post): post is E621Post => Boolean(post));
}, [postsQuery.data, postIds]);
const md5s = useMemo(
() => orderedPosts.map((post) => post.file.md5),
[orderedPosts],
);
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 { shownPosts, hiddenCount } = useMemo(() => {
if (blacklistSet.size === 0 || showBlacklisted) {
return { shownPosts: orderedPosts, hiddenCount: 0 };
}
const shown: E621Post[] = [];
let hidden = 0;
for (const post of orderedPosts) {
if (postIsBlacklisted(post, blacklistSet).length > 0) {
hidden += 1;
} else {
shown.push(post);
}
}
return { shownPosts: shown, hiddenCount: hidden };
}, [orderedPosts, blacklistSet, showBlacklisted]);
if (!Number.isFinite(id) || id <= 0) {
return (
<EmptyState
title="Pool not found"
description={
<Link to="/pools" className="text-ctp-blue hover:underline">
Back to pools
</Link>
}
/>
);
}
if (poolQuery.isPending) {
return (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
);
}
if (poolQuery.isError || !pool) {
return (
<EmptyState
title="Could not load this pool"
description={
<>
{e621ErrorMessage(poolQuery.error)}{" "}
<Link to="/pools" className="text-ctp-blue hover:underline">
Back to pools
</Link>
</>
}
/>
);
}
const remaining = pool.post_ids.length - postIds.length;
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<Link
to="/pools"
className="inline-flex items-center gap-1 text-sm text-ctp-subtext0 transition hover:text-ctp-text"
>
<ArrowLeft className="h-4 w-4" /> Pools
</Link>
<h1 className="mt-2 break-words text-lg font-semibold">
{pool.name}
</h1>
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-ctp-overlay0">
<span
className={cn(
"rounded-full border px-1.5 py-0.5 font-mono text-[10px] font-medium capitalize",
categoryBadge[pool.category] ??
"border-ctp-surface1 text-ctp-subtext0",
)}
>
{pool.category}
</span>
<span className="font-mono">#{pool.id}</span>
<span>{pool.post_count} posts</span>
<span>by {pool.creator_name || `#${pool.creator_id}`}</span>
<span>· updated {formatDate(pool.updated_at)}</span>
{!pool.is_active ? (
<span className="flex items-center gap-1 text-ctp-red">
<Archive className="h-3 w-3" /> deleted
</span>
) : null}
</p>
</div>
<PoolFollowButton poolId={pool.id} name={pool.name} />
</header>
{pool.description.trim() ? (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Description
</h2>
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
{renderDText(pool.description)}
</div>
</section>
) : null}
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Posts{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{shownPosts.length}
{hiddenCount > 0 ? ` of ${orderedPosts.length}` : ""}
</span>
</h2>
{hiddenCount > 0 || showBlacklisted ? (
<button
type="button"
onClick={() => setShowBlacklisted((value) => !value)}
className="inline-flex items-center gap-1.5 text-xs text-ctp-overlay0 transition hover:text-ctp-text"
>
{showBlacklisted ? (
<>
<EyeOff className="h-3.5 w-3.5" /> Hide blacklisted
</>
) : (
<>
<Eye className="h-3.5 w-3.5" /> Reveal {hiddenCount}{" "}
blacklisted
</>
)}
</button>
) : null}
</div>
{postsQuery.isPending ? (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
) : postsQuery.isError ? (
<EmptyState
title="Could not load the pool's posts"
description={e621ErrorMessage(postsQuery.error)}
/>
) : shownPosts.length === 0 ? (
<EmptyState
title="No posts to show"
description={
hiddenCount > 0
? "Everything here is blacklisted."
: "This pool has no available posts."
}
/>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3">
{shownPosts.map((post) => {
const blacklisted =
blacklistSet.size > 0 &&
postIsBlacklisted(post, blacklistSet).length > 0;
const card = (
<PostCard
post={post}
inLibrary={inLibrary.has(post.file.md5)}
from={`${location.pathname}${location.search}`}
/>
);
return blacklisted ? (
<div
key={post.id}
className="rounded-lg ring-1 ring-ctp-red/50"
title="Matches your blacklist"
>
{card}
</div>
) : (
<div key={post.id}>{card}</div>
);
})}
</div>
)}
{remaining > 0 ? (
<div className="mt-2 flex justify-center">
<Button
variant="secondary"
disabled={postsQuery.isFetching}
onClick={() => setVisibleCount((count) => count + CHUNK)}
>
{postsQuery.isFetching
? "Loading…"
: `Load more posts (${remaining} remaining)`}
</Button>
</div>
) : null}
</section>
</div>
);
}