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 { GraphQLError } from "graphql";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { CurrentUserType, UserType, AdminUserListType } from "../types/index";
|
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
|
||||||
import { users } from "../../db/schema/index";
|
import { users, user_photos, files } from "../../db/schema/index";
|
||||||
import { eq, ilike, or, count, and } from "drizzle-orm";
|
import { eq, ilike, or, count, and } from "drizzle-orm";
|
||||||
import { requireRole } from "../../lib/acl";
|
import { requireRole } from "../../lib/acl";
|
||||||
|
|
||||||
@@ -129,6 +129,8 @@ builder.mutationField("adminUpdateUser", (t) =>
|
|||||||
firstName: t.arg.string(),
|
firstName: t.arg.string(),
|
||||||
lastName: t.arg.string(),
|
lastName: t.arg.string(),
|
||||||
artistName: t.arg.string(),
|
artistName: t.arg.string(),
|
||||||
|
avatarId: t.arg.string(),
|
||||||
|
bannerId: t.arg.string(),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
requireRole(ctx, "admin");
|
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.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
|
||||||
if (args.artistName !== undefined && args.artistName !== null)
|
if (args.artistName !== undefined && args.artistName !== null)
|
||||||
updates.artist_name = args.artistName;
|
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
|
const updated = await ctx.db
|
||||||
.update(users)
|
.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,
|
UserGamification,
|
||||||
Achievement,
|
Achievement,
|
||||||
} from "@sexy.pivoine.art/types";
|
} from "@sexy.pivoine.art/types";
|
||||||
|
|
||||||
|
type AdminUserDetail = User & { photos: ModelPhoto[] };
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
|
|
||||||
export const FileType = builder.objectRef<MediaFile>("File").implement({
|
export const FileType = builder.objectRef<MediaFile>("File").implement({
|
||||||
@@ -343,3 +345,24 @@ export const AdminUserListType = builder
|
|||||||
total: t.exposeInt("total"),
|
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
|
$firstName: String
|
||||||
$lastName: String
|
$lastName: String
|
||||||
$artistName: String
|
$artistName: String
|
||||||
|
$avatarId: String
|
||||||
|
$bannerId: String
|
||||||
) {
|
) {
|
||||||
adminUpdateUser(
|
adminUpdateUser(
|
||||||
userId: $userId
|
userId: $userId
|
||||||
@@ -1062,6 +1064,8 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
|||||||
firstName: $firstName
|
firstName: $firstName
|
||||||
lastName: $lastName
|
lastName: $lastName
|
||||||
artistName: $artistName
|
artistName: $artistName
|
||||||
|
avatarId: $avatarId
|
||||||
|
bannerId: $bannerId
|
||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
@@ -1070,6 +1074,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
|||||||
artist_name
|
artist_name
|
||||||
role
|
role
|
||||||
avatar
|
avatar
|
||||||
|
banner
|
||||||
date_created
|
date_created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1081,6 +1086,8 @@ export async function adminUpdateUser(input: {
|
|||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
artistName?: string;
|
artistName?: string;
|
||||||
|
avatarId?: string;
|
||||||
|
bannerId?: string;
|
||||||
}) {
|
}) {
|
||||||
return loggedApiCall(
|
return loggedApiCall(
|
||||||
"adminUpdateUser",
|
"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 ────────────────────────────────────────────────────────────
|
// ─── Admin: Videos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ADMIN_LIST_VIDEOS_QUERY = gql`
|
const ADMIN_LIST_VIDEOS_QUERY = gql`
|
||||||
|
|||||||
@@ -160,15 +160,20 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-muted-foreground">{formatDate(user.date_created)}</td>
|
<td class="px-4 py-3 text-muted-foreground">{formatDate(user.date_created)}</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<Button
|
<div class="flex items-center justify-end gap-1">
|
||||||
size="sm"
|
<Button size="sm" variant="ghost" href="/admin/users/{user.id}">
|
||||||
variant="ghost"
|
<span class="icon-[ri--edit-line] h-4 w-4"></span>
|
||||||
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
</Button>
|
||||||
disabled={user.id === currentUserId}
|
<Button
|
||||||
onclick={() => confirmDelete(user)}
|
size="sm"
|
||||||
>
|
variant="ghost"
|
||||||
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
</Button>
|
disabled={user.id === currentUserId}
|
||||||
|
onclick={() => confirmDelete(user)}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/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