Add image cropping to avatar upload and show avatars platform-wide

- Add react-easy-crop for circular crop + zoom UI on avatar upload
- Create server-side getUserAvatarUrl utility for generating pre-signed URLs
- Update all nav components (admin, jury, mentor, observer) to show user avatars
- Add avatar URLs to user list, mentor list, and project detail API responses
- Replace initials-only avatars with UserAvatar component across admin pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 13:19:28 +01:00
parent f9f88d68ab
commit 8fda8deded
14 changed files with 346 additions and 140 deletions

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import {
generateAIAssignments,
generateFallbackAssignments,
@@ -31,14 +32,25 @@ export const assignmentRouter = router({
listByProject: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.assignment.findMany({
const assignments = await ctx.prisma.assignment.findMany({
where: { projectId: input.projectId },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
},
orderBy: { createdAt: 'desc' },
})
// Attach avatar URLs
return Promise.all(
assignments.map(async (a) => ({
...a,
user: {
...a.user,
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
},
}))
)
}),
/**

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
export const projectRouter = router({
/**
@@ -91,7 +92,7 @@ export const projectRouter = router({
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true },
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
@@ -99,7 +100,7 @@ export const projectRouter = router({
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true },
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
@@ -123,7 +124,35 @@ export const projectRouter = router({
}
}
return project
// Attach avatar URLs to team members and mentor
const teamMembersWithAvatars = await Promise.all(
project.teamMembers.map(async (member) => ({
...member,
user: {
...member.user,
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
},
}))
)
const mentorWithAvatar = project.mentorAssignment
? {
...project.mentorAssignment,
mentor: {
...project.mentorAssignment.mentor,
avatarUrl: await getUserAvatarUrl(
project.mentorAssignment.mentor.profileImageKey,
project.mentorAssignment.mentor.profileImageProvider
),
},
}
: null
return {
...project,
teamMembers: teamMembersWithAvatars,
mentorAssignment: mentorWithAvatar,
}
}),
/**

View File

@@ -5,6 +5,7 @@ import type { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
@@ -204,6 +205,8 @@ export const userRouter = router({
status: true,
expertiseTags: true,
maxAssignments: true,
profileImageKey: true,
profileImageProvider: true,
createdAt: true,
lastLoginAt: true,
_count: {
@@ -214,8 +217,10 @@ export const userRouter = router({
ctx.prisma.user.count({ where }),
])
const usersWithAvatars = await attachAvatarUrls(users)
return {
users,
users: usersWithAvatars,
total,
page,
perPage,
@@ -534,6 +539,8 @@ export const userRouter = router({
name: true,
expertiseTags: true,
maxAssignments: true,
profileImageKey: true,
profileImageProvider: true,
_count: {
select: {
assignments: input.roundId
@@ -545,7 +552,7 @@ export const userRouter = router({
orderBy: { name: 'asc' },
})
return users.map((u) => ({
const mapped = users.map((u) => ({
...u,
currentAssignments: u._count.assignments,
availableSlots:
@@ -553,6 +560,8 @@ export const userRouter = router({
? Math.max(0, u.maxAssignments - u._count.assignments)
: null,
}))
return attachAvatarUrls(mapped)
}),
/**

View File

@@ -0,0 +1,35 @@
import { createStorageProvider, type StorageProviderType } from '@/lib/storage'
/**
* Generate a pre-signed download URL for a user's avatar.
* Returns null if the user has no avatar.
*/
export async function getUserAvatarUrl(
profileImageKey: string | null | undefined,
profileImageProvider: string | null | undefined
): Promise<string | null> {
if (!profileImageKey) return null
try {
const providerType = (profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
return await provider.getDownloadUrl(profileImageKey)
} catch {
return null
}
}
/**
* Batch-generate avatar URLs for multiple users.
* Adds `avatarUrl` field to each user object.
*/
export async function attachAvatarUrls<
T extends { profileImageKey?: string | null; profileImageProvider?: string | null }
>(users: T[]): Promise<(T & { avatarUrl: string | null })[]> {
return Promise.all(
users.map(async (user) => ({
...user,
avatarUrl: await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider),
}))
)
}