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:
@@ -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),
|
||||
},
|
||||
}))
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
35
src/server/utils/avatar-url.ts
Normal file
35
src/server/utils/avatar-url.ts
Normal 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),
|
||||
}))
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user