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:
+2
-1
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user