Skip to content

Commit 6b6d16b

Browse files
Merge pull request #308 from DevLoversTeam/feat/user-avatars-leaderboard
feat(leaderboard): add user avatars to table rows with DiceBear fallback
2 parents 5fbff7f + 69a6c26 commit 6b6d16b

5 files changed

Lines changed: 73 additions & 27 deletions

File tree

frontend/components/leaderboard/LeaderboardPodium.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
import { motion } from 'framer-motion';
44
import { Crown } from 'lucide-react';
5-
import Image from 'next/image';
65

76
import { cn } from '@/lib/utils';
87

98
import { User } from './types';
9+
import { UserAvatar } from './UserAvatar';
1010

1111
const rankConfig = {
1212
1: {
@@ -52,7 +52,7 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) {
5252
].filter(Boolean) as User[];
5353

5454
return (
55-
<div className="mx-auto flex h-[350px] w-full max-w-3xl items-end justify-center gap-4 md:gap-8">
55+
<div className="mx-auto flex h-87.5 w-full max-w-3xl items-end justify-center gap-4 md:gap-8">
5656
{podiumOrder.map(user => {
5757
const rank = user.rank as 1 | 2 | 3;
5858
const isFirst = rank === 1;
@@ -86,11 +86,11 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) {
8686
)}
8787
>
8888
<div className="relative h-full w-full overflow-hidden rounded-full bg-gray-100 dark:bg-black">
89-
<Image
89+
<UserAvatar
9090
src={user.avatar}
91-
alt={user.username}
92-
fill
93-
className="object-cover"
91+
username={user.username}
92+
userId={user.userId}
93+
sizes="(min-width: 768px) 80px, 56px"
9494
/>
9595
</div>
9696

@@ -105,7 +105,7 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) {
105105
</div>
106106
</div>
107107

108-
<div className="max-w-[90px] truncate text-xs font-bold text-gray-900 md:max-w-[140px] md:text-base dark:text-white">
108+
<div className="max-w-22.5 truncate text-xs font-bold text-gray-900 md:max-w-35 md:text-base dark:text-white">
109109
{user.username}
110110
</div>
111111

frontend/components/leaderboard/LeaderboardTable.tsx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl';
77
import { cn } from '@/lib/utils';
88

99
import { CurrentUser, User } from './types';
10+
import { UserAvatar } from './UserAvatar';
1011

1112
interface LeaderboardTableProps {
1213
users: User[];
@@ -79,7 +80,7 @@ export function LeaderboardTable({
7980
• • •
8081
</div>
8182

82-
<div className="overflow-hidden rounded-2xl border-2 border-[var(--accent-primary)] bg-white shadow-[0_0_20px_var(--accent-primary)] backdrop-blur-md dark:bg-white/5">
83+
<div className="overflow-hidden rounded-2xl border-2 border-(--accent-primary) bg-white shadow-[0_0_20px_var(--accent-primary)] backdrop-blur-md dark:bg-white/5">
8384
<div className="w-full">
8485
<table className="w-full table-fixed border-separate border-spacing-0 text-left">
8586
<tbody>
@@ -106,21 +107,16 @@ function TableRow({
106107
const cellClass =
107108
'px-2 sm:px-6 py-3 sm:py-4 border-b border-slate-100 dark:border-white/5';
108109

109-
const leftBorderClass = isCurrentUser
110-
? 'border-l-[1px] sm:border-l-[1px] border-l-transparent'
111-
: 'border-l-[1px] sm:border-l-[1px] border-l-transparent';
112-
113-
const rightBorderClass = isCurrentUser
114-
? 'border-r-[1px] sm:border-r-[1px] border-r-transparent'
115-
: 'border-r-[1px] sm:border-r-[1px] border-r-transparent';
110+
const leftBorderClass = 'border-l border-l-transparent';
111+
const rightBorderClass = 'border-r border-r-transparent';
116112

117113
return (
118114
<tr
119115
className={cn(
120116
'group transition-all duration-300',
121117
isCurrentUser
122118
? 'bg-[color-mix(in_srgb,var(--accent-primary),transparent_90%)] shadow-inner'
123-
: 'hover:bg-slate-50/60 dark:hover:bg-white/[0.04]'
119+
: 'hover:bg-slate-50/60 dark:hover:bg-white/4'
124120
)}
125121
>
126122
<td className={cn(cellClass, leftBorderClass)}>
@@ -133,37 +129,40 @@ function TableRow({
133129
<div className="flex items-center gap-2 overflow-hidden sm:gap-4">
134130
<div
135131
className={cn(
136-
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-xs font-bold transition-all duration-300 sm:h-10 sm:w-10 sm:text-sm',
132+
'relative h-8 w-8 shrink-0 overflow-hidden rounded-full border transition-all duration-300 sm:h-10 sm:w-10',
137133
isCurrentUser
138-
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)] text-white shadow-[0_0_1px_var(--accent-primary)]'
139-
: 'border-slate-200 bg-slate-100 text-slate-600 group-hover:border-[var(--accent-primary)] group-hover:text-[var(--accent-primary)] dark:border-white/10 dark:bg-gradient-to-br dark:from-slate-800 dark:to-slate-900 dark:text-slate-300'
134+
? 'border-(--accent-primary) shadow-[0_0_1px_var(--accent-primary)]'
135+
: 'border-slate-200 group-hover:border-(--accent-primary) dark:border-white/10'
140136
)}
141-
aria-hidden="true"
142137
>
143-
{user.username.slice(0, 1).toUpperCase()}
138+
<UserAvatar
139+
src={user.avatar}
140+
username={user.username}
141+
userId={user.userId}
142+
/>
144143
</div>
145144

146145
<div className="flex min-w-0 flex-col">
147146
<span
148147
className={cn(
149148
'flex items-center gap-1 text-sm font-medium transition-colors sm:gap-2',
150149
isCurrentUser
151-
? 'text-sm font-black text-[var(--accent-primary)] sm:text-base'
152-
: 'text-slate-700 group-hover:text-[var(--accent-primary)] dark:text-slate-200 dark:group-hover:text-[var(--accent-primary)]'
150+
? 'text-sm font-black text-(--accent-primary) sm:text-base'
151+
: 'text-slate-700 group-hover:text-(--accent-primary) dark:text-slate-200 dark:group-hover:text-(--accent-primary)'
153152
)}
154153
>
155154
<span className="truncate">{user.username}</span>
156155

157156
{isCurrentUser && (
158-
<div className="relative ml-1 flex h-5 w-5 flex-shrink-0 items-center justify-center sm:h-8 sm:w-8">
157+
<div className="relative ml-1 flex h-5 w-5 shrink-0 items-center justify-center sm:h-8 sm:w-8">
159158
<motion.div
160159
animate={{ scale: [1, 1.2, 1] }}
161160
transition={{
162161
repeat: Infinity,
163162
duration: 0.8,
164163
ease: 'easeInOut',
165164
}}
166-
className="absolute inset-0 text-[var(--accent-primary)]"
165+
className="absolute inset-0 text-(--accent-primary)"
167166
>
168167
<svg
169168
viewBox="0 0 24 24"
@@ -194,7 +193,7 @@ function TableRow({
194193
className={cn(
195194
'inline-block font-mono font-bold transition-all',
196195
isCurrentUser
197-
? 'scale-110 text-sm text-[var(--accent-primary)] drop-shadow-sm sm:text-lg'
196+
? 'scale-110 text-sm text-(--accent-primary) drop-shadow-sm sm:text-lg'
198197
: 'text-sm text-slate-700 group-hover:scale-105 sm:text-base dark:text-slate-300'
199198
)}
200199
>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client';
2+
3+
import Image from 'next/image';
4+
import { useState } from 'react';
5+
6+
import { cn } from '@/lib/utils';
7+
8+
interface UserAvatarProps {
9+
src: string;
10+
username: string;
11+
userId?: string;
12+
className?: string;
13+
sizes?: string;
14+
}
15+
16+
function UserAvatarInner({
17+
src,
18+
username,
19+
userId,
20+
className,
21+
sizes = '40px',
22+
}: UserAvatarProps) {
23+
const [hasError, setHasError] = useState(false);
24+
25+
const seed = userId ? `${username}-${userId}` : username;
26+
const fallback = `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(seed)}`;
27+
const imgSrc = hasError ? fallback : src;
28+
const isSvg = imgSrc.endsWith('.svg') || imgSrc.includes('/svg?');
29+
30+
return (
31+
<Image
32+
src={imgSrc}
33+
alt={username}
34+
fill
35+
unoptimized={isSvg}
36+
className={cn('object-cover', className)}
37+
sizes={sizes}
38+
onError={() => setHasError(true)}
39+
/>
40+
);
41+
}
42+
43+
export function UserAvatar(props: UserAvatarProps) {
44+
return <UserAvatarInner key={props.src} {...props} />;
45+
}

frontend/components/leaderboard/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export interface User {
22
id: number;
3+
userId: string;
34
rank: number;
45
username: string;
56
points: number;

frontend/db/queries/leaderboard.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ const getLeaderboardDataCached = unstable_cache(
2626
return dbUsers.map((u, index) => {
2727
const username = u.username || 'Anonymous';
2828
const avatar =
29-
u.avatar && u.avatar !== 'null'
29+
u.avatar && u.avatar.trim() !== '' && u.avatar !== 'null'
3030
? u.avatar
3131
: `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(
3232
`${username}-${u.id}`
3333
)}`;
3434

3535
return {
3636
id: index + 1,
37+
userId: u.id,
3738
rank: index + 1,
3839
username,
3940
points: Number(u.points) || 0,

0 commit comments

Comments
 (0)