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:
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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] }),
|
||||||
|
|||||||
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "photo" text REFERENCES "files"("id") ON DELETE set null;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user