Files
J621/frontend/src/features/account/PreferencesCard.tsx
T
JakeBreath 1adb761c8d 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).
2026-09-18 19:06:18 -05:00

232 lines
7.5 KiB
TypeScript

import { useState } from "react";
import { Button, Field, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api";
import { perPageOptions, ratingFilters, sortOptions } from "@/lib/browse";
import { cn } from "@/lib/cn";
import { applyPreferences, savePreferences } from "@/lib/preferences";
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" },
{ value: "pools", label: "Pools" },
{ value: "followed", label: "Followed" },
];
export function PreferencesCard() {
const user = useAuth((state) => state.user);
const loadUser = useAuth((state) => state.loadUser);
const [values, setValues] = useState<UserPreferences>({
landing_page: user?.preferences?.landing_page ?? "library",
ratings: user?.preferences?.ratings ?? [],
ordering: user?.preferences?.ordering ?? "-created_at",
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);
if (!user) return null;
const ratings = values.ratings ?? [];
function toggleRating(value: string) {
const next = ratings.includes(value)
? ratings.filter((rating) => rating !== value)
: [...ratings, value];
setValues((current) => ({ ...current, ratings: next }));
}
async function handleSave() {
setSaving(true);
try {
const saved = await savePreferences(values);
applyPreferences(saved);
await loadUser();
toast.ok("Browsing preferences saved.");
} catch (error) {
toast.error(errorMessage(error));
} finally {
setSaving(false);
}
}
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Browsing preferences
</h2>
<p className="mt-1 text-xs text-ctp-overlay0">
Saved to your account, so they follow you to any browser. Changing the
filters while browsing updates them as well.
</p>
<div className="mt-4 flex flex-col gap-4">
<Field label="Landing page">
<select
className={inputClass}
value={values.landing_page}
onChange={(event) =>
setValues((current) => ({
...current,
landing_page: event.target
.value as UserPreferences["landing_page"],
}))
}
>
{LANDING_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Default rating filter
</span>
<div className="flex flex-wrap gap-1.5">
{ratingFilters.map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleRating(option.value)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
ratings.includes(option.value)
? option.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{option.label}
</button>
))}
</div>
<p className="text-[11px] text-ctp-overlay0">
No selection shows every rating.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Default sort">
<select
className={inputClass}
value={values.ordering}
onChange={(event) =>
setValues((current) => ({
...current,
ordering: event.target.value,
}))
}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<Field label="Items per page">
<select
className={inputClass}
value={values.per_page}
onChange={(event) =>
setValues((current) => ({
...current,
per_page: Number(event.target.value),
}))
}
>
{perPageOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</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
</span>
<input
type="range"
min={140}
max={320}
step={10}
value={values.zoom}
onChange={(event) =>
setValues((current) => ({
...current,
zoom: Number(event.target.value),
}))
}
className="accent-ctp-mauve"
/>
</label>
<label className="flex items-start gap-2 text-sm text-ctp-subtext0">
<input
type="checkbox"
checked={values.online_hot_default ?? true}
onChange={(event) =>
setValues((current) => ({
...current,
online_hot_default: event.target.checked,
}))
}
className="mt-0.5 accent-ctp-mauve"
/>
<span>
Online starts with{" "}
<span className="font-mono text-ctp-blue">order:hot</span>
<span className="mt-0.5 block text-[11px] leading-relaxed text-ctp-overlay0">
Used when you open Online without a search. With it off, Online
opens on the site-wide newest posts.
</span>
</span>
</label>
<div>
<Button onClick={() => void handleSave()} disabled={saving}>
{saving ? "Saving…" : "Save preferences"}
</Button>
</div>
</div>
</section>
);
}