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).
232 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|