diff --git a/ROADMAP.md b/ROADMAP.md index 6701e73..f9f2d2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,6 +55,13 @@ they land. - [x] "Followed" nav entry - [x] Periodic sync commands: `sync_followed_tags` and `sync_followed_pools` (one e621 search per unique tag/pool) +- [x] **Pools browser** (`/pools` + `/pools/`) + - [x] Index search by name; category, active/deleted and sort filters; + pagination according to the e621 OpenAPI spec + - [x] Cover thumbnails from each pool's first post (one batched post call, + blacklist-aware) and deleted-pool markers + - [x] Pool detail: DText description, posts kept in pool order with chunked + loading and in-library badges, blacklist reveal toggle, Follow button - [ ] `F` shortcut to favorite a post, tag finder in the Ctrl+K palette ## 3. Uploads & ingestion pipeline diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e27eb42..6d7d5a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> state.user); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + // What the button should show once the request and refetch settle. + const [target, setTarget] = useState(null); + + const query = useQuery({ + queryKey: ["follows-pools"], + queryFn: () => api>("/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("/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 ( +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/frontend/src/features/pools/PoolDetailPage.tsx b/frontend/src/features/pools/PoolDetailPage.tsx new file mode 100644 index 0000000..1f84dba --- /dev/null +++ b/frontend/src/features/pools/PoolDetailPage.tsx @@ -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, + ids: number[], + signal?: AbortSignal, +): Promise { + 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 = { + 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(); + 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 ( + + 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() ? ( +
+

+ 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; + const card = ( + + ); + return blacklisted ? ( +
+ {card} +
+ ) : ( +
{card}
+ ); + })} +
+ )} + + {remaining > 0 ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/features/pools/PoolsPage.tsx b/frontend/src/features/pools/PoolsPage.tsx new file mode 100644 index 0000000..94d8af1 --- /dev/null +++ b/frontend/src/features/pools/PoolsPage.tsx @@ -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 = { + 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(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(); + 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 ( +
+ +
{ + event.preventDefault(); + update({ q: nameInput }); + }} + className="flex flex-col gap-2" + > + + Pool name + +
+ + setDraft(event.target.value)} + /> +
+
+ + +
+
+ + + + + + +
+ +
+
+

+ {poolsQuery.data + ? `${pools.length} pool${pools.length === 1 ? "" : "s"}` + : "…"} +

+

Page {page}

+
+ + {poolsQuery.isPending ? ( +
+ +
+ ) : poolsQuery.isError ? ( + + ) : pools.length === 0 ? ( + + ) : ( +
+ {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 ( + +
+ {cover && cover.preview.url && !coverBlacklisted ? ( + {pool.name} + ) : ( +
+ + + {coverBlacklisted ? "Blacklisted cover" : "No cover"} + +
+ )} + + + {pool.category} + + + + {pool.post_count} posts + + + {!pool.is_active ? ( + + deleted + + ) : null} +
+ +
+

+ {pool.name} +

+

+ #{pool.id} · {pool.creator_name || "unknown"} · updated{" "} + {formatDate(pool.updated_at)} +

+
+ + ); + })} +
+ )} + + {page > 1 || hasNext ? ( +
+ + + Page {page} + + +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/lib/e621.ts b/frontend/src/lib/e621.ts index a0eef1c..41e5e8a 100644 --- a/frontend/src/lib/e621.ts +++ b/frontend/src/lib/e621.ts @@ -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 { + const data = await e621Request(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 { + const data = await e621Request(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;