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, ids: number[], chunk: number, signal?: AbortSignal, ): Promise { 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 = { 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(); 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 ( Back to pools } /> ); } if (poolQuery.isPending) { return (
); } if (poolQuery.isError || !pool) { return ( {e621ErrorMessage(poolQuery.error)}{" "} Back to pools } /> ); } const remaining = pool.post_ids.length - postIds.length; return (
Pools

{pool.name}

{pool.category} #{pool.id} {pool.post_count} posts by {pool.creator_name || `#${pool.creator_id}`} · updated {formatDate(pool.updated_at)} {!pool.is_active ? ( deleted ) : null}

{pool.description.trim() ? ( isMobile ? (
{renderDText(pool.description)}
) : (

Description

{renderDText(pool.description)}
) ) : null}

Posts{" "} {shownPosts.length} {hiddenCount > 0 ? ` of ${orderedPosts.length}` : ""}

{hiddenCount > 0 || showBlacklisted ? ( ) : null}
{postsQuery.isPending ? (
) : postsQuery.isError ? ( ) : shownPosts.length === 0 ? ( 0 ? "Everything here is blacklisted." : "This pool has no available posts." } /> ) : (
{shownPosts.map((post) => { const blacklisted = blacklistSet.size > 0 && postIsBlacklisted(post, blacklistSet).length > 0; if (!blacklisted) { return ( ); } return (
); })}
)} {remaining > 0 ? (
) : null}
); }