191 lines
5.7 KiB
Svelte
191 lines
5.7 KiB
Svelte
|
|
<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>
|