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).
This commit is contained in:
2026-09-18 19:06:18 -05:00
parent d16a77907a
commit 1adb761c8d
16 changed files with 237 additions and 25 deletions
@@ -9,6 +9,8 @@ import type { UserPreferences } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320];
const LANDING_OPTIONS: { value: NonNullable<UserPreferences["landing_page"]>; label: string }[] = [
{ value: "library", label: "Library" },
{ value: "online", label: "Online" },
@@ -26,6 +28,7 @@ export function PreferencesCard() {
per_page: user?.preferences?.per_page ?? 48,
zoom: user?.preferences?.zoom ?? 190,
online_hot_default: user?.preferences?.online_hot_default ?? true,
e621_per_page: user?.preferences?.e621_per_page ?? 48,
});
const [saving, setSaving] = useState(false);
@@ -149,8 +152,32 @@ export function PreferencesCard() {
))}
</select>
</Field>
<Field label="e621 posts per page">
<select
className={inputClass}
value={values.e621_per_page ?? 48}
onChange={(event) =>
setValues((current) => ({
...current,
e621_per_page: Number(event.target.value),
}))
}
>
{E621_PER_PAGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
</div>
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
e621 posts per page drives the Online browser and how many pool posts
load per request; e621 caps a request at 320.
</p>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Thumbnail size
@@ -13,7 +13,6 @@ import type {
FollowedPool,
FollowedTag,
FollowFeed,
Paginated,
} from "@/lib/types";
const ratingPill: Record<string, string> = {
@@ -207,11 +206,12 @@ export default function FollowedPage() {
const tagsQuery = useQuery({
queryKey: ["follows-tags"],
queryFn: () => api<Paginated<FollowedTag>>("/api/follows/tags/"),
// Unpaginated: this is the complete follow list, shown as cards below.
queryFn: () => api<FollowedTag[]>("/api/follows/tags/"),
});
const poolsQuery = useQuery({
queryKey: ["follows-pools"],
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
queryFn: () => api<FollowedPool[]>("/api/follows/pools/"),
});
const feedQuery = useQuery({
@@ -269,8 +269,8 @@ export default function FollowedPage() {
);
}
const tags = tagsQuery.data?.results ?? [];
const pools = poolsQuery.data?.results ?? [];
const tags = tagsQuery.data ?? [];
const pools = poolsQuery.data ?? [];
const feed = feedQuery.data;
return (
@@ -5,7 +5,7 @@ import { useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import type { FollowedPool, Paginated } from "@/lib/types";
import type { FollowedPool } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
@@ -23,12 +23,13 @@ export function PoolFollowButton({
const query = useQuery({
queryKey: ["follows-pools"],
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
// Unpaginated on purpose: the button must see every follow, not page one.
queryFn: () => api<FollowedPool[]>("/api/follows/pools/"),
enabled: Boolean(user),
staleTime: 30_000,
});
const follow = query.data?.results.find((entry) => entry.pool_id === poolId);
const follow = query.data?.find((entry) => entry.pool_id === poolId);
const followed = Boolean(follow);
const mutation = useMutation({
@@ -4,7 +4,7 @@ import { useState, type MouseEvent } from "react";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { FollowedTag, Paginated } from "@/lib/types";
import type { FollowedTag } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
@@ -16,13 +16,14 @@ export function TagFollowToggle({ tag }: { tag: string }) {
const query = useQuery({
queryKey: ["follows-tags"],
queryFn: () => api<Paginated<FollowedTag>>("/api/follows/tags/"),
// Unpaginated on purpose: the toggle must see every follow, not page one.
queryFn: () => api<FollowedTag[]>("/api/follows/tags/"),
enabled: Boolean(user),
staleTime: 30_000,
});
const normalized = tag.trim().toLowerCase();
const follow = query.data?.results.find((entry) => entry.tag === normalized);
const follow = query.data?.find((entry) => entry.tag === normalized);
const followed = Boolean(follow);
const mutation = useMutation({
+32 -4
View File
@@ -18,13 +18,14 @@ import {
type E621Post,
} from "@/lib/e621";
import { hotDefaultAction, HOT_ORDER } from "@/features/online/hotDefault";
import { schedulePreferenceSave } from "@/lib/preferences";
import { useE621 } from "@/store/e621";
import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui";
import { toast } from "@/store/toasts";
import { hasBackend } from "@/lib/backend";
const PAGE_SIZE = 48;
const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320];
const EMPTY_POSTS: E621Post[] = [];
export default function OnlinePage() {
@@ -44,6 +45,8 @@ export default function OnlinePage() {
const [blacklistDraft, setBlacklistDraft] = useState("");
const tagInput = draft ?? tags;
const zoom = useUi((state) => state.zoom);
const perPage = useUi((state) => state.e621PerPage);
const setE621PerPage = useUi((state) => state.setE621PerPage);
const appliedHotDefault = useRef(false);
// Landing on Online follows the account's order:hot default: applied when
@@ -100,12 +103,12 @@ export default function OnlinePage() {
}
const postsQuery = useQuery({
queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
queryKey: ["e621", "posts", tags, page, perPage, credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchPosts(effectiveCredentials(credentials), {
tags,
page,
limit: PAGE_SIZE,
limit: perPage,
signal,
}),
placeholderData: keepPreviousData,
@@ -166,7 +169,7 @@ export default function OnlinePage() {
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40);
}, [visiblePosts]);
const hasNext = posts.length >= PAGE_SIZE;
const hasNext = posts.length >= perPage;
function buildParams(tagValue: string, pageValue: number): URLSearchParams {
const params = new URLSearchParams();
@@ -223,6 +226,31 @@ export default function OnlinePage() {
</p>
</form>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Posts per page
</span>
<select
className={inputClass}
value={perPage}
onChange={(event) => {
const value = Number(event.target.value);
setE621PerPage(value);
schedulePreferenceSave({ e621_per_page: value });
setSearchParams(buildParams(tags, 1), { replace: true });
}}
>
{E621_PER_PAGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<span className="text-[11px] leading-relaxed text-ctp-overlay0">
e621 returns at most 320 posts per request.
</span>
</label>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Tags on this page
+11 -6
View File
@@ -25,17 +25,18 @@ import { useIsMobile } from "@/lib/useMediaQuery";
import { useE621 } from "@/store/e621";
import { useUi } from "@/store/ui";
const CHUNK = 100;
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 chunk = ids.slice(index, index + CHUNK);
posts.push(...(await fetchPostsByIds(credentials, chunk, signal)));
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;
}
@@ -54,8 +55,11 @@ export default function PoolDetailPage() {
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 [visibleCount, setVisibleCount] = useState(chunk);
const [showBlacklisted, setShowBlacklisted] = useState(false);
const poolQuery = useQuery({
@@ -84,6 +88,7 @@ export default function PoolDetailPage() {
fetchPostsChunked(
effectiveCredentials(credentials),
postIds,
chunk,
signal,
),
enabled: postIds.length > 0,
@@ -319,7 +324,7 @@ export default function PoolDetailPage() {
<Button
variant="secondary"
disabled={postsQuery.isFetching}
onClick={() => setVisibleCount((count) => count + CHUNK)}
onClick={() => setVisibleCount((count) => count + chunk)}
>
{postsQuery.isFetching
? "Loading…"
+1 -1
View File
@@ -255,7 +255,7 @@ export async function fetchPostsByIds(
credentials,
"/posts.json",
{
params: { tags: `id:${ids.join(",")}`, limit: Math.min(ids.length, 100) },
params: { tags: `id:${ids.join(",")}`, limit: Math.min(ids.length, 320) },
signal,
},
);
+4
View File
@@ -12,6 +12,9 @@ export function applyPreferences(
if (typeof preferences.ordering === "string") ui.setOrdering(preferences.ordering);
if (typeof preferences.per_page === "number") ui.setPerPage(preferences.per_page);
if (typeof preferences.zoom === "number") ui.setZoom(preferences.zoom);
if (typeof preferences.e621_per_page === "number") {
ui.setE621PerPage(preferences.e621_per_page);
}
}
/** The browse settings currently in effect, as a preferences payload. */
@@ -22,6 +25,7 @@ export function preferencesPayload(extra?: UserPreferences): UserPreferences {
ordering: ui.ordering,
per_page: ui.perPage,
zoom: ui.zoom,
e621_per_page: ui.e621PerPage,
...extra,
};
}
+2
View File
@@ -33,6 +33,8 @@ export interface UserPreferences {
per_page?: number;
zoom?: number;
online_hot_default?: boolean;
/** Posts per e621 request for the Online browser and pool loading (max 320). */
e621_per_page?: number;
}
export interface User {
+6
View File
@@ -16,6 +16,9 @@ interface UiState {
/** Minimum thumbnail width in px; media grids derive their column count. */
zoom: number;
setZoom: (zoom: number) => void;
/** Posts per e621 request for the Online browser and pool loading (max 320). */
e621PerPage: number;
setE621PerPage: (value: number) => void;
}
export const useUi = create<UiState>()(
@@ -39,6 +42,9 @@ export const useUi = create<UiState>()(
zoom: 190,
setZoom: (zoom) => set({ zoom }),
e621PerPage: 48,
setE621PerPage: (e621PerPage) => set({ e621PerPage }),
}),
{ name: "j621.ui" },
),