Per-user browse preferences
Adds a preferences JSON field on the user plus GET/POST /api/auth/preferences/ (merge semantics, validated keys), surfaced in /auth/me/ and typed on the frontend. The Account page gains a Browsing preferences card: landing page, default rating filter, default sort, items per page and thumbnail size. Signed-in users also sync these while browsing (the Library sidebar's rating/sort/per-page controls and the new thumbnail slider), debounced; on load the account's values seed the local UI state, so settings follow the user across browsers. Guests keep the existing localStorage behaviour. The thumbnail size drives the media grids (Library, Online, pool detail) between 140 and 320px columns. Verified the API against the dev server: merge keeps untouched keys, invalid values 400, values round-trip through /auth/me/.
This commit is contained in:
@@ -14,6 +14,7 @@ import { useE621 } from "@/store/e621";
|
||||
import { toast } from "@/store/toasts";
|
||||
|
||||
import { AvatarCard } from "./AvatarCard";
|
||||
import { PreferencesCard } from "./PreferencesCard";
|
||||
|
||||
const BASE_URL_OPTIONS = [
|
||||
{ value: "https://e621.net", label: "e621.net — main site" },
|
||||
@@ -210,7 +211,12 @@ export default function AccountPage() {
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{user ? <AvatarCard /> : null}
|
||||
{user ? (
|
||||
<>
|
||||
<AvatarCard />
|
||||
<PreferencesCard />
|
||||
</>
|
||||
) : null}
|
||||
{loading && !credentials ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="h-6 w-6" />
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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>
|
||||
|
||||
<div>
|
||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,41 +7,14 @@ import { MediaCard } from "@/components/MediaCard";
|
||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
import { MatchPanel } from "@/features/library/MatchPanel";
|
||||
import { api } from "@/lib/api";
|
||||
import { perPageOptions, ratingFilters, sortOptions } from "@/lib/browse";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { isTypingTarget } from "@/lib/dom";
|
||||
import { schedulePreferenceSave } from "@/lib/preferences";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useUi } from "@/store/ui";
|
||||
import type { MediaItem, Paginated } from "@/lib/types";
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "-created_at", label: "Newest first" },
|
||||
{ value: "created_at", label: "Oldest first" },
|
||||
{ value: "-size", label: "Largest first" },
|
||||
{ value: "size", label: "Smallest first" },
|
||||
{ value: "name", label: "Name A–Z" },
|
||||
{ value: "-name", label: "Name Z–A" },
|
||||
];
|
||||
|
||||
const perPageOptions = [24, 48, 96];
|
||||
|
||||
const ratingFilters = [
|
||||
{
|
||||
value: "s",
|
||||
label: "Safe",
|
||||
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
|
||||
},
|
||||
{
|
||||
value: "q",
|
||||
label: "Questionable",
|
||||
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
|
||||
},
|
||||
{
|
||||
value: "e",
|
||||
label: "Explicit",
|
||||
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
|
||||
},
|
||||
];
|
||||
|
||||
const statusFilters = [
|
||||
{
|
||||
value: "matched",
|
||||
@@ -84,6 +57,8 @@ export default function LibraryPage() {
|
||||
const setPerPage = useUi((state) => state.setPerPage);
|
||||
const ordering = useUi((state) => state.ordering);
|
||||
const setOrdering = useUi((state) => state.setOrdering);
|
||||
const zoom = useUi((state) => state.zoom);
|
||||
const setZoom = useUi((state) => state.setZoom);
|
||||
const user = useAuth((state) => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -141,11 +116,11 @@ export default function LibraryPage() {
|
||||
|
||||
function toggleRating(value: string) {
|
||||
setPage(1);
|
||||
setRatings(
|
||||
ratings.includes(value)
|
||||
? ratings.filter((rating) => rating !== value)
|
||||
: [...ratings, value],
|
||||
);
|
||||
const next = ratings.includes(value)
|
||||
? ratings.filter((rating) => rating !== value)
|
||||
: [...ratings, value];
|
||||
setRatings(next);
|
||||
schedulePreferenceSave({ ratings: next });
|
||||
}
|
||||
|
||||
function toggleStatus(value: string) {
|
||||
@@ -171,6 +146,7 @@ export default function LibraryPage() {
|
||||
setRatings([]);
|
||||
setStatuses([]);
|
||||
setPage(1);
|
||||
schedulePreferenceSave({ ratings: [] });
|
||||
}
|
||||
|
||||
const filterPanel = (
|
||||
@@ -291,6 +267,7 @@ export default function LibraryPage() {
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setOrdering(event.target.value);
|
||||
schedulePreferenceSave({ ordering: event.target.value });
|
||||
}}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
@@ -309,8 +286,10 @@ export default function LibraryPage() {
|
||||
className={inputClass}
|
||||
value={perPage}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
setPage(1);
|
||||
setPerPage(Number(event.target.value));
|
||||
setPerPage(value);
|
||||
schedulePreferenceSave({ per_page: value });
|
||||
}}
|
||||
>
|
||||
{perPageOptions.map((option) => (
|
||||
@@ -320,6 +299,25 @@ export default function LibraryPage() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<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={zoom}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
setZoom(value);
|
||||
schedulePreferenceSave({ zoom: value });
|
||||
}}
|
||||
className="accent-ctp-mauve"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -369,7 +367,12 @@ export default function LibraryPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3">
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${zoom}px, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{query.data.results.map((item) => (
|
||||
<MediaCard key={item.md5} item={item} />
|
||||
))}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type E621Post,
|
||||
} from "@/lib/e621";
|
||||
import { useE621 } from "@/store/e621";
|
||||
import { useUi } from "@/store/ui";
|
||||
import { toast } from "@/store/toasts";
|
||||
|
||||
const PAGE_SIZE = 48;
|
||||
@@ -37,6 +38,7 @@ export default function OnlinePage() {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [blacklistDraft, setBlacklistDraft] = useState("");
|
||||
const tagInput = draft ?? tags;
|
||||
const zoom = useUi((state) => state.zoom);
|
||||
|
||||
const blacklistMutation = useMutation({
|
||||
mutationFn: (lines: string[]) => saveBlacklist(lines),
|
||||
@@ -354,9 +356,12 @@ export default function OnlinePage() {
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3 transition-opacity",
|
||||
"grid gap-3 transition-opacity",
|
||||
postsQuery.isPlaceholderData && "opacity-50",
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${zoom}px, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{visiblePosts.map((post) => (
|
||||
<PostCard
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useIsMobile } from "@/lib/useMediaQuery";
|
||||
import { useE621 } from "@/store/e621";
|
||||
import { useUi } from "@/store/ui";
|
||||
|
||||
const CHUNK = 100;
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function PoolDetailPage() {
|
||||
const blacklist = useE621((state) => state.blacklist);
|
||||
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
|
||||
const isMobile = useIsMobile();
|
||||
const zoom = useUi((state) => state.zoom);
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(CHUNK);
|
||||
const [showBlacklisted, setShowBlacklisted] = useState(false);
|
||||
@@ -274,7 +276,12 @@ export default function PoolDetailPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3">
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${zoom}px, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{shownPosts.map((post) => {
|
||||
const blacklisted =
|
||||
blacklistSet.size > 0 &&
|
||||
|
||||
Reference in New Issue
Block a user