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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user