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:
@@ -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/<id>`)
|
||||
- [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
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/view/:md5" element={<Md5Redirect />} />
|
||||
<Route path="/online" element={<OnlinePage />} />
|
||||
<Route path="/online/view/:postId" element={<PostRedirect />} />
|
||||
<Route path="/pools" element={<PoolsPage />} />
|
||||
<Route path="/pools/:poolId" element={<PoolDetailPage />} />
|
||||
<Route
|
||||
path="/followed"
|
||||
element={
|
||||
|
||||
@@ -60,6 +60,7 @@ export function AppShell() {
|
||||
const navItems = [
|
||||
{ to: "/", label: "Library" },
|
||||
{ to: "/online", label: "Online" },
|
||||
{ to: "/pools", label: "Pools" },
|
||||
...(user ? [{ to: "/followed", label: "Followed" }] : []),
|
||||
...(user?.can_upload
|
||||
? [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Star } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import type { FollowedPool, Paginated } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
export function PoolFollowButton({
|
||||
poolId,
|
||||
name,
|
||||
}: {
|
||||
poolId: number;
|
||||
name?: string;
|
||||
}) {
|
||||
const user = useAuth((state) => state.user);
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// What the button should show once the request and refetch settle.
|
||||
const [target, setTarget] = useState<boolean | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["follows-pools"],
|
||||
queryFn: () => api<Paginated<FollowedPool>>("/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<FollowedPool>("/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 (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
variant={followed ? "secondary" : "primary"}
|
||||
disabled={busy || query.isPending}
|
||||
onClick={() => {
|
||||
if (busy) return;
|
||||
setTarget(!followed);
|
||||
mutation.mutate();
|
||||
}}
|
||||
title={followed ? `Unfollow pool ${label}` : `Follow pool ${label}`}
|
||||
>
|
||||
{busy ? (
|
||||
<Spinner className="h-3.5 w-3.5" />
|
||||
) : followed ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Star className="h-4 w-4" />
|
||||
)}
|
||||
{followed ? "Following" : "Follow pool"}
|
||||
</Button>
|
||||
{error ? <p className="text-xs text-ctp-red">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<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",
|
||||
};
|
||||
|
||||
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<string | null>(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<number, E621Post>();
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<CollapsibleSidebar>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
update({ q: nameInput });
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Pool name
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ctp-overlay0" />
|
||||
<input
|
||||
className={cn(inputClass, "pl-8 font-mono")}
|
||||
placeholder="Name contains…"
|
||||
value={nameInput}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
Search
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => update({ q: "" })}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Category
|
||||
</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={category}
|
||||
onChange={(event) => update({ category: event.target.value })}
|
||||
>
|
||||
<option value="">Any category</option>
|
||||
<option value="series">Series</option>
|
||||
<option value="collection">Collection</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Status
|
||||
</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={status}
|
||||
onChange={(event) => update({ status: event.target.value })}
|
||||
>
|
||||
<option value="">Any status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Deleted</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Sort
|
||||
</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={order}
|
||||
onChange={(event) => update({ order: event.target.value })}
|
||||
>
|
||||
{orderOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</CollapsibleSidebar>
|
||||
|
||||
<section className="min-w-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-ctp-overlay0">
|
||||
{poolsQuery.data
|
||||
? `${pools.length} pool${pools.length === 1 ? "" : "s"}`
|
||||
: "…"}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-ctp-overlay0">Page {page}</p>
|
||||
</div>
|
||||
|
||||
{poolsQuery.isPending ? (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : poolsQuery.isError ? (
|
||||
<EmptyState
|
||||
title="Could not load pools from e621"
|
||||
description={e621ErrorMessage(poolsQuery.error)}
|
||||
/>
|
||||
) : pools.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No pools found"
|
||||
description="Try a different name, category or status."
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3 transition-opacity",
|
||||
poolsQuery.isPlaceholderData && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{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 (
|
||||
<Link
|
||||
key={pool.id}
|
||||
to={`/pools/${pool.id}`}
|
||||
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-ctp-mantle">
|
||||
{cover && cover.preview.url && !coverBlacklisted ? (
|
||||
<img
|
||||
src={cover.preview.url}
|
||||
alt={pool.name}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-ctp-overlay0">
|
||||
<Layers className="h-5 w-5" />
|
||||
<span className="text-[10px]">
|
||||
{coverBlacklisted ? "Blacklisted cover" : "No cover"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-2 top-2 rounded-full border px-1.5 py-0.5 font-mono text-[10px] font-medium capitalize",
|
||||
categoryBadge[pool.category] ??
|
||||
"border-ctp-surface1 bg-ctp-base/80 text-ctp-subtext0",
|
||||
)}
|
||||
>
|
||||
{pool.category}
|
||||
</span>
|
||||
|
||||
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] uppercase text-ctp-overlay1">
|
||||
{pool.post_count} posts
|
||||
</span>
|
||||
|
||||
{!pool.is_active ? (
|
||||
<span className="absolute bottom-2 left-2 flex items-center gap-1 rounded-full bg-ctp-red/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust">
|
||||
<Archive className="h-3 w-3" /> deleted
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="px-2.5 py-2">
|
||||
<p
|
||||
className="truncate font-mono text-xs text-ctp-subtext1"
|
||||
title={pool.name}
|
||||
>
|
||||
{pool.name}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-ctp-overlay0">
|
||||
#{pool.id} · {pool.creator_name || "unknown"} · updated{" "}
|
||||
{formatDate(pool.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{page > 1 || hasNext ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1 || poolsQuery.isFetching}
|
||||
onClick={() =>
|
||||
setSearchParams(
|
||||
buildParams({ q, category, status, order, page: page - 1 }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="font-mono text-xs text-ctp-subtext0">
|
||||
Page {page}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!hasNext || poolsQuery.isFetching}
|
||||
onClick={() =>
|
||||
setSearchParams(
|
||||
buildParams({ q, category, status, order, page: page + 1 }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<E621Pool[]> {
|
||||
const data = await e621Request<unknown>(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<E621Pool> {
|
||||
const data = await e621Request<unknown>(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;
|
||||
|
||||
Reference in New Issue
Block a user