diff --git a/package-lock.json b/package-lock.json index f08a95b..e41491c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "papaparse": "^5.4.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-easy-crop": "^5.5.6", "react-hook-form": "^7.54.2", "react-leaflet": "^5.0.0", "react-phone-number-input": "^3.4.14", @@ -10637,6 +10638,12 @@ "node": ">=6.0.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/nypm": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz", @@ -11643,6 +11650,20 @@ "react": "^19.2.4" } }, + "node_modules/react-easy-crop": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz", + "integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-hook-form": { "version": "7.71.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", diff --git a/package.json b/package.json index 6d5bb03..0f40791 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "papaparse": "^5.4.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-easy-crop": "^5.5.6", "react-hook-form": "^7.54.2", "react-leaflet": "^5.0.0", "react-phone-number-input": "^3.4.14", diff --git a/src/app/(admin)/admin/mentors/page.tsx b/src/app/(admin)/admin/mentors/page.tsx index fe71849..9438d7e 100644 --- a/src/app/(admin)/admin/mentors/page.tsx +++ b/src/app/(admin)/admin/mentors/page.tsx @@ -13,7 +13,8 @@ import { import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { UserAvatar } from '@/components/shared/user-avatar' +import { getUserAvatarUrl } from '@/server/utils/avatar-url' import { Table, TableBody, @@ -24,7 +25,7 @@ import { } from '@/components/ui/table' import type { Route } from 'next' import { Plus, GraduationCap, Eye } from 'lucide-react' -import { formatDate, getInitials } from '@/lib/utils' +import { formatDate } from '@/lib/utils' async function MentorsContent() { const mentors = await prisma.user.findMany({ @@ -41,7 +42,15 @@ async function MentorsContent() { orderBy: [{ status: 'asc' }, { name: 'asc' }], }) - if (mentors.length === 0) { + // Generate avatar URLs + const mentorsWithAvatars = await Promise.all( + mentors.map(async (mentor) => ({ + ...mentor, + avatarUrl: await getUserAvatarUrl(mentor.profileImageKey, mentor.profileImageProvider), + })) + ) + + if (mentorsWithAvatars.length === 0) { return ( @@ -83,15 +92,11 @@ async function MentorsContent() { - {mentors.map((mentor) => ( + {mentorsWithAvatars.map((mentor) => (
- - - {getInitials(mentor.name || mentor.email || 'M')} - - +

{mentor.name || 'Unnamed'}

@@ -150,16 +155,12 @@ async function MentorsContent() { {/* Mobile card view */}

- {mentors.map((mentor) => ( + {mentorsWithAvatars.map((mentor) => (
- - - {getInitials(mentor.name || mentor.email || 'M')} - - +
{mentor.name || 'Unnamed'} diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 3f9fb69..c16b036 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -26,7 +26,7 @@ import { import { FileViewer } from '@/components/shared/file-viewer' import { FileUpload } from '@/components/shared/file-upload' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' -import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { UserAvatar } from '@/components/shared/user-avatar' import { ArrowLeft, Edit, @@ -47,7 +47,7 @@ import { Crown, UserPlus, } from 'lucide-react' -import { formatDate, formatDateOnly, getInitials } from '@/lib/utils' +import { formatDate, formatDateOnly } from '@/lib/utils' interface PageProps { params: Promise<{ id: string }> @@ -360,17 +360,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
- {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string } }) => ( + {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => (
-
- {member.role === 'LEAD' ? ( + {member.role === 'LEAD' ? ( +
- ) : ( - - {getInitials(member.user.name || member.user.email)} - - )} -
+
+ ) : ( + + )}

@@ -417,11 +415,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { {project.mentorAssignment ? (

- - - {getInitials(project.mentorAssignment.mentor.name || project.mentorAssignment.mentor.email)} - - +

{project.mentorAssignment.mentor.name || 'Unnamed'} @@ -519,11 +517,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {

- - - {getInitials(assignment.user.name || assignment.user.email)} - - +

{assignment.user.name || 'Unnamed'} diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx index b8cb986..d5ce6fe 100644 --- a/src/app/(admin)/admin/users/page.tsx +++ b/src/app/(admin)/admin/users/page.tsx @@ -13,7 +13,8 @@ import { import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { UserAvatar } from '@/components/shared/user-avatar' +import { getUserAvatarUrl } from '@/server/utils/avatar-url' import { Table, TableBody, @@ -23,7 +24,7 @@ import { TableRow, } from '@/components/ui/table' import { Plus, Users } from 'lucide-react' -import { formatDate, getInitials } from '@/lib/utils' +import { formatDate } from '@/lib/utils' import { UserActions, UserMobileActions } from '@/components/admin/user-actions' async function UsersContent() { @@ -48,7 +49,15 @@ async function UsersContent() { orderBy: [{ role: 'asc' }, { name: 'asc' }], }) - if (users.length === 0) { + // Generate avatar URLs + const usersWithAvatars = await Promise.all( + users.map(async (user) => ({ + ...user, + avatarUrl: await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider), + })) + ) + + if (usersWithAvatars.length === 0) { return ( @@ -97,15 +106,11 @@ async function UsersContent() { - {users.map((user) => ( + {usersWithAvatars.map((user) => (

- - - {getInitials(user.name || user.email || 'U')} - - +

{user.name || 'Unnamed'}

@@ -172,16 +177,12 @@ async function UsersContent() { {/* Mobile card view */}

- {users.map((user) => ( + {usersWithAvatars.map((user) => (
- - - {getInitials(user.name || user.email || 'U')} - - +
{user.name || 'Unnamed'} diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index 551cd6a..578b7cd 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -37,6 +37,8 @@ import { import { getInitials } from '@/lib/utils' import { Logo } from '@/components/shared/logo' import { EditionSelector } from '@/components/shared/edition-selector' +import { UserAvatar } from '@/components/shared/user-avatar' +import { trpc } from '@/lib/trpc/client' interface AdminSidebarProps { user: { @@ -125,6 +127,7 @@ const roleLabels: Record = { export function AdminSidebar({ user }: AdminSidebarProps) { const pathname = usePathname() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) + const { data: avatarUrl } = trpc.avatar.getUrl.useQuery() const isSuperAdmin = user.role === 'SUPER_ADMIN' const roleLabel = roleLabels[user.role || ''] || 'User' @@ -242,10 +245,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) { + + ) : ( + <> + {/* Current avatar preview */} +
+ +
+ + {/* File input */} +
+ + +
+ + )}
- {currentAvatarUrl && !preview && ( + {currentAvatarUrl && !imageSrc && ( - + {imageSrc && ( + + )}
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 010fa8b..6204188 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -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), + }, + })) + ) }), /** diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index ebf6865..1d8de08 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -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, + } }), /** diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 01a1b5c..3563d79 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -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) }), /** diff --git a/src/server/utils/avatar-url.ts b/src/server/utils/avatar-url.ts new file mode 100644 index 0000000..8c96ab7 --- /dev/null +++ b/src/server/utils/avatar-url.ts @@ -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 { + 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), + })) + ) +}