Self-service avatar picker in Account

Users can now set their own profile picture instead of asking staff:
POST /api/auth/avatar/ accepts a J-ID (or blank to clear) and reuses the
same item resolution as the staff endpoint. The Account page gains a
profile picture card with a searchable, paginated library grid — any
item works (the thumbnail is used), the current avatar is marked, and
the choice is confirmed before saving. Refreshing the signed-in user
updates the shell avatar immediately.

Verified against the dev server: set, clear, unknown J-ID -> 400,
anonymous -> 401.
This commit is contained in:
2026-09-17 22:31:05 -05:00
parent d8ba442e72
commit 3c49d2be2e
5 changed files with 313 additions and 19 deletions
+4 -3
View File
@@ -131,9 +131,10 @@ Files now stage first and are resolved before entering the library.
(remembered per browser in `localStorage`)
- [x] Toasts for action results (success/error) plus a shared confirm dialog for
destructive actions; form-field validation stays inline
- [ ] Mobile drawer polish for metadata panels (design spec §layout)
- [x] Profile pictures: staff Users page sets avatars from library J-IDs
(self-service picker in Account still pending)
- [x] Mobile bottom sheets for metadata panels (<768px): detail asides
(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] Command palette: tag finder, recent searches, navigation commands
(Ctrl+K / ⌘K, reachable from anywhere in the shell)
+2 -1
View File
@@ -1,12 +1,13 @@
from django.urls import path
from rest_framework.authtoken.views import obtain_auth_token
from .views import E621CredentialsView, LogoutView, MeView, RegisterView
from .views import AvatarView, E621CredentialsView, LogoutView, MeView, RegisterView
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("token/", obtain_auth_token, name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
path("me/", MeView.as_view(), name="me"),
path("avatar/", AvatarView.as_view(), name="avatar"),
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
]
+42 -12
View File
@@ -98,6 +98,45 @@ class IsStaffUser(permissions.BasePermission):
)
def resolve_avatar_item(value):
"""Turn a "J-42" / "42" string into a MediaItem.
Returns ``(item, error)``: an empty value clears the avatar (``None``,
``None``), an unknown item returns an error message.
"""
value = str(value or "").strip()
if not value:
return None, None
numeric = value[2:] if value.upper().startswith("J-") else value
item = (
MediaItem.objects.filter(pk=int(numeric)).first()
if numeric.isdigit()
else None
)
if item is None:
return None, f"No library item {value}."
return item, None
class AvatarView(APIView):
"""Self-service profile picture chosen from the library."""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = UserUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
item, error = resolve_avatar_item(
serializer.validated_data.get("avatar_j_id")
)
if error:
return Response({"detail": error}, status=status.HTTP_400_BAD_REQUEST)
user = request.user
user.avatar = item
user.save(update_fields=["avatar"])
return Response(UserSerializer(user, context={"request": request}).data)
class UserViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
@@ -133,19 +172,10 @@ class UserViewSet(
user.role = data["role"]
update_fields.append("role")
if "avatar_j_id" in data:
value = str(data.get("avatar_j_id") or "").strip()
if not value:
user.avatar = None
else:
numeric = value[2:] if value.upper().startswith("J-") else value
item = (
MediaItem.objects.filter(pk=int(numeric)).first()
if numeric.isdigit()
else None
)
if item is None:
item, error = resolve_avatar_item(data.get("avatar_j_id"))
if error:
return Response(
{"detail": f"No library item {value}."},
{"detail": error},
status=status.HTTP_400_BAD_REQUEST,
)
user.avatar = item
@@ -13,6 +13,8 @@ import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
import { toast } from "@/store/toasts";
import { AvatarCard } from "./AvatarCard";
const BASE_URL_OPTIONS = [
{ value: "https://e621.net", label: "e621.net — main site" },
{ value: "https://e926.net", label: "e926.net — safe mirror" },
@@ -208,6 +210,7 @@ export default function AccountPage() {
</p>
) : null}
</header>
{user ? <AvatarCard /> : null}
{loading && !credentials ? (
<div className="flex justify-center py-12">
<Spinner className="h-6 w-6" />
@@ -0,0 +1,259 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronLeft, ChevronRight, Search } from "lucide-react";
import { useState } from "react";
import { Button, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { MediaItem, Paginated, User } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
const PER_PAGE = 24;
function AvatarPickerModal({
currentJId,
saving,
onPick,
onClose,
}: {
currentJId: string | null;
saving: boolean;
onPick: (item: MediaItem) => void;
onClose: () => void;
}) {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<MediaItem | null>(null);
const query = useQuery({
queryKey: ["avatar-picker", search, page],
queryFn: () => {
const params = new URLSearchParams({
page: String(page),
per_page: String(PER_PAGE),
ordering: "-created_at",
});
if (search.trim()) params.set("search", search.trim());
return api<Paginated<MediaItem>>(`/api/files/?${params.toString()}`);
},
});
const items = query.data?.results ?? [];
const pageCount = query.data
? Math.max(1, Math.ceil(query.data.count / PER_PAGE))
: 1;
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-10 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-3xl overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-base"
onClick={(event) => event.stopPropagation()}
>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-ctp-surface0 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-ctp-text">
Choose a profile picture
</h2>
<p className="mt-0.5 text-xs text-ctp-overlay0">
Any library item works, including animations and videos (the
thumbnail is used).
</p>
</div>
<div className="relative w-full sm:w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ctp-overlay0" />
<input
className={cn(inputClass, "pl-8")}
placeholder="Filename or tags…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
autoFocus
/>
</div>
</header>
<div className="max-h-[55vh] overflow-y-auto p-4">
{query.isPending ? (
<div className="flex justify-center py-16">
<Spinner className="h-5 w-5" />
</div>
) : query.isError ? (
<p className="py-8 text-center text-sm text-ctp-red">
{errorMessage(query.error)}
</p>
) : items.length === 0 ? (
<p className="py-8 text-center text-sm text-ctp-overlay0">
Nothing matches that search.
</p>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
{items.map((item) => {
const isSelected = selected?.j_id === item.j_id;
const isCurrent = item.j_id === currentJId;
return (
<button
key={item.j_id}
type="button"
onClick={() => setSelected(item)}
onDoubleClick={() => onPick(item)}
title={`${item.j_id} · ${item.filename}`}
className={cn(
"relative overflow-hidden rounded-md border bg-ctp-mantle transition",
isSelected
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<span className="block aspect-square overflow-hidden">
<img
src={
item.thumbnail_url ??
`/api/files/${item.j_id}/thumbnail/`
}
alt={item.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
</span>
{isCurrent ? (
<span className="absolute right-1 top-1 rounded-full bg-ctp-green/90 px-1 py-0.5 font-mono text-[9px] font-semibold uppercase text-ctp-crust">
now
</span>
) : null}
{isSelected ? (
<span className="absolute left-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-ctp-mauve text-ctp-crust">
<Check className="h-3 w-3" strokeWidth={3} />
</span>
) : null}
</button>
);
})}
</div>
)}
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface0 px-4 py-3">
<div className="flex items-center gap-2 text-xs text-ctp-overlay0">
<button
type="button"
disabled={page <= 1 || query.isPending}
onClick={() => setPage((value) => Math.max(1, value - 1))}
className="rounded p-1 transition hover:bg-ctp-surface0 hover:text-ctp-text disabled:pointer-events-none disabled:opacity-40"
title="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="font-mono">
page {page} / {pageCount}
</span>
<button
type="button"
disabled={page >= pageCount || query.isPending}
onClick={() => setPage((value) => value + 1)}
className="rounded p-1 transition hover:bg-ctp-surface0 hover:text-ctp-text disabled:pointer-events-none disabled:opacity-40"
title="Next page"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-ctp-overlay0">
{selected ? selected.j_id : "select an item"}
</span>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
disabled={!selected || saving}
onClick={() => {
if (selected) onPick(selected);
}}
>
{saving ? "Saving…" : "Set avatar"}
</Button>
</div>
</footer>
</div>
</div>
);
}
export function AvatarCard() {
const user = useAuth((state) => state.user);
const loadUser = useAuth((state) => state.loadUser);
const [pickerOpen, setPickerOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (avatarJId: string | null) =>
api<User>("/api/auth/avatar/", {
method: "POST",
json: { avatar_j_id: avatarJId ?? "" },
}),
onSuccess: async (_user, avatarJId) => {
await loadUser();
setPickerOpen(false);
toast.ok(avatarJId ? "Profile picture updated." : "Profile picture removed.");
},
onError: (error) => toast.error(errorMessage(error)),
});
if (!user) return null;
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Profile picture
</h2>
<p className="mt-1 text-xs text-ctp-overlay0">
Shown next to your name in the shell. Pick any item from the library.
</p>
<div className="mt-4 flex flex-wrap items-center gap-4">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.username}
className="h-16 w-16 rounded-full border border-ctp-surface1 object-cover"
/>
) : (
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-lg font-semibold text-ctp-lavender">
{user.username.slice(0, 1).toUpperCase()}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
disabled={saveMutation.isPending}
onClick={() => setPickerOpen(true)}
>
{user.avatar_url ? "Change picture" : "Choose from library"}
</Button>
{user.avatar_url ? (
<Button
variant="ghost"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(null)}
>
Remove
</Button>
) : null}
</div>
</div>
{pickerOpen ? (
<AvatarPickerModal
currentJId={user.avatar_j_id}
saving={saveMutation.isPending}
onPick={(item) => saveMutation.mutate(item.j_id)}
onClose={() => setPickerOpen(false)}
/>
) : null}
</section>
);
}