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

@@ -29,6 +29,7 @@ export const users = pgTable(
role: roleEnum("role").notNull().default("viewer"), role: roleEnum("role").notNull().default("viewer"),
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }), avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
banner: text("banner").references(() => files.id, { onDelete: "set null" }), banner: text("banner").references(() => files.id, { onDelete: "set null" }),
photo: text("photo").references(() => files.id, { onDelete: "set null" }),
is_admin: boolean("is_admin").notNull().default(false), is_admin: boolean("is_admin").notNull().default(false),
email_verified: boolean("email_verified").notNull().default(false), email_verified: boolean("email_verified").notNull().default(false),
email_verify_token: text("email_verify_token"), email_verify_token: text("email_verify_token"),

View File

@@ -134,6 +134,7 @@ builder.mutationField("adminUpdateUser", (t) =>
artistName: t.arg.string(), artistName: t.arg.string(),
avatarId: t.arg.string(), avatarId: t.arg.string(),
bannerId: t.arg.string(), bannerId: t.arg.string(),
photoId: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireAdmin(ctx); requireAdmin(ctx);
@@ -149,6 +150,7 @@ builder.mutationField("adminUpdateUser", (t) =>
updates.artist_name = args.artistName; updates.artist_name = args.artistName;
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId; if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId; if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
if (args.photoId !== undefined && args.photoId !== null) updates.photo = args.photoId;
const updated = await ctx.db const updated = await ctx.db
.update(users) .update(users)

View File

@@ -55,6 +55,7 @@ export const UserType = builder.objectRef<User>("User").implement({
is_admin: t.exposeBoolean("is_admin"), is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
}), }),
@@ -75,6 +76,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
is_admin: t.exposeBoolean("is_admin"), is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
}), }),
@@ -133,6 +135,7 @@ export const ModelType = builder.objectRef<Model>("Model").implement({
description: t.exposeString("description", { nullable: true }), description: t.exposeString("description", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }), photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
@@ -416,6 +419,7 @@ export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUser
is_admin: t.exposeBoolean("is_admin"), is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType] }), photos: t.expose("photos", { type: [ModelPhotoType] }),

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "photo" text REFERENCES "files"("id") ON DELETE set null;

View File

@@ -962,6 +962,10 @@ export default {
artist_name: "Artist name", artist_name: "Artist name",
avatar: "Avatar", avatar: "Avatar",
banner: "Banner", banner: "Banner",
model_photo: "Model photo",
model_photo_hint: "Used in model cards and on the model profile page. Avatar is used for comments and article authors.",
model_photo_uploaded: "Model photo uploaded",
model_photo_failed: "Model photo upload failed",
is_admin: "Administrator", is_admin: "Administrator",
is_admin_hint: "Grants full admin access to the dashboard", is_admin_hint: "Grants full admin access to the dashboard",
photos: "Photo gallery", photos: "Photo gallery",

View File

@@ -491,6 +491,7 @@ const MODELS_QUERY = gql`
description description
avatar avatar
banner banner
photo
tags tags
date_created date_created
photos { photos {
@@ -540,6 +541,7 @@ const MODEL_BY_SLUG_QUERY = gql`
description description
avatar avatar
banner banner
photo
tags tags
date_created date_created
photos { photos {
@@ -1151,6 +1153,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
$artistName: String $artistName: String
$avatarId: String $avatarId: String
$bannerId: String $bannerId: String
$photoId: String
) { ) {
adminUpdateUser( adminUpdateUser(
userId: $userId userId: $userId
@@ -1161,6 +1164,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
artistName: $artistName artistName: $artistName
avatarId: $avatarId avatarId: $avatarId
bannerId: $bannerId bannerId: $bannerId
photoId: $photoId
) { ) {
id id
email email
@@ -1171,6 +1175,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
is_admin is_admin
avatar avatar
banner banner
photo
date_created date_created
} }
} }
@@ -1185,6 +1190,7 @@ export async function adminUpdateUser(input: {
artistName?: string; artistName?: string;
avatarId?: string; avatarId?: string;
bannerId?: string; bannerId?: string;
photoId?: string;
}) { }) {
return loggedApiCall( return loggedApiCall(
"adminUpdateUser", "adminUpdateUser",
@@ -1228,6 +1234,7 @@ const ADMIN_GET_USER_QUERY = gql`
is_admin is_admin
avatar avatar
banner banner
photo
description description
tags tags
email_verified email_verified

View File

@@ -21,6 +21,7 @@
let artistName = $state(data.user.artist_name ?? ""); let artistName = $state(data.user.artist_name ?? "");
let avatarId = $state<string | null>(data.user.avatar ?? null); let avatarId = $state<string | null>(data.user.avatar ?? null);
let bannerId = $state<string | null>(data.user.banner ?? 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 isAdmin = $state(data.user.is_admin ?? false);
let saving = $state(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[]) { async function handlePhotoUpload(files: File[]) {
for (const file of files) { for (const file of files) {
const fd = new FormData(); const fd = new FormData();
@@ -88,6 +103,7 @@
artistName: artistName || undefined, artistName: artistName || undefined,
avatarId: avatarId || undefined, avatarId: avatarId || undefined,
bannerId: bannerId || undefined, bannerId: bannerId || undefined,
photoId: photoId || undefined,
isAdmin, isAdmin,
}); });
toast.success($_("admin.user_edit.save_success")); toast.success($_("admin.user_edit.save_success"));
@@ -158,6 +174,20 @@
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
</div> </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 --> <!-- Admin flag -->
<label <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" 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"> <div class="relative">
<img <img
src={getAssetUrl(model.avatar, "preview")} src={getAssetUrl(model.photo ?? model.avatar, "preview")}
alt={model.artist_name} alt={model.artist_name}
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted" class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/> />

View File

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

View File

@@ -27,6 +27,8 @@ export interface User {
avatar: string | null; avatar: string | null;
/** UUID of the banner file */ /** UUID of the banner file */
banner: string | null; banner: string | null;
/** UUID of the dedicated model profile/card image */
photo: string | null;
email_verified: boolean; email_verified: boolean;
date_created: Date; date_created: Date;
} }
@@ -81,6 +83,8 @@ export interface Model {
description: string | null; description: string | null;
avatar: string | null; avatar: string | null;
banner: string | null; banner: string | null;
/** UUID of the dedicated model profile/card image (distinct from avatar) */
photo: string | null;
tags: string[] | null; tags: string[] | null;
date_created: Date; date_created: Date;
photos?: ModelPhoto[]; photos?: ModelPhoto[];