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