feat: add dedicated model photo separate from avatar

Adds a `photo` field to the users table (and a migration) that serves
as a dedicated profile/card image for models. This is now used in model
cards and on the model single page, while `avatar` is reserved for
comments, article authors, and the user profile page.

- DB: `photo` column on `users` with FK to `files`
- GraphQL: exposed on ModelType, UserType, AdminUserDetailType; photoId arg on adminUpdateUser
- Services: photo field in MODELS_QUERY, MODEL_BY_SLUG_QUERY, ADMIN_GET/UPDATE_USER
- Frontend: model cards and single page use `photo ?? avatar` fallback
- Admin: model photo upload section in user edit page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 10:54:27 +01:00
parent 2980c0b637
commit 4d81266cb1
10 changed files with 56 additions and 3 deletions

View File

@@ -21,6 +21,7 @@
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 photoId = $state<string | null>(data.user.photo ?? null);
let isAdmin = $state(data.user.is_admin ?? false);
let saving = $state(false);
@@ -52,6 +53,20 @@
}
}
async function handlePhotoUpload2(files: File[]) {
const file = files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
try {
const res = await uploadFile(fd);
photoId = res.id;
toast.success($_("admin.user_edit.model_photo_uploaded"));
} catch {
toast.error($_("admin.user_edit.model_photo_failed"));
}
}
async function handlePhotoUpload(files: File[]) {
for (const file of files) {
const fd = new FormData();
@@ -88,6 +103,7 @@
artistName: artistName || undefined,
avatarId: avatarId || undefined,
bannerId: bannerId || undefined,
photoId: photoId || undefined,
isAdmin,
});
toast.success($_("admin.user_edit.save_success"));
@@ -158,6 +174,20 @@
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
</div>
<!-- Model photo (used in cards & model page, not for avatar/comments) -->
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.model_photo")}</Label>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
{#if photoId}
<img
src={getAssetUrl(photoId, "preview")}
alt=""
class="w-full h-48 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload2} />
</div>
<!-- Admin flag -->
<label
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"

View File

@@ -110,7 +110,7 @@
>
<div class="relative">
<img
src={getAssetUrl(model.avatar, "preview")}
src={getAssetUrl(model.photo ?? model.avatar, "preview")}
alt={model.artist_name}
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>

View File

@@ -31,7 +31,7 @@
<Meta
title={data.model.artist_name ?? ""}
description={data.model.description}
image={getAssetUrl(data.model.avatar, "medium")!}
image={getAssetUrl(data.model.photo ?? data.model.avatar, "medium")!}
/>
<div
@@ -67,7 +67,7 @@
<!-- Profile Image -->
<div class="relative">
<img
src={getAssetUrl(data.model.avatar, "thumbnail")}
src={getAssetUrl(data.model.photo ?? data.model.avatar, "preview")}
alt="${data.model.artist_name}"
class="w-32 h-32 rounded-2xl object-cover ring-4 ring-primary/20"
/>