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
+4
View File
@@ -20,6 +20,8 @@ import FollowedPage from "@/features/follows/FollowedPage";
import LibraryPage from "@/features/library/LibraryPage";
import Md5Redirect from "@/features/library/Md5Redirect";
import OnlinePage from "@/features/online/OnlinePage";
import PoolDetailPage from "@/features/pools/PoolDetailPage";
import PoolsPage from "@/features/pools/PoolsPage";
import UploadPage from "@/features/upload/UploadPage";
import UsersPage from "@/features/users/UsersPage";
import { isAgeVerified, markAgeVerified } from "@/lib/age";
@@ -71,6 +73,8 @@ export default function App() {
<Route path="/view/:md5" element={<Md5Redirect />} />
<Route path="/online" element={<OnlinePage />} />
<Route path="/online/view/:postId" element={<PostRedirect />} />
<Route path="/pools" element={<PoolsPage />} />
<Route path="/pools/:poolId" element={<PoolDetailPage />} />
<Route
path="/followed"
element={
+1
View File
@@ -60,6 +60,7 @@ export function AppShell() {
const navItems = [
{ to: "/", label: "Library" },
{ to: "/online", label: "Online" },
{ to: "/pools", label: "Pools" },
...(user ? [{ to: "/followed", label: "Followed" }] : []),
...(user?.can_upload
? [
@@ -0,0 +1,82 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Star } from "lucide-react";
import { useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import type { FollowedPool, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
export function PoolFollowButton({
poolId,
name,
}: {
poolId: number;
name?: string;
}) {
const user = useAuth((state) => state.user);
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
// What the button should show once the request and refetch settle.
const [target, setTarget] = useState<boolean | null>(null);
const query = useQuery({
queryKey: ["follows-pools"],
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
enabled: Boolean(user),
staleTime: 30_000,
});
const follow = query.data?.results.find((entry) => entry.pool_id === poolId);
const followed = Boolean(follow);
const mutation = useMutation({
mutationFn: () =>
follow
? api(`/api/follows/pools/${follow.id}/`, { method: "DELETE" })
: api<FollowedPool>("/api/follows/pools/", {
method: "POST",
json: { pool_id: poolId },
}),
onSuccess: () => {
setError(null);
void queryClient.invalidateQueries({ queryKey: ["follows-pools"] });
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
},
onError: (err) => {
setTarget(null);
setError(errorMessage(err));
},
});
if (!user) return null;
const busy = mutation.isPending || (target !== null && followed !== target);
const label = name ? `"${name}"` : `#${poolId}`;
return (
<div className="flex flex-col items-end gap-1">
<Button
variant={followed ? "secondary" : "primary"}
disabled={busy || query.isPending}
onClick={() => {
if (busy) return;
setTarget(!followed);
mutation.mutate();
}}
title={followed ? `Unfollow pool ${label}` : `Follow pool ${label}`}
>
{busy ? (
<Spinner className="h-3.5 w-3.5" />
) : followed ? (
<Check className="h-4 w-4" />
) : (
<Star className="h-4 w-4" />
)}
{followed ? "Following" : "Follow pool"}
</Button>
{error ? <p className="text-xs text-ctp-red">{error}</p> : null}
</div>
);
}
@@ -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>
);
}
+352
View File
@@ -0,0 +1,352 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Archive, Layers, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
effectiveCredentials,
fetchPools,
fetchPostsByIds,
postIsBlacklisted,
type E621Pool,
type E621PoolOrder,
type E621Post,
} from "@/lib/e621";
import { formatDate } from "@/lib/format";
import { useE621 } from "@/store/e621";
const PAGE_SIZE = 48;
const EMPTY_POOLS: E621Pool[] = [];
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",
};
const orderOptions: { value: E621PoolOrder; label: string }[] = [
{ value: "id_desc", label: "Newest first" },
{ value: "id_asc", label: "Oldest first" },
{ value: "name", label: "Name A–Z" },
{ value: "post_count", label: "Most posts" },
{ value: "created_at", label: "Recently active" },
];
export default function PoolsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const credentials = useE621((state) => state.credentials);
const blacklist = useE621((state) => state.blacklist);
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
const q = searchParams.get("q") ?? "";
const category = searchParams.get("category") ?? "";
const status = searchParams.get("status") ?? "";
const order = (searchParams.get("order") ?? "id_desc") as E621PoolOrder;
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const [draft, setDraft] = useState<string | null>(null);
const nameInput = draft ?? q;
const poolsQuery = useQuery({
queryKey: [
"e621",
"pools",
q,
category,
status,
order,
page,
credentials?.username ?? "",
],
queryFn: ({ signal }) =>
fetchPools(effectiveCredentials(credentials), {
name: q,
category,
isActive: status,
order,
page,
limit: PAGE_SIZE,
signal,
}),
placeholderData: keepPreviousData,
staleTime: 5 * 60_000,
});
const pools = poolsQuery.data ?? EMPTY_POOLS;
// Covers come from each pool's first post, fetched in one batch.
const coverIds = useMemo(
() =>
[
...new Set(
pools
.map((pool) => pool.post_ids[0])
.filter((id): id is number => typeof id === "number"),
),
],
[pools],
);
const coversQuery = useQuery({
queryKey: ["e621", "pool-covers", coverIds.join(","), credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchPostsByIds(effectiveCredentials(credentials), coverIds, signal),
enabled: coverIds.length > 0,
staleTime: 30 * 60_000,
});
const covers = useMemo(() => {
const map = new Map<number, E621Post>();
for (const post of coversQuery.data ?? []) map.set(post.id, post);
return map;
}, [coversQuery.data]);
function buildParams(values: {
q: string;
category: string;
status: string;
order: string;
page: number;
}): URLSearchParams {
const params = new URLSearchParams();
if (values.q.trim()) params.set("q", values.q.trim());
if (values.category) params.set("category", values.category);
if (values.status) params.set("status", values.status);
if (values.order && values.order !== "id_desc")
params.set("order", values.order);
if (values.page > 1) params.set("page", String(values.page));
return params;
}
function update(patch: Partial<{ q: string; category: string; status: string; order: string }>) {
setDraft(null);
setSearchParams(
buildParams({
q: patch.q ?? q,
category: patch.category ?? category,
status: patch.status ?? status,
order: patch.order ?? order,
page: 1,
}),
);
}
const hasNext = pools.length >= PAGE_SIZE;
return (
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<CollapsibleSidebar>
<form
onSubmit={(event) => {
event.preventDefault();
update({ q: nameInput });
}}
className="flex flex-col gap-2"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Pool name
</span>
<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 font-mono")}
placeholder="Name contains…"
value={nameInput}
onChange={(event) => setDraft(event.target.value)}
/>
</div>
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Search
</Button>
<Button
type="button"
variant="secondary"
onClick={() => update({ q: "" })}
>
Clear
</Button>
</div>
</form>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Category
</span>
<select
className={inputClass}
value={category}
onChange={(event) => update({ category: event.target.value })}
>
<option value="">Any category</option>
<option value="series">Series</option>
<option value="collection">Collection</option>
</select>
</label>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Status
</span>
<select
className={inputClass}
value={status}
onChange={(event) => update({ status: event.target.value })}
>
<option value="">Any status</option>
<option value="true">Active</option>
<option value="false">Deleted</option>
</select>
</label>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Sort
</span>
<select
className={inputClass}
value={order}
onChange={(event) => update({ order: event.target.value })}
>
{orderOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</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">
{poolsQuery.data
? `${pools.length} pool${pools.length === 1 ? "" : "s"}`
: "…"}
</p>
<p className="font-mono text-xs text-ctp-overlay0">Page {page}</p>
</div>
{poolsQuery.isPending ? (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
) : poolsQuery.isError ? (
<EmptyState
title="Could not load pools from e621"
description={e621ErrorMessage(poolsQuery.error)}
/>
) : pools.length === 0 ? (
<EmptyState
title="No pools found"
description="Try a different name, category or status."
/>
) : (
<div
className={cn(
"grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3 transition-opacity",
poolsQuery.isPlaceholderData && "opacity-50",
)}
>
{pools.map((pool) => {
const cover = covers.get(pool.post_ids[0] ?? -1);
const coverBlacklisted = Boolean(
cover &&
blacklistSet.size > 0 &&
postIsBlacklisted(cover, blacklistSet).length > 0,
);
return (
<Link
key={pool.id}
to={`/pools/${pool.id}`}
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
>
<div className="relative aspect-[4/3] overflow-hidden bg-ctp-mantle">
{cover && cover.preview.url && !coverBlacklisted ? (
<img
src={cover.preview.url}
alt={pool.name}
loading="lazy"
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-ctp-overlay0">
<Layers className="h-5 w-5" />
<span className="text-[10px]">
{coverBlacklisted ? "Blacklisted cover" : "No cover"}
</span>
</div>
)}
<span
className={cn(
"absolute left-2 top-2 rounded-full border px-1.5 py-0.5 font-mono text-[10px] font-medium capitalize",
categoryBadge[pool.category] ??
"border-ctp-surface1 bg-ctp-base/80 text-ctp-subtext0",
)}
>
{pool.category}
</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">
{pool.post_count} posts
</span>
{!pool.is_active ? (
<span className="absolute bottom-2 left-2 flex items-center gap-1 rounded-full bg-ctp-red/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust">
<Archive className="h-3 w-3" /> deleted
</span>
) : null}
</div>
<div className="px-2.5 py-2">
<p
className="truncate font-mono text-xs text-ctp-subtext1"
title={pool.name}
>
{pool.name}
</p>
<p className="mt-0.5 truncate text-[11px] text-ctp-overlay0">
#{pool.id} · {pool.creator_name || "unknown"} · updated{" "}
{formatDate(pool.updated_at)}
</p>
</div>
</Link>
);
})}
</div>
)}
{page > 1 || hasNext ? (
<div className="mt-6 flex items-center justify-center gap-3">
<Button
variant="secondary"
disabled={page <= 1 || poolsQuery.isFetching}
onClick={() =>
setSearchParams(
buildParams({ q, category, status, order, page: page - 1 }),
)
}
>
Previous
</Button>
<span className="font-mono text-xs text-ctp-subtext0">
Page {page}
</span>
<Button
variant="secondary"
disabled={!hasNext || poolsQuery.isFetching}
onClick={() =>
setSearchParams(
buildParams({ q, category, status, order, page: page + 1 }),
)
}
>
Next
</Button>
</div>
) : null}
</section>
</div>
);
}
+62
View File
@@ -316,6 +316,68 @@ export async function fetchPostsByMd5(
return data.posts;
}
export interface E621Pool {
id: number;
name: string;
created_at: string;
updated_at: string;
creator_id: number;
creator_name: string;
description: string;
is_active: boolean;
category: "collection" | "series";
post_ids: number[];
post_count: number;
}
export type E621PoolOrder =
| "id_desc"
| "id_asc"
| "name"
| "created_at"
| "post_count";
export async function fetchPools(
credentials: E621Credentials,
options: {
name?: string;
category?: string;
isActive?: string;
order?: E621PoolOrder;
page?: number;
limit?: number;
signal?: AbortSignal;
} = {},
): Promise<E621Pool[]> {
const data = await e621Request<unknown>(credentials, "/pools.json", {
params: {
limit: options.limit ?? 48,
page: options.page,
"search[order]": options.order ?? "id_desc",
"search[name_matches]": options.name || undefined,
"search[category]": options.category || undefined,
"search[is_active]": options.isActive || undefined,
},
signal: options.signal,
});
// The spec says a bare array; tolerate a wrapped payload too.
if (Array.isArray(data)) return data as E621Pool[];
const wrapped = (data as { pools?: E621Pool[] })?.pools;
return Array.isArray(wrapped) ? wrapped : [];
}
export async function fetchPool(
credentials: E621Credentials,
poolId: number,
signal?: AbortSignal,
): Promise<E621Pool> {
const data = await e621Request<unknown>(credentials, `/pools/${poolId}.json`, {
signal,
});
const wrapped = (data as { pool?: E621Pool })?.pool;
return (wrapped ?? (data as E621Pool)) as E621Pool;
}
export interface E621IqdbResult {
post_id: number | null;
score: number | null;