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