Files
J621/frontend/src/features/account/PreferencesCard.tsx
T
JakeBreath dc79349d9e Online defaults to order:hot, with an Account toggle
New online_hot_default preference (on by default, so guests and accounts
that never saved preferences get it): opening /online without a search
replaces the URL with ?tags=order:hot — e621's metatag for the order the
Hot page uses — so it is visible in the search field and shareable.
Existing searches are never touched: they live in the URL, so a refresh
or back/forward keeps them, while a fresh visit (nav pill, first time,
after a long time away) gets the hot default again. Turning the toggle
off opens Online on the site-wide newest posts as before.

The toggle sits in Account -> Browsing preferences and saves with the
rest of the settings; the backend validates the new boolean
(400 for non-boolean input).
2026-09-17 23:46:14 -05:00

204 lines
6.6 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 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,
});
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>
</div>
<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>
);
}