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).
339 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|