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:
@@ -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;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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] }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
190
packages/frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
190
packages/frontend/src/routes/admin/users/[id]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user