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()} + > +
+
+

+ Choose a profile picture +

+

+ Any library item works, including animations and videos (the + thumbnail is used). +

+
+
+ + { + setSearch(event.target.value); + setPage(1); + }} + autoFocus + /> +
+
+ +
+ {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 ( + + ); + })} +
+ )} +
+ +
+
+ + + page {page} / {pageCount} + + +
+
+ + {selected ? selected.j_id : "select an item"} + + + +
+
+
+
+ ); +} + +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} + ) : ( + + {user.username.slice(0, 1).toUpperCase()} + + )} +
+ + {user.avatar_url ? ( + + ) : null} +
+
+ + {pickerOpen ? ( + saveMutation.mutate(item.j_id)} + onClose={() => setPickerOpen(false)} + /> + ) : null} +
+ ); +}