From 1adb761c8dcfc38c3ea5ac25a4aed061bc36710e Mon Sep 17 00:00:00 2001
From: JakeBreath
Date: Fri, 18 Sep 2026 19:06:18 -0500
Subject: [PATCH] Fix following past 48 entries and make the e621 page size
configurable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
ROADMAP.md | 4 +-
backend/apps/accounts/serializers.py | 2 +
.../apps/accounts/tests/test_preferences.py | 49 ++++++++++++
backend/apps/follows/tests/__init__.py | 0
.../apps/follows/tests/test_follows_list.py | 78 +++++++++++++++++++
backend/apps/follows/views.py | 11 ++-
.../src/features/account/PreferencesCard.tsx | 27 +++++++
.../src/features/follows/FollowedPage.tsx | 10 +--
.../src/features/follows/PoolFollowButton.tsx | 7 +-
.../src/features/follows/TagFollowToggle.tsx | 7 +-
frontend/src/features/online/OnlinePage.tsx | 36 ++++++++-
.../src/features/pools/PoolDetailPage.tsx | 17 ++--
frontend/src/lib/e621.ts | 2 +-
frontend/src/lib/preferences.ts | 4 +
frontend/src/lib/types.ts | 2 +
frontend/src/store/ui.ts | 6 ++
16 files changed, 237 insertions(+), 25 deletions(-)
create mode 100644 backend/apps/accounts/tests/test_preferences.py
create mode 100644 backend/apps/follows/tests/__init__.py
create mode 100644 backend/apps/follows/tests/test_follows_list.py
diff --git a/ROADMAP.md b/ROADMAP.md
index 3b63c88..ec6e099 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -145,7 +145,9 @@ Files now stage first and are resolved before entering the library.
- [x] Profile pictures: staff Users page and a self-service Account picker
(searchable library grid, remove supported) set avatars from library J-IDs
- [x] Profile extras: per-user landing page, default rating filter/sort, items
- per page and thumbnail size (synced to the account, applied on load)
+ per page and thumbnail size (synced to the account, applied on load),
+ plus e621 posts per page (48/100/200/320) for the Online browser and
+ pool loading
- [x] Backend-less local mode: with no backend connected the SPA runs on the
e621-facing pages only (Online, Pools) using credentials stored in the
browser, and the shell offers a "Setup Backend" button instead
diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py
index 6ec6208..9935d1c 100644
--- a/backend/apps/accounts/serializers.py
+++ b/backend/apps/accounts/serializers.py
@@ -134,5 +134,7 @@ class PreferencesSerializer(serializers.Serializer):
ordering = serializers.CharField(max_length=30, required=False)
per_page = serializers.IntegerField(min_value=12, max_value=200, required=False)
zoom = serializers.IntegerField(min_value=120, max_value=400, required=False)
+ # e621 renders at most 320 posts per request.
+ e621_per_page = serializers.IntegerField(min_value=12, max_value=320, required=False)
# Open Online with the order:hot metatag when no search is given.
online_hot_default = serializers.BooleanField(required=False)
diff --git a/backend/apps/accounts/tests/test_preferences.py b/backend/apps/accounts/tests/test_preferences.py
new file mode 100644
index 0000000..5c27ba9
--- /dev/null
+++ b/backend/apps/accounts/tests/test_preferences.py
@@ -0,0 +1,49 @@
+"""Per-user browse preferences: validation and merge semantics."""
+
+import json
+
+from django.contrib.auth import get_user_model
+from django.test import Client, TestCase
+
+from rest_framework.authtoken.models import Token
+
+User = get_user_model()
+
+
+class PreferenceTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="prefs-user", password="prefs-pass-123456"
+ )
+ self.client = Client()
+ self.client.defaults["HTTP_AUTHORIZATION"] = (
+ f"Token {Token.objects.create(user=self.user).key}"
+ )
+
+ def post(self, payload):
+ return self.client.post(
+ "/api/auth/preferences/",
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+
+ def test_e621_per_page_round_trips_within_range(self):
+ response = self.post({"e621_per_page": 320})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["e621_per_page"], 320)
+ self.assertEqual(
+ self.client.get("/api/auth/me/").json()["preferences"]["e621_per_page"],
+ 320,
+ )
+
+ def test_e621_per_page_rejects_out_of_range(self):
+ for value in (0, 8, 321, 1000):
+ self.assertEqual(self.post({"e621_per_page": value}).status_code, 400, value)
+
+ def test_merge_keeps_other_keys(self):
+ self.post({"ratings": ["s"], "e621_per_page": 100})
+ response = self.post({"per_page": 24})
+ data = response.json()
+ self.assertEqual(data["ratings"], ["s"])
+ self.assertEqual(data["e621_per_page"], 100)
+ self.assertEqual(data["per_page"], 24)
diff --git a/backend/apps/follows/tests/__init__.py b/backend/apps/follows/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/follows/tests/test_follows_list.py b/backend/apps/follows/tests/test_follows_list.py
new file mode 100644
index 0000000..8a6811a
--- /dev/null
+++ b/backend/apps/follows/tests/test_follows_list.py
@@ -0,0 +1,78 @@
+"""The follow lists are complete sets, not pages.
+
+The SPA uses `GET /api/follows/tags/` (and pools) for two things: deciding a
+follow toggle's state, and rendering every card on the Followed page. With the
+standard 48-item pagination that silently broke past 48 follows — a 49th tag
+could not be followed (the toggle never saw it) and the page hid it. These
+tests pin the unpaginated behaviour.
+"""
+
+from django.contrib.auth import get_user_model
+from django.test import Client, TestCase
+
+from rest_framework.authtoken.models import Token
+
+from apps.follows.models import FollowedPool, FollowedTag
+
+User = get_user_model()
+
+
+class FollowListTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="follow-user", password="follow-pass-123456"
+ )
+ self.other = User.objects.create_user(
+ username="follow-other", password="follow-pass-123456"
+ )
+ self.client = self.api_client(self.user)
+ self.other_client = self.api_client(self.other)
+
+ def api_client(self, user):
+ client = Client()
+ client.defaults["HTTP_AUTHORIZATION"] = (
+ f"Token {Token.objects.create(user=user).key}"
+ )
+ return client
+
+ def test_tag_list_is_not_paginated_past_48(self):
+ FollowedTag.objects.bulk_create(
+ [
+ FollowedTag(user=self.user, tag=f"tag_{index:03d}")
+ for index in range(60)
+ ]
+ )
+ response = self.client.get("/api/follows/tags/")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertIsInstance(data, list, "the list must not be paginated")
+ self.assertEqual(len(data), 60)
+ self.assertEqual(data[0]["tag"], "tag_000")
+ self.assertEqual(data[-1]["tag"], "tag_059")
+ # The shape the SPA uses for its follow state.
+ self.assertIn("unseen_count", data[0])
+ self.assertIn("id", data[0])
+
+ def test_pool_list_is_not_paginated(self):
+ FollowedPool.objects.bulk_create(
+ [
+ FollowedPool(user=self.user, pool_id=59000 + index, name=f"pool {index}")
+ for index in range(55)
+ ]
+ )
+ data = self.client.get("/api/follows/pools/").json()
+ self.assertIsInstance(data, list)
+ self.assertEqual(len(data), 55)
+
+ def test_lists_only_contain_the_callers_follows(self):
+ FollowedTag.objects.create(user=self.user, tag="mine")
+ FollowedTag.objects.create(user=self.other, tag="theirs")
+
+ mine = self.client.get("/api/follows/tags/").json()
+ theirs = self.other_client.get("/api/follows/tags/").json()
+ self.assertEqual([row["tag"] for row in mine], ["mine"])
+ self.assertEqual([row["tag"] for row in theirs], ["theirs"])
+
+ def test_anonymous_cannot_list(self):
+ self.assertEqual(Client().get("/api/follows/tags/").status_code, 401)
+ self.assertEqual(Client().get("/api/follows/pools/").status_code, 401)
diff --git a/backend/apps/follows/views.py b/backend/apps/follows/views.py
index 6ec0299..adf5573 100644
--- a/backend/apps/follows/views.py
+++ b/backend/apps/follows/views.py
@@ -36,10 +36,16 @@ class FollowedTagViewSet(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
- """Tags the current user follows."""
+ """Tags the current user follows.
+
+ Deliberately unpaginated: the SPA treats this as the complete set (follow
+ toggles read their state from it and the Followed page lists every card),
+ so a 48-item page silently broke following past 48 entries.
+ """
serializer_class = FollowedTagSerializer
permission_classes = [IsAuthenticated]
+ pagination_class = None
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
@@ -112,10 +118,11 @@ class FollowedPoolViewSet(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
- """Pools the current user follows."""
+ """Pools the current user follows (unpaginated, like the tag list)."""
serializer_class = FollowedPoolSerializer
permission_classes = [IsAuthenticated]
+ pagination_class = None
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
diff --git a/frontend/src/features/account/PreferencesCard.tsx b/frontend/src/features/account/PreferencesCard.tsx
index c070bd6..dced50a 100644
--- a/frontend/src/features/account/PreferencesCard.tsx
+++ b/frontend/src/features/account/PreferencesCard.tsx
@@ -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; 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() {
))}
+
+
+
+
+
+ e621 posts per page drives the Online browser and how many pool posts
+ load per request; e621 caps a request at 320.
+
+
+
+
Tags on this page
diff --git a/frontend/src/features/pools/PoolDetailPage.tsx b/frontend/src/features/pools/PoolDetailPage.tsx
index ad82427..504cfd3 100644
--- a/frontend/src/features/pools/PoolDetailPage.tsx
+++ b/frontend/src/features/pools/PoolDetailPage.tsx
@@ -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,
ids: number[],
+ chunk: 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)));
+ 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() {