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" },
),