Files
J621/frontend/src/features/pools/PoolDetailPage.tsx
T
JakeBreath 1adb761c8d Fix following past 48 entries and make the e621 page size configurable
Follow lists were paginated at the API default of 48, but the SPA treats
them as complete sets: the tag/pool toggles read their state from page one
(so the 49th follow looked unfollowed and its spinner waited for a page that
could never contain it) and the Followed page rendered only 48 cards while
showing that as the count. Both follow endpoints are now unpaginated — they
are per-user sets and still restricted to the caller's rows — and the three
consumers take plain arrays.

Post visibility: the old J621-Django online view fetched limit=320 (e621's
maximum) while ours hard-coded 48, and fetchPostsByIds capped id batches at
100. The Online browser now has a 'Posts per page' setting (48/100/200/320)
in its sidebar, mirrored in Account -> Browsing preferences, stored per user
as e621_per_page and also used for pool loading; the id-batch cap is raised
to 320.

Tests: follow list shape/isolation (4) and preference validation/merge (3)
added; the full backend suite is 46 green. Live-checked the array response
shape and the preference bounds (200 accepted, 500 rejected).
2026-09-18 19:06:18 -05:00

339 lines
11 KiB
TypeScript

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 { BottomSheet } from "@/components/BottomSheet";
import { PostCard } from "@/features/online/PostCard";
import { PoolFollowButton } from "@/features/follows/PoolFollowButton";
import { api } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
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 { useIsMobile } from "@/lib/useMediaQuery";
import { useE621 } from "@/store/e621";
import { useUi } from "@/store/ui";
const CHUNK_FALLBACK = 100;
async function fetchPostsChunked(
credentials: ReturnType<typeof effectiveCredentials>,
ids: number[],
chunk: number,
signal?: AbortSignal,
): Promise<E621Post[]> {
const posts: E621Post[] = [];
for (let index = 0; index < ids.length; index += chunk) {
const slice = ids.slice(index, index + chunk);
posts.push(...(await fetchPostsByIds(credentials, slice, 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 isMobile = useIsMobile();
const zoom = useUi((state) => state.zoom);
// Pool posts load in chunks of the user's e621 page size (max 320).
const e621PerPage = useUi((state) => state.e621PerPage);
const chunk = Math.min(320, Math.max(24, e621PerPage || CHUNK_FALLBACK));
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,
chunk,
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: hasBackend() && 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() ? (
isMobile ? (
<BottomSheet title="Description">
<div className="text-sm leading-relaxed text-ctp-subtext0">
{renderDText(pool.description)}
</div>
</BottomSheet>
) : (
<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 gap-3"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${zoom}px, 1fr))`,
}}
>
{shownPosts.map((post) => {
const blacklisted =
blacklistSet.size > 0 &&
postIsBlacklisted(post, blacklistSet).length > 0;
if (!blacklisted) {
return (
<PostCard
key={post.id}
post={post}
inLibrary={inLibrary.has(post.file.md5)}
from={`${location.pathname}${location.search}`}
/>
);
}
return (
<div
key={post.id}
className="min-w-0 rounded-lg ring-1 ring-ctp-red/50"
title="Matches your blacklist"
>
<PostCard
post={post}
inLibrary={inLibrary.has(post.file.md5)}
from={`${location.pathname}${location.search}`}
/>
</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>
);
}