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:
+3
-1
@@ -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
|
- [x] Profile pictures: staff Users page and a self-service Account picker
|
||||||
(searchable library grid, remove supported) set avatars from library J-IDs
|
(searchable library grid, remove supported) set avatars from library J-IDs
|
||||||
- [x] Profile extras: per-user landing page, default rating filter/sort, items
|
- [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
|
- [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
|
e621-facing pages only (Online, Pools) using credentials stored in the
|
||||||
browser, and the shell offers a "Setup Backend" button instead
|
browser, and the shell offers a "Setup Backend" button instead
|
||||||
|
|||||||
@@ -134,5 +134,7 @@ class PreferencesSerializer(serializers.Serializer):
|
|||||||
ordering = serializers.CharField(max_length=30, required=False)
|
ordering = serializers.CharField(max_length=30, required=False)
|
||||||
per_page = serializers.IntegerField(min_value=12, max_value=200, 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)
|
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.
|
# Open Online with the order:hot metatag when no search is given.
|
||||||
online_hot_default = serializers.BooleanField(required=False)
|
online_hot_default = serializers.BooleanField(required=False)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -36,10 +36,16 @@ class FollowedTagViewSet(
|
|||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
viewsets.GenericViewSet,
|
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
|
serializer_class = FollowedTagSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
pagination_class = None
|
||||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -112,10 +118,11 @@ class FollowedPoolViewSet(
|
|||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
):
|
):
|
||||||
"""Pools the current user follows."""
|
"""Pools the current user follows (unpaginated, like the tag list)."""
|
||||||
|
|
||||||
serializer_class = FollowedPoolSerializer
|
serializer_class = FollowedPoolSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
pagination_class = None
|
||||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { UserPreferences } from "@/lib/types";
|
|||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
|
const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320];
|
||||||
|
|
||||||
const LANDING_OPTIONS: { value: NonNullable<UserPreferences["landing_page"]>; label: string }[] = [
|
const LANDING_OPTIONS: { value: NonNullable<UserPreferences["landing_page"]>; label: string }[] = [
|
||||||
{ value: "library", label: "Library" },
|
{ value: "library", label: "Library" },
|
||||||
{ value: "online", label: "Online" },
|
{ value: "online", label: "Online" },
|
||||||
@@ -26,6 +28,7 @@ export function PreferencesCard() {
|
|||||||
per_page: user?.preferences?.per_page ?? 48,
|
per_page: user?.preferences?.per_page ?? 48,
|
||||||
zoom: user?.preferences?.zoom ?? 190,
|
zoom: user?.preferences?.zoom ?? 190,
|
||||||
online_hot_default: user?.preferences?.online_hot_default ?? true,
|
online_hot_default: user?.preferences?.online_hot_default ?? true,
|
||||||
|
e621_per_page: user?.preferences?.e621_per_page ?? 48,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -149,8 +152,32 @@ export function PreferencesCard() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</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>
|
</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">
|
<label className="flex flex-col gap-1.5">
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
Thumbnail size
|
Thumbnail size
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
FollowedPool,
|
FollowedPool,
|
||||||
FollowedTag,
|
FollowedTag,
|
||||||
FollowFeed,
|
FollowFeed,
|
||||||
Paginated,
|
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
const ratingPill: Record<string, string> = {
|
const ratingPill: Record<string, string> = {
|
||||||
@@ -207,11 +206,12 @@ export default function FollowedPage() {
|
|||||||
|
|
||||||
const tagsQuery = useQuery({
|
const tagsQuery = useQuery({
|
||||||
queryKey: ["follows-tags"],
|
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({
|
const poolsQuery = useQuery({
|
||||||
queryKey: ["follows-pools"],
|
queryKey: ["follows-pools"],
|
||||||
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
|
queryFn: () => api<FollowedPool[]>("/api/follows/pools/"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const feedQuery = useQuery({
|
const feedQuery = useQuery({
|
||||||
@@ -269,8 +269,8 @@ export default function FollowedPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = tagsQuery.data?.results ?? [];
|
const tags = tagsQuery.data ?? [];
|
||||||
const pools = poolsQuery.data?.results ?? [];
|
const pools = poolsQuery.data ?? [];
|
||||||
const feed = feedQuery.data;
|
const feed = feedQuery.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
|||||||
import { Button, Spinner } from "@/components/ui";
|
import { Button, Spinner } from "@/components/ui";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, errorMessage } from "@/lib/api";
|
||||||
import { hasBackend } from "@/lib/backend";
|
import { hasBackend } from "@/lib/backend";
|
||||||
import type { FollowedPool, Paginated } from "@/lib/types";
|
import type { FollowedPool } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
@@ -23,12 +23,13 @@ export function PoolFollowButton({
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["follows-pools"],
|
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),
|
enabled: Boolean(user),
|
||||||
staleTime: 30_000,
|
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 followed = Boolean(follow);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, type MouseEvent } from "react";
|
|||||||
|
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import type { FollowedTag, Paginated } from "@/lib/types";
|
import type { FollowedTag } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
@@ -16,13 +16,14 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["follows-tags"],
|
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),
|
enabled: Boolean(user),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalized = tag.trim().toLowerCase();
|
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 followed = Boolean(follow);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import {
|
|||||||
type E621Post,
|
type E621Post,
|
||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
import { hotDefaultAction, HOT_ORDER } from "@/features/online/hotDefault";
|
import { hotDefaultAction, HOT_ORDER } from "@/features/online/hotDefault";
|
||||||
|
import { schedulePreferenceSave } from "@/lib/preferences";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { useUi } from "@/store/ui";
|
import { useUi } from "@/store/ui";
|
||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
import { hasBackend } from "@/lib/backend";
|
import { hasBackend } from "@/lib/backend";
|
||||||
|
|
||||||
const PAGE_SIZE = 48;
|
const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320];
|
||||||
const EMPTY_POSTS: E621Post[] = [];
|
const EMPTY_POSTS: E621Post[] = [];
|
||||||
|
|
||||||
export default function OnlinePage() {
|
export default function OnlinePage() {
|
||||||
@@ -44,6 +45,8 @@ export default function OnlinePage() {
|
|||||||
const [blacklistDraft, setBlacklistDraft] = useState("");
|
const [blacklistDraft, setBlacklistDraft] = useState("");
|
||||||
const tagInput = draft ?? tags;
|
const tagInput = draft ?? tags;
|
||||||
const zoom = useUi((state) => state.zoom);
|
const zoom = useUi((state) => state.zoom);
|
||||||
|
const perPage = useUi((state) => state.e621PerPage);
|
||||||
|
const setE621PerPage = useUi((state) => state.setE621PerPage);
|
||||||
const appliedHotDefault = useRef(false);
|
const appliedHotDefault = useRef(false);
|
||||||
|
|
||||||
// Landing on Online follows the account's order:hot default: applied when
|
// Landing on Online follows the account's order:hot default: applied when
|
||||||
@@ -100,12 +103,12 @@ export default function OnlinePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const postsQuery = useQuery({
|
const postsQuery = useQuery({
|
||||||
queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
|
queryKey: ["e621", "posts", tags, page, perPage, credentials?.username ?? ""],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
fetchPosts(effectiveCredentials(credentials), {
|
fetchPosts(effectiveCredentials(credentials), {
|
||||||
tags,
|
tags,
|
||||||
page,
|
page,
|
||||||
limit: PAGE_SIZE,
|
limit: perPage,
|
||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
@@ -166,7 +169,7 @@ export default function OnlinePage() {
|
|||||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40);
|
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40);
|
||||||
}, [visiblePosts]);
|
}, [visiblePosts]);
|
||||||
|
|
||||||
const hasNext = posts.length >= PAGE_SIZE;
|
const hasNext = posts.length >= perPage;
|
||||||
|
|
||||||
function buildParams(tagValue: string, pageValue: number): URLSearchParams {
|
function buildParams(tagValue: string, pageValue: number): URLSearchParams {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -223,6 +226,31 @@ export default function OnlinePage() {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
Tags on this page
|
Tags on this page
|
||||||
|
|||||||
@@ -25,17 +25,18 @@ import { useIsMobile } from "@/lib/useMediaQuery";
|
|||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
import { useUi } from "@/store/ui";
|
import { useUi } from "@/store/ui";
|
||||||
|
|
||||||
const CHUNK = 100;
|
const CHUNK_FALLBACK = 100;
|
||||||
|
|
||||||
async function fetchPostsChunked(
|
async function fetchPostsChunked(
|
||||||
credentials: ReturnType<typeof effectiveCredentials>,
|
credentials: ReturnType<typeof effectiveCredentials>,
|
||||||
ids: number[],
|
ids: number[],
|
||||||
|
chunk: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<E621Post[]> {
|
): Promise<E621Post[]> {
|
||||||
const posts: E621Post[] = [];
|
const posts: E621Post[] = [];
|
||||||
for (let index = 0; index < ids.length; index += CHUNK) {
|
for (let index = 0; index < ids.length; index += chunk) {
|
||||||
const chunk = ids.slice(index, index + CHUNK);
|
const slice = ids.slice(index, index + chunk);
|
||||||
posts.push(...(await fetchPostsByIds(credentials, chunk, signal)));
|
posts.push(...(await fetchPostsByIds(credentials, slice, signal)));
|
||||||
}
|
}
|
||||||
return posts;
|
return posts;
|
||||||
}
|
}
|
||||||
@@ -54,8 +55,11 @@ export default function PoolDetailPage() {
|
|||||||
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
|
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const zoom = useUi((state) => state.zoom);
|
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 [showBlacklisted, setShowBlacklisted] = useState(false);
|
||||||
|
|
||||||
const poolQuery = useQuery({
|
const poolQuery = useQuery({
|
||||||
@@ -84,6 +88,7 @@ export default function PoolDetailPage() {
|
|||||||
fetchPostsChunked(
|
fetchPostsChunked(
|
||||||
effectiveCredentials(credentials),
|
effectiveCredentials(credentials),
|
||||||
postIds,
|
postIds,
|
||||||
|
chunk,
|
||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
enabled: postIds.length > 0,
|
enabled: postIds.length > 0,
|
||||||
@@ -319,7 +324,7 @@ export default function PoolDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={postsQuery.isFetching}
|
disabled={postsQuery.isFetching}
|
||||||
onClick={() => setVisibleCount((count) => count + CHUNK)}
|
onClick={() => setVisibleCount((count) => count + chunk)}
|
||||||
>
|
>
|
||||||
{postsQuery.isFetching
|
{postsQuery.isFetching
|
||||||
? "Loading…"
|
? "Loading…"
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export async function fetchPostsByIds(
|
|||||||
credentials,
|
credentials,
|
||||||
"/posts.json",
|
"/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,
|
signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export function applyPreferences(
|
|||||||
if (typeof preferences.ordering === "string") ui.setOrdering(preferences.ordering);
|
if (typeof preferences.ordering === "string") ui.setOrdering(preferences.ordering);
|
||||||
if (typeof preferences.per_page === "number") ui.setPerPage(preferences.per_page);
|
if (typeof preferences.per_page === "number") ui.setPerPage(preferences.per_page);
|
||||||
if (typeof preferences.zoom === "number") ui.setZoom(preferences.zoom);
|
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. */
|
/** The browse settings currently in effect, as a preferences payload. */
|
||||||
@@ -22,6 +25,7 @@ export function preferencesPayload(extra?: UserPreferences): UserPreferences {
|
|||||||
ordering: ui.ordering,
|
ordering: ui.ordering,
|
||||||
per_page: ui.perPage,
|
per_page: ui.perPage,
|
||||||
zoom: ui.zoom,
|
zoom: ui.zoom,
|
||||||
|
e621_per_page: ui.e621PerPage,
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface UserPreferences {
|
|||||||
per_page?: number;
|
per_page?: number;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
online_hot_default?: boolean;
|
online_hot_default?: boolean;
|
||||||
|
/** Posts per e621 request for the Online browser and pool loading (max 320). */
|
||||||
|
e621_per_page?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface UiState {
|
|||||||
/** Minimum thumbnail width in px; media grids derive their column count. */
|
/** Minimum thumbnail width in px; media grids derive their column count. */
|
||||||
zoom: number;
|
zoom: number;
|
||||||
setZoom: (zoom: number) => void;
|
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>()(
|
export const useUi = create<UiState>()(
|
||||||
@@ -39,6 +42,9 @@ export const useUi = create<UiState>()(
|
|||||||
|
|
||||||
zoom: 190,
|
zoom: 190,
|
||||||
setZoom: (zoom) => set({ zoom }),
|
setZoom: (zoom) => set({ zoom }),
|
||||||
|
|
||||||
|
e621PerPage: 48,
|
||||||
|
setE621PerPage: (e621PerPage) => set({ e621PerPage }),
|
||||||
}),
|
}),
|
||||||
{ name: "j621.ui" },
|
{ name: "j621.ui" },
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user