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:
@@ -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