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
|
(Library/Online/Similar) and long pool descriptions
|
||||||
- [x] Profile pictures: staff Users page and a self-service Account picker
|
- [x] Profile pictures: staff Users page and a self-service Account picker
|
||||||
(searchable library grid, remove supported) set avatars from library J-IDs
|
(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
|
- [x] Command palette: tag finder, recent searches, navigation commands
|
||||||
(Ctrl+K / ⌘K, reachable from anywhere in the shell)
|
(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_username = models.CharField(max_length=100, blank=True, default="")
|
||||||
e621_api_key = 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")
|
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
|
@property
|
||||||
def e621_configured(self):
|
def e621_configured(self):
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"avatar_url",
|
"avatar_url",
|
||||||
"e621_username",
|
"e621_username",
|
||||||
"e621_configured",
|
"e621_configured",
|
||||||
|
"preferences",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
@@ -114,3 +115,19 @@ class E621CredentialsSerializer(serializers.Serializer):
|
|||||||
base_url = serializers.URLField(
|
base_url = serializers.URLField(
|
||||||
max_length=200, required=False, allow_blank=True
|
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 django.urls import path
|
||||||
from rest_framework.authtoken.views import obtain_auth_token
|
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 = [
|
urlpatterns = [
|
||||||
path("register/", RegisterView.as_view(), name="register"),
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
@@ -9,5 +16,6 @@ urlpatterns = [
|
|||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
path("logout/", LogoutView.as_view(), name="logout"),
|
||||||
path("me/", MeView.as_view(), name="me"),
|
path("me/", MeView.as_view(), name="me"),
|
||||||
path("avatar/", AvatarView.as_view(), name="avatar"),
|
path("avatar/", AvatarView.as_view(), name="avatar"),
|
||||||
|
path("preferences/", PreferencesView.as_view(), name="preferences"),
|
||||||
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
|
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from apps.library.models import MediaItem
|
|||||||
from .models import User
|
from .models import User
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
E621CredentialsSerializer,
|
E621CredentialsSerializer,
|
||||||
|
PreferencesSerializer,
|
||||||
RegisterSerializer,
|
RegisterSerializer,
|
||||||
UserListSerializer,
|
UserListSerializer,
|
||||||
UserSerializer,
|
UserSerializer,
|
||||||
@@ -137,6 +138,24 @@ class AvatarView(APIView):
|
|||||||
return Response(UserSerializer(user, context={"request": request}).data)
|
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(
|
class UserViewSet(
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { LogOut, Menu } from "lucide-react";
|
import { LogOut, Menu } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, NavLink, Outlet, useLocation, useNavigationType } from "react-router-dom";
|
import {
|
||||||
|
Link,
|
||||||
|
NavLink,
|
||||||
|
Outlet,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useNavigationType,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
@@ -21,12 +28,23 @@ export function AppShell() {
|
|||||||
const toggleSidebar = useUi((state) => state.toggleSidebar);
|
const toggleSidebar = useUi((state) => state.toggleSidebar);
|
||||||
const loadE621 = useE621((state) => state.load);
|
const loadE621 = useE621((state) => state.load);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const landingApplied = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadE621();
|
void loadE621();
|
||||||
}, [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({
|
const statusQuery = useQuery({
|
||||||
queryKey: ["status"],
|
queryKey: ["status"],
|
||||||
queryFn: () => api<SystemStatus>("/api/status/"),
|
queryFn: () => api<SystemStatus>("/api/status/"),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useE621 } from "@/store/e621";
|
|||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
import { AvatarCard } from "./AvatarCard";
|
import { AvatarCard } from "./AvatarCard";
|
||||||
|
import { PreferencesCard } from "./PreferencesCard";
|
||||||
|
|
||||||
const BASE_URL_OPTIONS = [
|
const BASE_URL_OPTIONS = [
|
||||||
{ value: "https://e621.net", label: "e621.net — main site" },
|
{ value: "https://e621.net", label: "e621.net — main site" },
|
||||||
@@ -210,7 +211,12 @@ export default function AccountPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
{user ? <AvatarCard /> : null}
|
{user ? (
|
||||||
|
<>
|
||||||
|
<AvatarCard />
|
||||||
|
<PreferencesCard />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{loading && !credentials ? (
|
{loading && !credentials ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<Spinner className="h-6 w-6" />
|
<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 { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||||
import { MatchPanel } from "@/features/library/MatchPanel";
|
import { MatchPanel } from "@/features/library/MatchPanel";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { perPageOptions, ratingFilters, sortOptions } from "@/lib/browse";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { isTypingTarget } from "@/lib/dom";
|
import { isTypingTarget } from "@/lib/dom";
|
||||||
|
import { schedulePreferenceSave } from "@/lib/preferences";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { useUi } from "@/store/ui";
|
import { useUi } from "@/store/ui";
|
||||||
import type { MediaItem, Paginated } from "@/lib/types";
|
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 = [
|
const statusFilters = [
|
||||||
{
|
{
|
||||||
value: "matched",
|
value: "matched",
|
||||||
@@ -84,6 +57,8 @@ export default function LibraryPage() {
|
|||||||
const setPerPage = useUi((state) => state.setPerPage);
|
const setPerPage = useUi((state) => state.setPerPage);
|
||||||
const ordering = useUi((state) => state.ordering);
|
const ordering = useUi((state) => state.ordering);
|
||||||
const setOrdering = useUi((state) => state.setOrdering);
|
const setOrdering = useUi((state) => state.setOrdering);
|
||||||
|
const zoom = useUi((state) => state.zoom);
|
||||||
|
const setZoom = useUi((state) => state.setZoom);
|
||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,11 +116,11 @@ export default function LibraryPage() {
|
|||||||
|
|
||||||
function toggleRating(value: string) {
|
function toggleRating(value: string) {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setRatings(
|
const next = ratings.includes(value)
|
||||||
ratings.includes(value)
|
? ratings.filter((rating) => rating !== value)
|
||||||
? ratings.filter((rating) => rating !== value)
|
: [...ratings, value];
|
||||||
: [...ratings, value],
|
setRatings(next);
|
||||||
);
|
schedulePreferenceSave({ ratings: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStatus(value: string) {
|
function toggleStatus(value: string) {
|
||||||
@@ -171,6 +146,7 @@ export default function LibraryPage() {
|
|||||||
setRatings([]);
|
setRatings([]);
|
||||||
setStatuses([]);
|
setStatuses([]);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
schedulePreferenceSave({ ratings: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterPanel = (
|
const filterPanel = (
|
||||||
@@ -291,6 +267,7 @@ export default function LibraryPage() {
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setOrdering(event.target.value);
|
setOrdering(event.target.value);
|
||||||
|
schedulePreferenceSave({ ordering: event.target.value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sortOptions.map((option) => (
|
{sortOptions.map((option) => (
|
||||||
@@ -309,8 +286,10 @@ export default function LibraryPage() {
|
|||||||
className={inputClass}
|
className={inputClass}
|
||||||
value={perPage}
|
value={perPage}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
const value = Number(event.target.value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setPerPage(Number(event.target.value));
|
setPerPage(value);
|
||||||
|
schedulePreferenceSave({ per_page: value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{perPageOptions.map((option) => (
|
{perPageOptions.map((option) => (
|
||||||
@@ -320,6 +299,25 @@ export default function LibraryPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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) => (
|
{query.data.results.map((item) => (
|
||||||
<MediaCard key={item.md5} item={item} />
|
<MediaCard key={item.md5} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type E621Post,
|
type E621Post,
|
||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { useUi } from "@/store/ui";
|
||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const PAGE_SIZE = 48;
|
const PAGE_SIZE = 48;
|
||||||
@@ -37,6 +38,7 @@ export default function OnlinePage() {
|
|||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
const [blacklistDraft, setBlacklistDraft] = useState("");
|
const [blacklistDraft, setBlacklistDraft] = useState("");
|
||||||
const tagInput = draft ?? tags;
|
const tagInput = draft ?? tags;
|
||||||
|
const zoom = useUi((state) => state.zoom);
|
||||||
|
|
||||||
const blacklistMutation = useMutation({
|
const blacklistMutation = useMutation({
|
||||||
mutationFn: (lines: string[]) => saveBlacklist(lines),
|
mutationFn: (lines: string[]) => saveBlacklist(lines),
|
||||||
@@ -354,9 +356,12 @@ export default function OnlinePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3 transition-opacity",
|
"grid gap-3 transition-opacity",
|
||||||
postsQuery.isPlaceholderData && "opacity-50",
|
postsQuery.isPlaceholderData && "opacity-50",
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(auto-fill, minmax(${zoom}px, 1fr))`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{visiblePosts.map((post) => (
|
{visiblePosts.map((post) => (
|
||||||
<PostCard
|
<PostCard
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { useIsMobile } from "@/lib/useMediaQuery";
|
import { useIsMobile } from "@/lib/useMediaQuery";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { useUi } from "@/store/ui";
|
||||||
|
|
||||||
const CHUNK = 100;
|
const CHUNK = 100;
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export default function PoolDetailPage() {
|
|||||||
const blacklist = useE621((state) => state.blacklist);
|
const blacklist = useE621((state) => state.blacklist);
|
||||||
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
|
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const zoom = useUi((state) => state.zoom);
|
||||||
|
|
||||||
const [visibleCount, setVisibleCount] = useState(CHUNK);
|
const [visibleCount, setVisibleCount] = useState(CHUNK);
|
||||||
const [showBlacklisted, setShowBlacklisted] = useState(false);
|
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) => {
|
{shownPosts.map((post) => {
|
||||||
const blacklisted =
|
const blacklisted =
|
||||||
blacklistSet.size > 0 &&
|
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 {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -11,6 +19,7 @@ export interface User {
|
|||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
e621_username: string;
|
e621_username: string;
|
||||||
e621_configured: boolean;
|
e621_configured: boolean;
|
||||||
|
preferences: UserPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
import { api, getToken, setToken } from "@/lib/api";
|
import { api, getToken, setToken } from "@/lib/api";
|
||||||
|
import { applyPreferences } from "@/lib/preferences";
|
||||||
import { queryClient } from "@/lib/queryClient";
|
import { queryClient } from "@/lib/queryClient";
|
||||||
import type { User } from "@/lib/types";
|
import type { User } from "@/lib/types";
|
||||||
import { useE621 } from "@/store/e621";
|
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.
|
// Never show the previous account's cached data to this one.
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
useE621.getState().clear();
|
useE621.getState().clear();
|
||||||
|
applyPreferences(user.preferences);
|
||||||
set({ user, ready: true });
|
set({ user, ready: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ export const useAuth = create<AuthState>((set) => ({
|
|||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
useE621.getState().clear();
|
useE621.getState().clear();
|
||||||
|
applyPreferences(data.user.preferences);
|
||||||
set({ user: data.user, ready: true });
|
set({ user: data.user, ready: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ export const useAuth = create<AuthState>((set) => ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const user = await api<User>("/api/auth/me/");
|
const user = await api<User>("/api/auth/me/");
|
||||||
|
applyPreferences(user.preferences);
|
||||||
set({ user, ready: true });
|
set({ user, ready: true });
|
||||||
} catch {
|
} catch {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ interface UiState {
|
|||||||
setPerPage: (perPage: number) => void;
|
setPerPage: (perPage: number) => void;
|
||||||
ordering: string;
|
ordering: string;
|
||||||
setOrdering: (ordering: string) => void;
|
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>()(
|
export const useUi = create<UiState>()(
|
||||||
@@ -33,6 +36,9 @@ export const useUi = create<UiState>()(
|
|||||||
|
|
||||||
ordering: "-created_at",
|
ordering: "-created_at",
|
||||||
setOrdering: (ordering) => set({ ordering }),
|
setOrdering: (ordering) => set({ ordering }),
|
||||||
|
|
||||||
|
zoom: 190,
|
||||||
|
setZoom: (zoom) => set({ zoom }),
|
||||||
}),
|
}),
|
||||||
{ name: "j621.ui" },
|
{ name: "j621.ui" },
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user