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

@@ -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 (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
@@ -83,15 +92,11 @@ async function MentorsContent() {
</TableRow>
</TableHeader>
<TableBody>
{mentors.map((mentor) => (
{mentorsWithAvatars.map((mentor) => (
<TableRow key={mentor.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(mentor.name || mentor.email || 'M')}
</AvatarFallback>
</Avatar>
<UserAvatar user={mentor} avatarUrl={mentor.avatarUrl} size="sm" />
<div>
<p className="font-medium">{mentor.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">
@@ -150,16 +155,12 @@ async function MentorsContent() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{mentors.map((mentor) => (
{mentorsWithAvatars.map((mentor) => (
<Card key={mentor.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
{getInitials(mentor.name || mentor.email || 'M')}
</AvatarFallback>
</Avatar>
<UserAvatar user={mentor} avatarUrl={mentor.avatarUrl} size="md" />
<div>
<CardTitle className="text-base">
{mentor.name || 'Unnamed'}