feat: add admin user edit page with avatar, banner, and photo gallery

- Backend: adminGetUser query returns user + photos; adminUpdateUser now
  accepts avatarId/bannerId; new adminAddUserPhoto and adminRemoveUserPhoto
  mutations; AdminUserDetailType added to GraphQL schema
- Frontend: /admin/users/[id] page for editing name, avatar, banner, and
  managing the model photo gallery (upload multiple, delete individually)
- Admin users list: edit button per row linking to the detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:18:43 +01:00
parent e06a1915f2
commit d021acaf0b
6 changed files with 366 additions and 11 deletions

View File

@@ -1,7 +1,7 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CurrentUserType, UserType, AdminUserListType } from "../types/index";
import { users } from "../../db/schema/index";
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm";
import { requireRole } from "../../lib/acl";
@@ -129,6 +129,8 @@ builder.mutationField("adminUpdateUser", (t) =>
firstName: t.arg.string(),
lastName: t.arg.string(),
artistName: t.arg.string(),
avatarId: t.arg.string(),
bannerId: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin");
@@ -140,6 +142,8 @@ builder.mutationField("adminUpdateUser", (t) =>
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
if (args.artistName !== undefined && args.artistName !== null)
updates.artist_name = args.artistName;
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
const updated = await ctx.db
.update(users)
@@ -166,3 +170,60 @@ builder.mutationField("adminDeleteUser", (t) =>
},
}),
);
builder.queryField("adminGetUser", (t) =>
t.field({
type: AdminUserDetailType,
nullable: true,
args: {
userId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin");
const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1);
if (!user[0]) return null;
const photoRows = await ctx.db
.select({ id: files.id, filename: files.filename })
.from(user_photos)
.leftJoin(files, eq(user_photos.file_id, files.id))
.where(eq(user_photos.user_id, args.userId))
.orderBy(user_photos.sort);
return {
...user[0],
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })),
};
},
}),
);
builder.mutationField("adminAddUserPhoto", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
fileId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin");
await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId });
return true;
},
}),
);
builder.mutationField("adminRemoveUserPhoto", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
fileId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin");
await ctx.db
.delete(user_photos)
.where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId)));
return true;
},
}),
);

View File

@@ -24,6 +24,8 @@ import type {
UserGamification,
Achievement,
} from "@sexy.pivoine.art/types";
type AdminUserDetail = User & { photos: ModelPhoto[] };
import { builder } from "../builder";
export const FileType = builder.objectRef<MediaFile>("File").implement({
@@ -343,3 +345,24 @@ export const AdminUserListType = builder
total: t.exposeInt("total"),
}),
});
export const AdminUserDetailType = builder
.objectRef<AdminUserDetail>("AdminUserDetail")
.implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType] }),
}),
});

View File

@@ -1055,6 +1055,8 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
$firstName: String
$lastName: String
$artistName: String
$avatarId: String
$bannerId: String
) {
adminUpdateUser(
userId: $userId
@@ -1062,6 +1064,8 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
firstName: $firstName
lastName: $lastName
artistName: $artistName
avatarId: $avatarId
bannerId: $bannerId
) {
id
email
@@ -1070,6 +1074,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
artist_name
role
avatar
banner
date_created
}
}
@@ -1081,6 +1086,8 @@ export async function adminUpdateUser(input: {
firstName?: string;
lastName?: string;
artistName?: string;
avatarId?: string;
bannerId?: string;
}) {
return loggedApiCall(
"adminUpdateUser",
@@ -1111,6 +1118,66 @@ export async function adminDeleteUser(userId: string) {
);
}
const ADMIN_GET_USER_QUERY = gql`
query AdminGetUser($userId: String!) {
adminGetUser(userId: $userId) {
id
email
first_name
last_name
artist_name
slug
role
avatar
banner
description
tags
email_verified
date_created
photos {
id
filename
}
}
}
`;
export async function adminGetUser(userId: string, token?: string) {
return loggedApiCall(
"adminGetUser",
async () => {
const client = token ? getAuthClient(token) : getGraphQLClient();
const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId });
return data.adminGetUser;
},
{ userId },
);
}
const ADMIN_ADD_USER_PHOTO_MUTATION = gql`
mutation AdminAddUserPhoto($userId: String!, $fileId: String!) {
adminAddUserPhoto(userId: $userId, fileId: $fileId)
}
`;
export async function adminAddUserPhoto(userId: string, fileId: string) {
return loggedApiCall("adminAddUserPhoto", async () => {
await getGraphQLClient().request(ADMIN_ADD_USER_PHOTO_MUTATION, { userId, fileId });
});
}
const ADMIN_REMOVE_USER_PHOTO_MUTATION = gql`
mutation AdminRemoveUserPhoto($userId: String!, $fileId: String!) {
adminRemoveUserPhoto(userId: $userId, fileId: $fileId)
}
`;
export async function adminRemoveUserPhoto(userId: string, fileId: string) {
return loggedApiCall("adminRemoveUserPhoto", async () => {
await getGraphQLClient().request(ADMIN_REMOVE_USER_PHOTO_MUTATION, { userId, fileId });
});
}
// ─── Admin: Videos ────────────────────────────────────────────────────────────
const ADMIN_LIST_VIDEOS_QUERY = gql`

View File

@@ -160,15 +160,20 @@
</td>
<td class="px-4 py-3 text-muted-foreground">{formatDate(user.date_created)}</td>
<td class="px-4 py-3 text-right">
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={user.id === currentUserId}
onclick={() => confirmDelete(user)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
<div class="flex items-center justify-end gap-1">
<Button size="sm" variant="ghost" href="/admin/users/{user.id}">
<span class="icon-[ri--edit-line] h-4 w-4"></span>
</Button>
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={user.id === currentUserId}
onclick={() => confirmDelete(user)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</div>
</td>
</tr>
{/each}

View File

@@ -0,0 +1,9 @@
import { adminGetUser } from "$lib/services";
import { error } from "@sveltejs/kit";
export async function load({ params, cookies }) {
const token = cookies.get("session_token") || "";
const user = await adminGetUser(params.id, token).catch(() => null);
if (!user) throw error(404, "User not found");
return { user };
}

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import { invalidateAll } from "$app/navigation";
import {
adminUpdateUser,
adminAddUserPhoto,
adminRemoveUserPhoto,
uploadFile,
} from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
const { data } = $props();
let firstName = $state(data.user.first_name ?? "");
let lastName = $state(data.user.last_name ?? "");
let artistName = $state(data.user.artist_name ?? "");
let avatarId = $state<string | null>(data.user.avatar ?? null);
let bannerId = $state<string | null>(data.user.banner ?? null);
let saving = $state(false);
async function handleAvatarUpload(files: File[]) {
const file = files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
try {
const res = await uploadFile(fd);
avatarId = res.id;
toast.success("Avatar uploaded");
} catch {
toast.error("Avatar upload failed");
}
}
async function handleBannerUpload(files: File[]) {
const file = files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
try {
const res = await uploadFile(fd);
bannerId = res.id;
toast.success("Banner uploaded");
} catch {
toast.error("Banner upload failed");
}
}
async function handlePhotoUpload(files: File[]) {
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
try {
const res = await uploadFile(fd);
await adminAddUserPhoto(data.user.id, res.id);
} catch {
toast.error(`Failed to upload ${file.name}`);
return;
}
}
toast.success(`${files.length} photo${files.length > 1 ? "s" : ""} added`);
await invalidateAll();
}
async function removePhoto(fileId: string) {
try {
await adminRemoveUserPhoto(data.user.id, fileId);
toast.success("Photo removed");
await invalidateAll();
} catch {
toast.error("Failed to remove photo");
}
}
async function handleSave() {
saving = true;
try {
await adminUpdateUser({
userId: data.user.id,
firstName: firstName || undefined,
lastName: lastName || undefined,
artistName: artistName || undefined,
avatarId: avatarId || undefined,
bannerId: bannerId || undefined,
});
toast.success("Saved");
} catch (e: any) {
toast.error(e?.message ?? "Save failed");
} finally {
saving = false;
}
}
</script>
<div class="p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/users" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
</Button>
<div>
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
<p class="text-xs text-muted-foreground">{data.user.email} · {data.user.role}</p>
</div>
</div>
<div class="space-y-6">
<!-- Basic info -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="firstName">First name</Label>
<Input id="firstName" bind:value={firstName} />
</div>
<div class="space-y-1.5">
<Label for="lastName">Last name</Label>
<Input id="lastName" bind:value={lastName} />
</div>
</div>
<div class="space-y-1.5">
<Label for="artistName">Artist name</Label>
<Input id="artistName" bind:value={artistName} />
</div>
<!-- Avatar -->
<div class="space-y-1.5">
<Label>Avatar</Label>
{#if avatarId}
<img
src={getAssetUrl(avatarId, "thumbnail")}
alt=""
class="h-20 w-20 rounded-full object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
</div>
<!-- Banner -->
<div class="space-y-1.5">
<Label>Banner</Label>
{#if bannerId}
<img
src={getAssetUrl(bannerId, "preview")}
alt=""
class="w-full h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
</div>
<div class="flex gap-3">
<Button onclick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
<!-- Photo gallery -->
<div class="space-y-3 pt-4 border-t border-border/40">
<Label>Photo gallery</Label>
{#if data.user.photos && data.user.photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each data.user.photos as photo (photo.id)}
<div class="relative group">
<img
src={getAssetUrl(photo.id, "thumbnail")}
alt=""
class="w-full aspect-square object-cover rounded"
/>
<button
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded"
onclick={() => removePhoto(photo.id)}
type="button"
>
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No photos yet.</p>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
</div>
</div>
</div>