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:
@@ -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<string, string> = {
|
||||
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) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{/* Avatar */}
|
||||
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue text-white shadow-xs transition-transform duration-200 group-hover:scale-[1.02]">
|
||||
<span className="text-sm font-semibold">
|
||||
{getInitials(user.name || user.email || 'U')}
|
||||
</span>
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
|
||||
{/* Online indicator */}
|
||||
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" />
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { usePathname } from 'next/navigation'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { Route } from 'next'
|
||||
import { BookOpen, ClipboardList, Home, LogOut, Menu, Settings, User, X } from 'lucide-react'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
interface JuryNavProps {
|
||||
@@ -47,6 +47,7 @@ const navigation = [
|
||||
export function JuryNav({ user }: JuryNavProps) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,11 +91,7 @@ export function JuryNav({ user }: JuryNavProps) {
|
||||
className="gap-2 hidden sm:flex"
|
||||
size="sm"
|
||||
>
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(user.name || user.email || 'U')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { usePathname } from 'next/navigation'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { BookOpen, Home, LogOut, Menu, Settings, User, Users, X } from 'lucide-react'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
interface MentorNavProps {
|
||||
@@ -47,6 +47,7 @@ const navigation: { name: string; href: Route; icon: typeof Home }[] = [
|
||||
export function MentorNav({ user }: MentorNavProps) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,11 +91,7 @@ export function MentorNav({ user }: MentorNavProps) {
|
||||
className="gap-2 hidden sm:flex"
|
||||
size="sm"
|
||||
>
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(user.name || user.email || 'U')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { usePathname } from 'next/navigation'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { Route } from 'next'
|
||||
import { Home, BarChart3, Menu, X, LogOut, Eye, Settings } from 'lucide-react'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
interface ObserverNavProps {
|
||||
@@ -42,6 +42,7 @@ const navigation = [
|
||||
export function ObserverNav({ user }: ObserverNavProps) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-card">
|
||||
@@ -78,11 +79,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(user.name || user.email || 'O')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
|
||||
<span className="hidden sm:inline text-sm truncate max-w-[120px]">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user