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 ?+ Any library item works, including animations and videos (the + thumbnail is used). +
++ {errorMessage(query.error)} +
+ ) : items.length === 0 ? ( ++ Nothing matches that search. +
+ ) : ( ++ Shown next to your name in the shell. Pick any item from the library. +
+ +