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:
2026-09-17 22:34:38 -05:00
parent 3c49d2be2e
commit bb87f563a9
17 changed files with 432 additions and 43 deletions
+2 -1
View File
@@ -135,7 +135,8 @@ Files now stage first and are resolved before entering the library.
(Library/Online/Similar) and long pool descriptions
- [x] Profile pictures: staff Users page and a self-service Account picker
(searchable library grid, remove supported) set avatars from library J-IDs
- [ ] Profile extras (per-user browse preferences)
- [x] Profile extras: per-user landing page, default rating filter/sort, items
per page and thumbnail size (synced to the account, applied on load)
- [x] Command palette: tag finder, recent searches, navigation commands
(Ctrl+K / ⌘K, reachable from anywhere in the shell)
@@ -0,0 +1,18 @@
# Generated by Django 6.1.1 on 2026-09-18 03:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_user_avatar'),
]
operations = [
migrations.AddField(
model_name='user',
name='preferences',
field=models.JSONField(blank=True, default=dict),
),
]
+2
View File
@@ -25,6 +25,8 @@ class User(AbstractUser):
e621_username = models.CharField(max_length=100, blank=True, default="")
e621_api_key = models.CharField(max_length=100, blank=True, default="")
e621_base_url = models.CharField(max_length=200, default="https://e621.net")
# Per-user browse preferences (landing page, default filters, grid size).
preferences = models.JSONField(default=dict, blank=True)
@property
def e621_configured(self):
+17
View File
@@ -50,6 +50,7 @@ class UserSerializer(serializers.ModelSerializer):
"avatar_url",
"e621_username",
"e621_configured",
"preferences",
]
read_only_fields = fields
@@ -114,3 +115,19 @@ class E621CredentialsSerializer(serializers.Serializer):
base_url = serializers.URLField(
max_length=200, required=False, allow_blank=True
)
class PreferencesSerializer(serializers.Serializer):
"""Per-user browse preferences; unknown keys are ignored."""
LANDING_PAGES = ["library", "online", "pools", "followed"]
landing_page = serializers.ChoiceField(choices=LANDING_PAGES, required=False)
ratings = serializers.ListField(
child=serializers.ChoiceField(choices=["s", "q", "e"]),
required=False,
max_length=3,
)
ordering = serializers.CharField(max_length=30, 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)
+9 -1
View File
@@ -1,7 +1,14 @@
from django.urls import path
from rest_framework.authtoken.views import obtain_auth_token
from .views import AvatarView, E621CredentialsView, LogoutView, MeView, RegisterView
from .views import (
AvatarView,
E621CredentialsView,
LogoutView,
MeView,
PreferencesView,
RegisterView,
)
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
@@ -9,5 +16,6 @@ urlpatterns = [
path("logout/", LogoutView.as_view(), name="logout"),
path("me/", MeView.as_view(), name="me"),
path("avatar/", AvatarView.as_view(), name="avatar"),
path("preferences/", PreferencesView.as_view(), name="preferences"),
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
]
+19
View File
@@ -10,6 +10,7 @@ from apps.library.models import MediaItem
from .models import User
from .serializers import (
E621CredentialsSerializer,
PreferencesSerializer,
RegisterSerializer,
UserListSerializer,
UserSerializer,
@@ -137,6 +138,24 @@ class AvatarView(APIView):
return Response(UserSerializer(user, context={"request": request}).data)
class PreferencesView(APIView):
"""Per-user browse preferences, merged so callers can send single keys."""
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(request.user.preferences or {})
def post(self, request):
serializer = PreferencesSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
preferences = dict(request.user.preferences or {})
preferences.update(serializer.validated_data)
request.user.preferences = preferences
request.user.save(update_fields=["preferences"])
return Response(preferences)
class UserViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
+20 -2
View File
@@ -1,7 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { LogOut, Menu } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, NavLink, Outlet, useLocation, useNavigationType } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import {
Link,
NavLink,
Outlet,
useLocation,
useNavigate,
useNavigationType,
} from "react-router-dom";
import { CommandPalette } from "@/components/CommandPalette";
import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -21,12 +28,23 @@ export function AppShell() {
const toggleSidebar = useUi((state) => state.toggleSidebar);
const loadE621 = useE621((state) => state.load);
const location = useLocation();
const navigate = useNavigate();
const [paletteOpen, setPaletteOpen] = useState(false);
const landingApplied = useRef(false);
useEffect(() => {
void loadE621();
}, [loadE621]);
// Open on the preferred landing page, but only for the initial "/" visit.
useEffect(() => {
if (landingApplied.current || !user) return;
landingApplied.current = true;
const landing = user.preferences?.landing_page ?? "library";
if (landing === "library" || location.pathname !== "/") return;
navigate(landing === "pools" ? "/pools" : `/${landing}`, { replace: true });
}, [user, location.pathname, navigate]);
const statusQuery = useQuery({
queryKey: ["status"],
queryFn: () => api<SystemStatus>("/api/status/"),
@@ -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>
);
}
+39 -36
View File
@@ -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} />
))}
+6 -1
View File
@@ -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 &&
+30
View File
@@ -0,0 +1,30 @@
/** Browse controls shared by the Library page and the preferences card. */
export 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" },
];
export const perPageOptions = [24, 48, 96];
export 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",
},
];
+55
View File
@@ -0,0 +1,55 @@
import { api, getToken } from "@/lib/api";
import type { UserPreferences } from "@/lib/types";
import { useUi } from "@/store/ui";
/** Seed the local browse settings from the signed-in user's saved preferences. */
export function applyPreferences(
preferences: UserPreferences | null | undefined,
): void {
if (!preferences) return;
const ui = useUi.getState();
if (Array.isArray(preferences.ratings)) ui.setRatings(preferences.ratings);
if (typeof preferences.ordering === "string") ui.setOrdering(preferences.ordering);
if (typeof preferences.per_page === "number") ui.setPerPage(preferences.per_page);
if (typeof preferences.zoom === "number") ui.setZoom(preferences.zoom);
}
/** The browse settings currently in effect, as a preferences payload. */
export function preferencesPayload(extra?: UserPreferences): UserPreferences {
const ui = useUi.getState();
return {
ratings: ui.ratings,
ordering: ui.ordering,
per_page: ui.perPage,
zoom: ui.zoom,
...extra,
};
}
export function savePreferences(
patch: UserPreferences,
): Promise<UserPreferences> {
return api<UserPreferences>("/api/auth/preferences/", {
method: "POST",
json: patch,
});
}
let pending: number | null = null;
let pendingPatch: UserPreferences = {};
/**
* Persist browse settings for the signed-in user, debounced so slider drags
* do not spam the API. Guests keep using localStorage only.
*/
export function schedulePreferenceSave(patch: UserPreferences): void {
if (!getToken()) return;
pendingPatch = { ...pendingPatch, ...patch };
if (pending !== null) window.clearTimeout(pending);
pending = window.setTimeout(() => {
pending = null;
const body = preferencesPayload(pendingPatch);
pendingPatch = {};
void savePreferences(body).catch(() => undefined);
}, 700);
}
+9
View File
@@ -1,3 +1,11 @@
export interface UserPreferences {
landing_page?: "library" | "online" | "pools" | "followed";
ratings?: string[];
ordering?: string;
per_page?: number;
zoom?: number;
}
export interface User {
id: number;
username: string;
@@ -11,6 +19,7 @@ export interface User {
avatar_url: string | null;
e621_username: string;
e621_configured: boolean;
preferences: UserPreferences;
}
export interface AdminUser {
+4
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { api, getToken, setToken } from "@/lib/api";
import { applyPreferences } from "@/lib/preferences";
import { queryClient } from "@/lib/queryClient";
import type { User } from "@/lib/types";
import { useE621 } from "@/store/e621";
@@ -28,6 +29,7 @@ export const useAuth = create<AuthState>((set) => ({
// Never show the previous account's cached data to this one.
queryClient.clear();
useE621.getState().clear();
applyPreferences(user.preferences);
set({ user, ready: true });
},
@@ -39,6 +41,7 @@ export const useAuth = create<AuthState>((set) => ({
setToken(data.token);
queryClient.clear();
useE621.getState().clear();
applyPreferences(data.user.preferences);
set({ user: data.user, ready: true });
},
@@ -61,6 +64,7 @@ export const useAuth = create<AuthState>((set) => ({
}
try {
const user = await api<User>("/api/auth/me/");
applyPreferences(user.preferences);
set({ user, ready: true });
} catch {
setToken(null);
+6
View File
@@ -13,6 +13,9 @@ interface UiState {
setPerPage: (perPage: number) => void;
ordering: string;
setOrdering: (ordering: string) => void;
/** Minimum thumbnail width in px; media grids derive their column count. */
zoom: number;
setZoom: (zoom: number) => void;
}
export const useUi = create<UiState>()(
@@ -33,6 +36,9 @@ export const useUi = create<UiState>()(
ordering: "-created_at",
setOrdering: (ordering) => set({ ordering }),
zoom: 190,
setZoom: (zoom) => set({ zoom }),
}),
{ name: "j621.ui" },
),