From 3c49d2be2e08890aef54e057be107b252d86e2eb Mon Sep 17 00:00:00 2001
From: JakeBreath
Date: Thu, 17 Sep 2026 22:31:05 -0500
Subject: [PATCH] Self-service avatar picker in Account
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
ROADMAP.md | 7 +-
backend/apps/accounts/urls.py | 3 +-
backend/apps/accounts/views.py | 60 +++-
frontend/src/features/account/AccountPage.tsx | 3 +
frontend/src/features/account/AvatarCard.tsx | 259 ++++++++++++++++++
5 files changed, 313 insertions(+), 19 deletions(-)
create mode 100644 frontend/src/features/account/AvatarCard.tsx
diff --git a/ROADMAP.md b/ROADMAP.md
index b18c662..3f2d10c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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)
diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py
index 0f4597a..927b9da 100644
--- a/backend/apps/accounts/urls.py
+++ b/backend/apps/accounts/urls.py
@@ -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"),
]
diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py
index d65fed2..e9f36fb 100644
--- a/backend/apps/accounts/views.py
+++ b/backend/apps/accounts/views.py
@@ -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,22 +172,13 @@ 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
+ item, error = resolve_avatar_item(data.get("avatar_j_id"))
+ if error:
+ return Response(
+ {"detail": error},
+ status=status.HTTP_400_BAD_REQUEST,
)
- if item is None:
- return Response(
- {"detail": f"No library item {value}."},
- status=status.HTTP_400_BAD_REQUEST,
- )
- user.avatar = item
+ user.avatar = item
update_fields.append("avatar")
if update_fields:
user.save(update_fields=update_fields)
diff --git a/frontend/src/features/account/AccountPage.tsx b/frontend/src/features/account/AccountPage.tsx
index 66e2f95..ebd9919 100644
--- a/frontend/src/features/account/AccountPage.tsx
+++ b/frontend/src/features/account/AccountPage.tsx
@@ -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() {
) : null}
+ {user ? : null}
{loading && !credentials ? (
diff --git a/frontend/src/features/account/AvatarCard.tsx b/frontend/src/features/account/AvatarCard.tsx
new file mode 100644
index 0000000..cd9374b
--- /dev/null
+++ b/frontend/src/features/account/AvatarCard.tsx
@@ -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
(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>(`/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 (
+
+
event.stopPropagation()}
+ >
+
+
+
+ {query.isPending ? (
+
+
+
+ ) : query.isError ? (
+
+ {errorMessage(query.error)}
+
+ ) : items.length === 0 ? (
+
+ Nothing matches that search.
+
+ ) : (
+
+ {items.map((item) => {
+ const isSelected = selected?.j_id === item.j_id;
+ const isCurrent = item.j_id === currentJId;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
+
+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("/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 (
+
+
+ Profile picture
+
+
+ Shown next to your name in the shell. Pick any item from the library.
+
+
+
+ {user.avatar_url ? (
+

+ ) : (
+
+ {user.username.slice(0, 1).toUpperCase()}
+
+ )}
+
+
+ {user.avatar_url ? (
+
+ ) : null}
+
+
+
+ {pickerOpen ? (
+ saveMutation.mutate(item.j_id)}
+ onClose={() => setPickerOpen(false)}
+ />
+ ) : null}
+
+ );
+}