Conversation
There was a problem hiding this comment.
Pull request overview
This PR refreshes the leaderboard UI/UX by introducing a new header/banner layout, richer leaderboard visuals (podium + badges + legend), and a stats sidebar, along with a few related client-component and typing adjustments.
Changes:
- Add new leaderboard UI components (Banner, InviteFriendsButton, LeaderboardStats) and update the leaderboard header/layout.
- Redesign leaderboard ranking presentation with a top-3 podium, updated badge tiers, and a rankings legend.
- Dependency/lockfile updates (adds
shx) and minor cleanup/typing tweaks in chat/dashboard components.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds shx to devDependencies. |
| package-lock.json | Updates lockfile to include shx and related dependency graph changes. |
| app/components/leaderboard/LeaderboardStats.tsx | New leaderboard stats sidebar component (AOS + derived aggregates). |
| app/components/leaderboard/InviteFriendsButton.tsx | New “Invite Friends” clipboard/share button. |
| app/components/leaderboard/Header.tsx | New header layout with banner, back button overlay, and invite action. |
| app/components/leaderboard/Banner.tsx | New banner component (image or generated gradient). |
| app/components/leaderboard/BackButton.tsx | Adds configurable href prop; updates navigation target. |
| app/components/landing-page/TopLeaderbord.tsx | Simplifies categories typing shape on TopMember. |
| app/components/dashboard/widgets/StatsCard.tsx | Marks component as client-side. |
| app/components/dashboard/Navbar.tsx | Removes an unused icon import. |
| app/components/chat/Messages.tsx | Adjusts SyntaxHighlighter typing/spread; edits code block rendering. |
| app/components/chat/Conversations.tsx | Marks component as client-side. |
| app/components/LeaderboardTable.tsx | Major leaderboard table redesign: podium, badge tiers/styles, legend, layout changes. |
| app/components/Chat.tsx | Removes unused toast import. |
| app/(public)/leaderboard/page.tsx | Updates BackButton destination for the public leaderboard list page. |
| app/(public)/leaderboard/[slug]/page.tsx | Removes standalone BackButton and updates page layout/wrapping. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <> | ||
| {/* @ts-expect-error atomDark style type not compatible with SyntaxHighlighter */} | ||
| { } | ||
|
|
There was a problem hiding this comment.
The standalone { } inside the fragment evaluates to an empty object and will be rendered as a React child, which triggers a runtime error (“Objects are not valid as a React child”). Remove this expression entirely.
| const getBadgeInfo = (rank: number, hours: number) => { | ||
| if (hours >= 160) return { label: "MISSION IMPOSSIBLE", class: "badge-impossible", icon: faGhost }; | ||
| if (hours >= 130) return { label: "GOD LEVEL", class: "badge-god", icon: faCrown }; | ||
| if (hours >= 100) return { label: "STARLIGHT", class: "badge-starlight", icon: faStar }; | ||
| if (hours >= 50) return { label: "ELITE", class: "badge-elite", icon: faFire }; | ||
| if (hours >= 20) return { label: "PRO", class: "badge-pro", icon: faBolt }; | ||
| if (hours >= 5) return { label: "NOVICE", class: "badge-novice", icon: faMedal }; | ||
| if (hours >= 1) return { label: "NEWBIE", class: "badge-newbie", icon: faSeedling }; | ||
| return { label: "NONE", class: "badge-none", icon: faMinus }; // 0 hours | ||
| }; |
There was a problem hiding this comment.
rank is not used in getBadgeInfo, which may be flagged by lint rules and makes the API misleading. Either remove the parameter or incorporate rank-based logic if it’s intended to matter.
| "@types/react-syntax-highlighter": "^15.5.13", | ||
| "eslint": "^9", | ||
| "eslint-config-next": "16.1.6", | ||
| "shx": "^0.4.0", |
There was a problem hiding this comment.
shx is added to devDependencies, but there are no package.json scripts (or other repo references) using it. If it’s not needed, remove it; otherwise add/update the script(s) that rely on shx so the dependency is justified.
| "shx": "^0.4.0", |
| const handleInvite = () => { | ||
| if (typeof window !== "undefined") { | ||
| const inviteUrl = joinCode | ||
| ? `${window.location.origin}/join/${joinCode}` | ||
| : window.location.href; // fallback | ||
|
|
||
| const message = leaderboardName | ||
| ? `Join my coding leaderboard "${leaderboardName}" on DevPulse!\n\nTrack metrics, compete with fellow developers, and showcase your engineering skills.\n\nJoin here: ${inviteUrl}` | ||
| : `Join my coding leaderboard on DevPulse!\n\nJoin here: ${inviteUrl}`; | ||
|
|
||
| navigator.clipboard.writeText(message); | ||
| toast.success("Invite message copied to clipboard!"); |
There was a problem hiding this comment.
navigator.clipboard.writeText(...) returns a Promise and can reject (e.g., non-secure context, permissions denied, unsupported browser). Consider awaiting it and handling errors (showing a toast on failure) and/or guarding for navigator.clipboard before calling.
| const handleInvite = () => { | |
| if (typeof window !== "undefined") { | |
| const inviteUrl = joinCode | |
| ? `${window.location.origin}/join/${joinCode}` | |
| : window.location.href; // fallback | |
| const message = leaderboardName | |
| ? `Join my coding leaderboard "${leaderboardName}" on DevPulse!\n\nTrack metrics, compete with fellow developers, and showcase your engineering skills.\n\nJoin here: ${inviteUrl}` | |
| : `Join my coding leaderboard on DevPulse!\n\nJoin here: ${inviteUrl}`; | |
| navigator.clipboard.writeText(message); | |
| toast.success("Invite message copied to clipboard!"); | |
| const handleInvite = async () => { | |
| if (typeof window === "undefined") { | |
| return; | |
| } | |
| const inviteUrl = joinCode | |
| ? `${window.location.origin}/join/${joinCode}` | |
| : window.location.href; // fallback | |
| const message = leaderboardName | |
| ? `Join my coding leaderboard "${leaderboardName}" on DevPulse!\n\nTrack metrics, compete with fellow developers, and showcase your engineering skills.\n\nJoin here: ${inviteUrl}` | |
| : `Join my coding leaderboard on DevPulse!\n\nJoin here: ${inviteUrl}`; | |
| if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") { | |
| toast.error("Clipboard is not available in this browser."); | |
| return; | |
| } | |
| try { | |
| await navigator.clipboard.writeText(message); | |
| toast.success("Invite message copied to clipboard!"); | |
| } catch (_error) { | |
| toast.error("Failed to copy invite message to clipboard."); |
| {/* Using a temporary placeholder banner image */} | ||
| <Banner name={leaderboard.name} imageUrl="https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2070&auto=format&fit=crop" /> |
There was a problem hiding this comment.
This hard-codes a “temporary placeholder” Unsplash banner URL in the UI. If this is meant to be temporary, it’s easy to forget and ship; consider falling back to the generated gradient (omit imageUrl) or sourcing the banner URL from leaderboard data/config instead of inlining it here.
| {/* Using a temporary placeholder banner image */} | |
| <Banner name={leaderboard.name} imageUrl="https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2070&auto=format&fit=crop" /> | |
| <Banner name={leaderboard.name} /> |
| (m.languages as { name?: string; total_seconds?: number }[] || []).forEach((l: { name?: string; total_seconds?: number } | string) => { | ||
| const name = typeof l === "string" ? l : l.name || "Unknown"; | ||
| const secs = typeof l === "string" ? 3600 : l.total_seconds || 3600; | ||
| languageTime[name] = (languageTime[name] || 0) + secs; | ||
| }); | ||
| (m.editors as { name?: string; total_seconds?: number }[] || []).forEach((e: { name?: string; total_seconds?: number } | string) => { | ||
| const name = typeof e === "string" ? e : e.name || "Unknown"; | ||
| const secs = typeof e === "string" ? 3600 : e.total_seconds || 3600; | ||
| editorTime[name] = (editorTime[name] || 0) + secs; |
There was a problem hiding this comment.
NonNullableMember.languages / editors are typed as { name: string }[] (no total_seconds). Defaulting each entry to 3600 seconds will significantly overcount and makes the “Top Language/Editor” totals and percentages inaccurate. Either extend the view/type to include per-language/editor total_seconds, or change this logic to compute counts (or derive time from a source that actually has per-language/editor durations).
| (m.languages as { name?: string; total_seconds?: number }[] || []).forEach((l: { name?: string; total_seconds?: number } | string) => { | |
| const name = typeof l === "string" ? l : l.name || "Unknown"; | |
| const secs = typeof l === "string" ? 3600 : l.total_seconds || 3600; | |
| languageTime[name] = (languageTime[name] || 0) + secs; | |
| }); | |
| (m.editors as { name?: string; total_seconds?: number }[] || []).forEach((e: { name?: string; total_seconds?: number } | string) => { | |
| const name = typeof e === "string" ? e : e.name || "Unknown"; | |
| const secs = typeof e === "string" ? 3600 : e.total_seconds || 3600; | |
| editorTime[name] = (editorTime[name] || 0) + secs; | |
| const memberSeconds = m.total_seconds || 0; | |
| const languages = ((m.languages as ({ name?: string } | string)[] | undefined) || []); | |
| const perLanguageSeconds = languages.length > 0 ? memberSeconds / languages.length : 0; | |
| languages.forEach((l) => { | |
| const name = typeof l === "string" ? l : l.name || "Unknown"; | |
| languageTime[name] = (languageTime[name] || 0) + perLanguageSeconds; | |
| }); | |
| const editors = ((m.editors as ({ name?: string } | string)[] | undefined) || []); | |
| const perEditorSeconds = editors.length > 0 ? memberSeconds / editors.length : 0; | |
| editors.forEach((e) => { | |
| const name = typeof e === "string" ? e : e.name || "Unknown"; | |
| editorTime[name] = (editorTime[name] || 0) + perEditorSeconds; |
| import { useEffect } from "react"; | ||
| import AOS from "aos"; | ||
| import "aos/dist/aos.css"; | ||
| import { NonNullableMember } from "../LeaderboardTable"; |
There was a problem hiding this comment.
This imports NonNullableMember as a value import, creating a circular module dependency (LeaderboardTable imports LeaderboardStats, and LeaderboardStats imports from LeaderboardTable). Make this a type-only import (import type ...) and/or move shared types to a separate module to avoid runtime circular import issues.
| import { NonNullableMember } from "../LeaderboardTable"; | |
| import type { NonNullableMember } from "../LeaderboardTable"; |
| setTimeout(() => { | ||
| AOS.refresh(); | ||
| }, 200); |
There was a problem hiding this comment.
The setTimeout in this effect is never cleared. If the component unmounts quickly (navigation), it can still fire and call AOS.refresh(). Store the timeout id and clear it in the cleanup function.
| setTimeout(() => { | |
| AOS.refresh(); | |
| }, 200); | |
| const timeoutId = setTimeout(() => { | |
| AOS.refresh(); | |
| }, 200); | |
| return () => { | |
| clearTimeout(timeoutId); | |
| }; |
| @@ -243,18 +236,42 @@ export default function LeaderboardTable({ | |||
| color: #93c5fd; | |||
| } | |||
There was a problem hiding this comment.
getBadgeInfo no longer returns badge-master or badge-hustler, but the CSS for these classes remains. Removing unused badge styles (or updating getBadgeInfo to use them) will keep the inline stylesheet smaller and easier to maintain.
update leaderboard