Skip to content

Commit f05cd2c

Browse files
committed
Add public scoreboard page and landing top-three section
Final PBCTF 5.0 standings at /leaderboard: full board in the retro terminal style with team search, banned teams shown in red and unranked. Landing gets a Hall of Fame section with the top three podium linking to the full board, plus a Scoreboard nav link.
1 parent 14bea54 commit f05cd2c

11 files changed

Lines changed: 1101 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"use client";
2+
3+
import dynamic from "next/dynamic";
4+
5+
// Client-only mount, same as the landing page: the scoreboard reuses the
6+
// landing components (canvas starfield, sessionStorage-backed sound hook).
7+
const LeaderboardPage = dynamic(
8+
() => import("@/components/landing/Leaderboard/LeaderboardPage"),
9+
{ ssr: false }
10+
);
11+
12+
export default function LeaderboardClient() {
13+
return <LeaderboardPage />;
14+
}

app/leaderboard/page.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Metadata } from "next";
2+
import LeaderboardClient from "./leaderboard-client";
3+
4+
export const metadata: Metadata = {
5+
title: "Scoreboard",
6+
description:
7+
"Final PBCTF 5.0 standings. Every ranked team, every score, and the ones that didn't play fair.",
8+
};
9+
10+
export default function LeaderboardRoute() {
11+
return <LeaderboardClient />;
12+
}

components/landing/Header/Header.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const NAV_LINKS = [
99
{ id: 'nav-timeline', label: 'Timeline', href: '#timeline' },
1010
{ id: 'nav-categories', label: 'Categories', href: '#categories' },
1111
{ id: 'nav-prizes', label: 'Prizes', href: '#prizes' },
12+
{ id: 'nav-leaderboard', label: 'Scoreboard', href: '#leaderboard' },
1213
{ id: 'nav-faq', label: 'FAQ', href: '#faq' },
1314
];
1415

components/landing/LandingPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import MissionBrief from "./MissionBrief/MissionBrief";
1212
import Timeline from "./Timeline/Timeline";
1313
import Categories from "./Categories/Categories";
1414
import Prizes from "./Prizes/Prizes";
15+
import Leaderboard from "./Leaderboard/Leaderboard";
1516
import AboutPointBlank from "./AboutPointBlank/AboutPointBlank";
1617
import Venue from "./Venue/Venue";
1718
import FAQ from "./FAQ/FAQ";
@@ -92,6 +93,7 @@ export default function LandingPage() {
9293
<Timeline />
9394
<Categories />
9495
<Prizes />
96+
<Leaderboard />
9597
<Sponsors />
9698
<AboutPointBlank />
9799
<Venue />
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/* ===================================================================
2+
Leaderboard — landing "Final Standings" section (top three + CTA)
3+
=================================================================== */
4+
5+
.pbctf-landing .lb-section__header {
6+
margin-bottom: var(--space-12);
7+
}
8+
9+
.pbctf-landing .lb-section__cta {
10+
display: flex;
11+
flex-direction: column;
12+
align-items: center;
13+
gap: var(--space-4);
14+
margin-top: var(--space-12);
15+
}
16+
17+
.pbctf-landing .lb-section__note {
18+
font-family: 'JetBrains Mono', monospace;
19+
font-size: var(--text-xs);
20+
letter-spacing: 0.08em;
21+
color: var(--muted-dark);
22+
text-align: center;
23+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useRef } from 'react';
2+
import { gsap } from 'gsap';
3+
import { ScrollTrigger } from 'gsap/ScrollTrigger';
4+
import { useGSAP } from '@gsap/react';
5+
import { useRetroSound } from '../hooks/useRetroSound';
6+
import { getTopThree, RANKED_COUNT, BANNED_COUNT } from '@/data/leaderboard';
7+
import Podium from './Podium';
8+
import './Leaderboard.css';
9+
10+
gsap.registerPlugin(ScrollTrigger);
11+
12+
const TOP_THREE = getTopThree();
13+
14+
export default function Leaderboard() {
15+
const sectionRef = useRef(null);
16+
const { playHover, playClick } = useRetroSound();
17+
18+
useGSAP(() => {
19+
const ctx = sectionRef.current;
20+
if (!ctx) return;
21+
22+
const cards = ctx.querySelectorAll('.lb-podium__card');
23+
gsap.set(cards, { opacity: 0, y: 40 });
24+
gsap.to(cards, {
25+
opacity: 1,
26+
y: 0,
27+
duration: 0.9,
28+
stagger: 0.12,
29+
ease: 'expo.out',
30+
scrollTrigger: {
31+
trigger: ctx,
32+
start: 'top 75%',
33+
},
34+
});
35+
}, { scope: sectionRef });
36+
37+
return (
38+
<section id="leaderboard" className="section lb-section" ref={sectionRef}>
39+
<div className="container">
40+
<header className="lb-section__header">
41+
<span className="section__label">Final_Standings</span>
42+
<h2 className="section__title">Hall of Fame</h2>
43+
<p className="section__subtitle">
44+
The dust has settled. Out of {RANKED_COUNT} ranked teams, these
45+
three claimed the podium.
46+
</p>
47+
</header>
48+
49+
<Podium teams={TOP_THREE} />
50+
51+
<div className="lb-section__cta">
52+
<a
53+
href="/leaderboard"
54+
className="btn btn--secondary"
55+
onMouseEnter={playHover}
56+
onClick={playClick}
57+
>
58+
View Full Scoreboard
59+
<svg
60+
className="btn__arrow"
61+
viewBox="0 0 24 24"
62+
fill="none"
63+
stroke="currentColor"
64+
strokeWidth="2"
65+
strokeLinecap="round"
66+
strokeLinejoin="round"
67+
aria-hidden="true"
68+
>
69+
<line x1="5" y1="12" x2="19" y2="12" />
70+
<polyline points="12 5 19 12 12 19" />
71+
</svg>
72+
</a>
73+
<p className="lb-section__note">
74+
{BANNED_COUNT} teams were disqualified for rule violations and hold no rank.
75+
</p>
76+
</div>
77+
</div>
78+
</section>
79+
);
80+
}

0 commit comments

Comments
 (0)