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'}

View File

@@ -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 }) {
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{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 } }) => (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
{member.role === 'LEAD' ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{getInitials(member.user.name || member.user.email)}
</span>
)}
</div>
</div>
) : (
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
@@ -417,11 +415,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{project.mentorAssignment ? (
<div className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="text-sm">
{getInitials(project.mentorAssignment.mentor.name || project.mentorAssignment.mentor.email)}
</AvatarFallback>
</Avatar>
<UserAvatar
user={project.mentorAssignment.mentor}
avatarUrl={project.mentorAssignment.mentor.avatarUrl}
size="md"
/>
<div>
<p className="font-medium">
{project.mentorAssignment.mentor.name || 'Unnamed'}
@@ -519,11 +517,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<TableRow key={assignment.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(assignment.user.name || assignment.user.email)}
</AvatarFallback>
</Avatar>
<UserAvatar
user={assignment.user}
avatarUrl={assignment.user.avatarUrl}
size="sm"
/>
<div>
<p className="font-medium text-sm">
{assignment.user.name || 'Unnamed'}

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,
@@ -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 (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
@@ -97,15 +106,11 @@ async function UsersContent() {
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
{usersWithAvatars.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="sm" />
<div>
<p className="font-medium">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">
@@ -172,16 +177,12 @@ async function UsersContent() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{users.map((user) => (
{usersWithAvatars.map((user) => (
<Card key={user.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(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="md" />
<div>
<CardTitle className="text-base">
{user.name || 'Unnamed'}