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`)
|
(remembered per browser in `localStorage`)
|
||||||
- [x] Toasts for action results (success/error) plus a shared confirm dialog for
|
- [x] Toasts for action results (success/error) plus a shared confirm dialog for
|
||||||
destructive actions; form-field validation stays inline
|
destructive actions; form-field validation stays inline
|
||||||
- [ ] Mobile drawer polish for metadata panels (design spec §layout)
|
- [x] Mobile bottom sheets for metadata panels (<768px): detail asides
|
||||||
- [x] Profile pictures: staff Users page sets avatars from library J-IDs
|
(Library/Online/Similar) and long pool descriptions
|
||||||
(self-service picker in Account still pending)
|
- [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)
|
- [ ] Profile extras (per-user browse preferences)
|
||||||
- [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)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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 E621CredentialsView, LogoutView, MeView, RegisterView
|
from .views import AvatarView, E621CredentialsView, LogoutView, MeView, RegisterView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("register/", RegisterView.as_view(), name="register"),
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
path("token/", obtain_auth_token, name="login"),
|
path("token/", obtain_auth_token, name="login"),
|
||||||
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("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
|
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(
|
class UserViewSet(
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
@@ -133,19 +172,10 @@ class UserViewSet(
|
|||||||
user.role = data["role"]
|
user.role = data["role"]
|
||||||
update_fields.append("role")
|
update_fields.append("role")
|
||||||
if "avatar_j_id" in data:
|
if "avatar_j_id" in data:
|
||||||
value = str(data.get("avatar_j_id") or "").strip()
|
item, error = resolve_avatar_item(data.get("avatar_j_id"))
|
||||||
if not value:
|
if error:
|
||||||
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:
|
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": f"No library item {value}."},
|
{"detail": error},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
user.avatar = item
|
user.avatar = item
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { useAuth } from "@/store/auth";
|
|||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
|
import { AvatarCard } from "./AvatarCard";
|
||||||
|
|
||||||
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" },
|
||||||
{ value: "https://e926.net", label: "e926.net — safe mirror" },
|
{ value: "https://e926.net", label: "e926.net — safe mirror" },
|
||||||
@@ -208,6 +210,7 @@ export default function AccountPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
{user ? <AvatarCard /> : 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,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