From bb87f563a94db65d9008204aca7e8ec90b73d715 Mon Sep 17 00:00:00 2001
From: JakeBreath
Date: Thu, 17 Sep 2026 22:34:38 -0500
Subject: [PATCH] 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/.
---
ROADMAP.md | 3 +-
.../migrations/0005_user_preferences.py | 18 ++
backend/apps/accounts/models.py | 2 +
backend/apps/accounts/serializers.py | 17 ++
backend/apps/accounts/urls.py | 10 +-
backend/apps/accounts/views.py | 19 ++
frontend/src/components/AppShell.tsx | 22 ++-
frontend/src/features/account/AccountPage.tsx | 8 +-
.../src/features/account/PreferencesCard.tsx | 181 ++++++++++++++++++
frontend/src/features/library/LibraryPage.tsx | 75 ++++----
frontend/src/features/online/OnlinePage.tsx | 7 +-
.../src/features/pools/PoolDetailPage.tsx | 9 +-
frontend/src/lib/browse.ts | 30 +++
frontend/src/lib/preferences.ts | 55 ++++++
frontend/src/lib/types.ts | 9 +
frontend/src/store/auth.ts | 4 +
frontend/src/store/ui.ts | 6 +
17 files changed, 432 insertions(+), 43 deletions(-)
create mode 100644 backend/apps/accounts/migrations/0005_user_preferences.py
create mode 100644 frontend/src/features/account/PreferencesCard.tsx
create mode 100644 frontend/src/lib/browse.ts
create mode 100644 frontend/src/lib/preferences.ts
diff --git a/ROADMAP.md b/ROADMAP.md
index 3f2d10c..984a610 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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)
diff --git a/backend/apps/accounts/migrations/0005_user_preferences.py b/backend/apps/accounts/migrations/0005_user_preferences.py
new file mode 100644
index 0000000..4ec45bc
--- /dev/null
+++ b/backend/apps/accounts/migrations/0005_user_preferences.py
@@ -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),
+ ),
+ ]
diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py
index b27269e..f528e18 100644
--- a/backend/apps/accounts/models.py
+++ b/backend/apps/accounts/models.py
@@ -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):
diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py
index bb0d7b2..16489f9 100644
--- a/backend/apps/accounts/serializers.py
+++ b/backend/apps/accounts/serializers.py
@@ -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)
diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py
index 927b9da..7e45c27 100644
--- a/backend/apps/accounts/urls.py
+++ b/backend/apps/accounts/urls.py
@@ -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"),
]
diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py
index e9f36fb..e1feb8a 100644
--- a/backend/apps/accounts/views.py
+++ b/backend/apps/accounts/views.py
@@ -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,
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index 0bf9ab3..a7b863e 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -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("/api/status/"),
diff --git a/frontend/src/features/account/AccountPage.tsx b/frontend/src/features/account/AccountPage.tsx
index ebd9919..134327b 100644
--- a/frontend/src/features/account/AccountPage.tsx
+++ b/frontend/src/features/account/AccountPage.tsx
@@ -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() {
) : null}
- {user ? : null}
+ {user ? (
+ <>
+
+
+ >
+ ) : null}
{loading && !credentials ? (
diff --git a/frontend/src/features/account/PreferencesCard.tsx b/frontend/src/features/account/PreferencesCard.tsx
new file mode 100644
index 0000000..edeafab
--- /dev/null
+++ b/frontend/src/features/account/PreferencesCard.tsx
@@ -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
; 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({
+ 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 (
+
+
+ Browsing preferences
+
+
+ Saved to your account, so they follow you to any browser. Changing the
+ filters while browsing updates them as well.
+
+
+
+
+
+
+
+
+
+ Default rating filter
+
+
+ {ratingFilters.map((option) => (
+
+ ))}
+
+
+ No selection shows every rating.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx
index a59ffc4..56db2e7 100644
--- a/frontend/src/features/library/LibraryPage.tsx
+++ b/frontend/src/features/library/LibraryPage.tsx
@@ -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() {
))}
+
+
>
);
@@ -369,7 +367,12 @@ export default function LibraryPage() {
}
/>
) : (
-
+
{query.data.results.map((item) => (
))}
diff --git a/frontend/src/features/online/OnlinePage.tsx b/frontend/src/features/online/OnlinePage.tsx
index a1cbcee..435a4b3 100644
--- a/frontend/src/features/online/OnlinePage.tsx
+++ b/frontend/src/features/online/OnlinePage.tsx
@@ -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
(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() {
) : (
{visiblePosts.map((post) => (
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() {
}
/>
) : (
-
+
{shownPosts.map((post) => {
const blacklisted =
blacklistSet.size > 0 &&
diff --git a/frontend/src/lib/browse.ts b/frontend/src/lib/browse.ts
new file mode 100644
index 0000000..c665cc9
--- /dev/null
+++ b/frontend/src/lib/browse.ts
@@ -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",
+ },
+];
diff --git a/frontend/src/lib/preferences.ts b/frontend/src/lib/preferences.ts
new file mode 100644
index 0000000..507e3f2
--- /dev/null
+++ b/frontend/src/lib/preferences.ts
@@ -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
{
+ return api("/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);
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 0b29a39..26f1dbc 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -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 {
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
index d3d1ccb..5d72e55 100644
--- a/frontend/src/store/auth.ts
+++ b/frontend/src/store/auth.ts
@@ -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((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((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((set) => ({
}
try {
const user = await api("/api/auth/me/");
+ applyPreferences(user.preferences);
set({ user, ready: true });
} catch {
setToken(null);
diff --git a/frontend/src/store/ui.ts b/frontend/src/store/ui.ts
index 6a17f1b..f677181 100644
--- a/frontend/src/store/ui.ts
+++ b/frontend/src/store/ui.ts
@@ -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()(
@@ -33,6 +36,9 @@ export const useUi = create()(
ordering: "-created_at",
setOrdering: (ordering) => set({ ordering }),
+
+ zoom: 190,
+ setZoom: (zoom) => set({ zoom }),
}),
{ name: "j621.ui" },
),