Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions frontend/components/leaderboard/LeaderboardPodium.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

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

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

import { User } from './types';
import { UserAvatar } from './UserAvatar';

const rankConfig = {
1: {
Expand Down Expand Up @@ -52,7 +52,7 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) {
].filter(Boolean) as User[];

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

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

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

Expand Down
37 changes: 18 additions & 19 deletions frontend/components/leaderboard/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';

import { CurrentUser, User } from './types';
import { UserAvatar } from './UserAvatar';

interface LeaderboardTableProps {
users: User[];
Expand Down Expand Up @@ -79,7 +80,7 @@ export function LeaderboardTable({
• • •
</div>

<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">
<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">
<div className="w-full">
<table className="w-full table-fixed border-separate border-spacing-0 text-left">
<tbody>
Expand All @@ -106,21 +107,16 @@ function TableRow({
const cellClass =
'px-2 sm:px-6 py-3 sm:py-4 border-b border-slate-100 dark:border-white/5';

const leftBorderClass = isCurrentUser
? 'border-l-[1px] sm:border-l-[1px] border-l-transparent'
: 'border-l-[1px] sm:border-l-[1px] border-l-transparent';

const rightBorderClass = isCurrentUser
? 'border-r-[1px] sm:border-r-[1px] border-r-transparent'
: 'border-r-[1px] sm:border-r-[1px] border-r-transparent';
const leftBorderClass = 'border-l border-l-transparent';
const rightBorderClass = 'border-r border-r-transparent';

return (
<tr
className={cn(
'group transition-all duration-300',
isCurrentUser
? 'bg-[color-mix(in_srgb,var(--accent-primary),transparent_90%)] shadow-inner'
: 'hover:bg-slate-50/60 dark:hover:bg-white/[0.04]'
: 'hover:bg-slate-50/60 dark:hover:bg-white/4'
)}
>
<td className={cn(cellClass, leftBorderClass)}>
Expand All @@ -133,37 +129,40 @@ function TableRow({
<div className="flex items-center gap-2 overflow-hidden sm:gap-4">
<div
className={cn(
'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',
'relative h-8 w-8 shrink-0 overflow-hidden rounded-full border transition-all duration-300 sm:h-10 sm:w-10',
isCurrentUser
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)] text-white shadow-[0_0_1px_var(--accent-primary)]'
: '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'
? 'border-(--accent-primary) shadow-[0_0_1px_var(--accent-primary)]'
: 'border-slate-200 group-hover:border-(--accent-primary) dark:border-white/10'
)}
aria-hidden="true"
>
{user.username.slice(0, 1).toUpperCase()}
<UserAvatar
src={user.avatar}
username={user.username}
userId={user.userId}
/>
</div>

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

{isCurrentUser && (
<div className="relative ml-1 flex h-5 w-5 flex-shrink-0 items-center justify-center sm:h-8 sm:w-8">
<div className="relative ml-1 flex h-5 w-5 shrink-0 items-center justify-center sm:h-8 sm:w-8">
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{
repeat: Infinity,
duration: 0.8,
ease: 'easeInOut',
}}
className="absolute inset-0 text-[var(--accent-primary)]"
className="absolute inset-0 text-(--accent-primary)"
>
<svg
viewBox="0 0 24 24"
Expand Down Expand Up @@ -194,7 +193,7 @@ function TableRow({
className={cn(
'inline-block font-mono font-bold transition-all',
isCurrentUser
? 'scale-110 text-sm text-[var(--accent-primary)] drop-shadow-sm sm:text-lg'
? 'scale-110 text-sm text-(--accent-primary) drop-shadow-sm sm:text-lg'
: 'text-sm text-slate-700 group-hover:scale-105 sm:text-base dark:text-slate-300'
)}
>
Expand Down
45 changes: 45 additions & 0 deletions frontend/components/leaderboard/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';

import Image from 'next/image';
import { useState } from 'react';

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

interface UserAvatarProps {
src: string;
username: string;
userId?: string;
className?: string;
sizes?: string;
}

function UserAvatarInner({
src,
username,
userId,
className,
sizes = '40px',
}: UserAvatarProps) {
const [hasError, setHasError] = useState(false);

const seed = userId ? `${username}-${userId}` : username;
const fallback = `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(seed)}`;
const imgSrc = hasError ? fallback : src;
const isSvg = imgSrc.endsWith('.svg') || imgSrc.includes('/svg?');

return (
<Image
src={imgSrc}
alt={username}
fill
unoptimized={isSvg}
className={cn('object-cover', className)}
sizes={sizes}
onError={() => setHasError(true)}
/>
);
}

export function UserAvatar(props: UserAvatarProps) {
return <UserAvatarInner key={props.src} {...props} />;
}
1 change: 1 addition & 0 deletions frontend/components/leaderboard/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface User {
id: number;
userId: string;
rank: number;
username: string;
points: number;
Expand Down
3 changes: 2 additions & 1 deletion frontend/db/queries/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@ const getLeaderboardDataCached = unstable_cache(
return dbUsers.map((u, index) => {
const username = u.username || 'Anonymous';
const avatar =
u.avatar && u.avatar !== 'null'
u.avatar && u.avatar.trim() !== '' && u.avatar !== 'null'
? u.avatar
: `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(
`${username}-${u.id}`
)}`;

return {
id: index + 1,
userId: u.id,
rank: index + 1,
username,
points: Number(u.points) || 0,
Expand Down