Skip to content
Open
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
12 changes: 11 additions & 1 deletion app/[username]/vs/[opponent]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { loadCard } from "@/lib/scout";
import { getRepoStars } from "@/lib/github/stars";
import { pickFlag } from "@/lib/flagPriority";
import { recordScout } from "@/lib/analytics";
import { recordLeaderboardEntry } from "@/lib/leaderboard";
import { computeDuel } from "@/lib/duel";
import type { Card } from "@/lib/scoring/types";

Expand Down Expand Up @@ -118,7 +119,16 @@ export default async function Page({ params }: Params) {
);
}

after(() => Promise.all([recordScout(), recordScout()])); // a duel is two scouts
// a duel is two scouts — record both, and add both to the leaderboard.
// Returned so after()/waitUntil keeps the Redis writes alive until they settle.
after(() =>
Promise.all([
recordScout(),
recordScout(),
recordLeaderboardEntry(a.card),
recordLeaderboardEntry(b.card),
]),
);

const duel = computeDuel(withFlag(a.card), withFlag(b.card));
return (
Expand Down
3 changes: 2 additions & 1 deletion app/api/card/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type GithubError } from "@/lib/github/client";
import { scoutCard } from "@/lib/scout";
import { pickFlag } from "@/lib/flagPriority";
import { recordScout } from "@/lib/analytics";
import { recordLeaderboardEntry } from "@/lib/leaderboard";
import { after } from "next/server";
import type { Card } from "@/lib/scoring/types";

Expand All @@ -18,7 +19,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ username
// just resolve the visitor's flag and record the scout after the response.
try {
const card = await scoutCard(username);
after(() => recordScout());
after(() => Promise.all([recordScout(), recordLeaderboardEntry(card)]));
return Response.json(resolveCountry(card, override));
} catch (e) {
const err = e as GithubError;
Expand Down
117 changes: 117 additions & 0 deletions app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import Link from "next/link";
import type { Metadata } from "next";
import Background from "@/components/Background";
import LeaderboardRow from "@/components/LeaderboardRow";
import LeaderboardSearch from "@/components/LeaderboardSearch";
import { getLeaderboardPage, getRankFor, getNeighbors, recordLeaderboardEntry } from "@/lib/leaderboard";
import { loadCard } from "@/lib/scout";

export const dynamic = "force-dynamic";

export const metadata: Metadata = {
title: "Global Leaderboard · GitFut",
description: "Every scouted GitFut card, ranked by overall rating.",
};

const PAGE_SIZE = 25;

export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; focus?: string }>;
}) {
const { page: pageParam, focus } = await searchParams;
const requested = Number(pageParam) || 1;
const { entries, total, page, totalPages } = await getLeaderboardPage(requested, PAGE_SIZE);

// Focused search: scout the login (adds a new profile to the board), then read
// back its rank + neighbours. A scout failure yields a friendly note, not a crash.
let focusBlock: React.ReactNode = null;
if (focus) {
const res = await loadCard(focus);
if ("card" in res) {
await recordLeaderboardEntry(res.card);
const ranked = await getRankFor(focus);
const neighbors = await getNeighbors(focus, 3);
Comment on lines +23 to +35
focusBlock = (
<div className="mt-5 rounded-2xl border border-brand/40 bg-brand/[.06] p-4">
<div className="font-display text-[12px] font-bold tracking-[.2em] text-brand">YOUR POSITION</div>
{ranked ? (
<p className="mt-1 text-[14px] text-ink-soft">
<span className="font-display text-ink">@{res.card.login}</span> is ranked{" "}
<span className="font-display text-ink">#{ranked.rank}</span> of {ranked.total.toLocaleString()}.
</p>
) : (
<p className="mt-1 text-[14px] text-ink-soft">@{res.card.login} isn’t ranked yet — try again in a moment.</p>
)}
<ol className="mt-3 flex flex-col gap-1.5">
{neighbors.map((e) => (
<li key={e.login}>
<LeaderboardRow entry={e} highlight={e.login.toLowerCase() === res.card.login.toLowerCase()} />
</li>
))}
</ol>
</div>
);
} else {
focusBlock = (
<p className="mt-5 rounded-xl border border-white/10 bg-white/[.03] px-4 py-3 text-[14px] text-ink-soft">
Couldn’t scout <span className="font-display text-ink">@{focus}</span> — check the username and try again.
</p>
);
}
}

return (
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
<main className="relative z-[2] mx-auto w-full max-w-[680px] px-5 py-[clamp(28px,6vh,64px)]">
<div className="flex items-baseline justify-between gap-4">
<div>
<div className="font-display text-[12px] font-bold tracking-[.3em] text-brand">GITFUT</div>
<h1 className="font-display mt-1 text-[clamp(28px,6vw,44px)] font-black leading-[.95]">
Global Leaderboard
</h1>
</div>
<Link href="/" className="font-display text-[13px] tracking-[.08em] text-ink-soft transition hover:text-ink">
← HOME
</Link>
</div>
<p className="mt-2 text-[14px] text-ink-soft">
{total.toLocaleString()} card{total === 1 ? "" : "s"} scouted, ranked by overall.
</p>

<LeaderboardSearch initial={focus ?? ""} />
{focusBlock}

<ol className="mt-6 flex flex-col gap-1.5">
{entries.map((e) => (
<li key={e.login}>
<LeaderboardRow entry={e} />
</li>
))}
</ol>

<nav className="mt-6 flex items-center justify-between font-display text-[13px] tracking-[.06em]">
{page > 1 ? (
<Link href={`/leaderboard?page=${page - 1}`} className="text-ink-soft transition hover:text-ink">
← PREV
</Link>
) : (
<span className="text-ink-soft/30">← PREV</span>
)}
<span className="text-ink-soft">
{page} / {totalPages}
</span>
{page < totalPages ? (
<Link href={`/leaderboard?page=${page + 1}`} className="text-ink-soft transition hover:text-ink">
NEXT →
</Link>
) : (
<span className="text-ink-soft/30">NEXT →</span>
)}
</nav>
</main>
</div>
);
}
16 changes: 14 additions & 2 deletions app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { loadCard } from "@/lib/scout";
import { getRepoStars } from "@/lib/github/stars";
import { pickFlag } from "@/lib/flagPriority";
import { recordScout } from "@/lib/analytics";
import { recordLeaderboardEntry } from "@/lib/leaderboard";
import type { Card } from "@/lib/scoring/types";
import ScoutRoute from "./ScoutRoute";

Expand Down Expand Up @@ -72,7 +73,10 @@ export default async function Page({
let card: Card | null = "card" in res ? res.card : null;
let canonicalCountry = ""; // GitHub-derived flag; share links omit ?country= unless overridden
if (card) {
after(() => recordScout()); // analytics, flushed after the response (serverless-safe)
const scouted = card; // canonical card, before the display-flag override below
// analytics + leaderboard, flushed after the response (serverless-safe).
// Returned so after()/waitUntil keeps the Redis writes alive until they settle.
after(() => Promise.all([recordScout(), recordLeaderboardEntry(scouted)]));
canonicalCountry = pickFlag(null, card.country) ?? ""; // GitHub-derived only
const displayCountry = pickFlag(override, card.country) ?? "";
card = { ...card, country: displayCountry };
Expand All @@ -81,7 +85,15 @@ export default async function Page({
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
{card ? (
<ScoutRoute card={card} stars={stars} canonicalCountry={canonicalCountry} />
<>
<Link
href="/leaderboard"
className="font-display absolute right-[clamp(16px,4vw,40px)] top-[clamp(14px,3vh,24px)] z-[3] text-[13px] tracking-[.1em] text-ink-soft transition hover:text-ink"
>
LEADERBOARD
</Link>
<ScoutRoute card={card} stars={stars} canonicalCountry={canonicalCountry} />
</>
) : (
<NotScouted username={username} error={(res as { error: GithubError }).error} />
)}
Expand Down
9 changes: 8 additions & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import ScoutForm from "@/components/ScoutForm";
import CardFan from "@/components/CardFan";
import LoadingScreen from "@/components/LoadingScreen";
Expand Down Expand Up @@ -57,7 +58,13 @@ export default function AppShell({
<main className="relative z-[2] flex min-h-screen flex-col">
{/* Overlaid in the corner (not a flow header) so it never pushes the
vertically-centered hero down. */}
<div className="absolute right-[clamp(20px,5vw,52px)] top-[clamp(16px,3vh,26px)] z-[3]">
<div className="absolute right-[clamp(20px,5vw,52px)] top-[clamp(16px,3vh,26px)] z-[3] flex items-center gap-4">
<Link
href="/leaderboard"
className="font-display text-[13px] tracking-[.1em] text-ink-soft transition hover:text-ink"
>
LEADERBOARD
</Link>
<GithubStar stars={stars} />
</div>
<div className="mx-auto flex w-full max-w-[1180px] flex-1 items-center gap-[clamp(24px,5vw,72px)] px-[clamp(22px,5vw,56px)] max-[860px]:flex-col max-[860px]:gap-[34px] max-[860px]:pb-6 max-[860px]:pt-[clamp(40px,6vh,56px)] max-[860px]:text-center">
Expand Down
54 changes: 54 additions & 0 deletions components/LeaderboardRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Link from "next/link";
import type { LeaderboardEntry } from "@/lib/leaderboard-core";
import { RESULT_THEME } from "@/components/finishTheme";
import { languageLogoUrl } from "@/lib/github/languages";

// One ranked row. Tier ink/chip come from RESULT_THEME so the OVR pill reads in
// the card's own finish colour. `highlight` lifts the searched player's row.
export default function LeaderboardRow({
entry,
highlight = false,
}: {
entry: LeaderboardEntry;
highlight?: boolean;
}) {
const theme = RESULT_THEME[entry.finish];
return (
<Link
href={`/${entry.login}`}
className={`flex items-center gap-3 rounded-xl border px-3 py-2.5 transition hover:border-brand/50 ${
highlight ? "border-brand bg-brand/10" : "border-white/10 bg-white/[.03]"
}`}
>
<span className="font-display w-9 shrink-0 text-right text-[15px] tabular-nums text-ink-soft">
{entry.rank}
</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={entry.avatarUrl}
alt=""
width={34}
height={34}
className="h-[34px] w-[34px] shrink-0 rounded-full object-cover"
/>
<span className="min-w-0 flex-1 truncate font-display text-[15px] text-ink">{entry.name}</span>
{entry.country && (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/badges/flags/${entry.country}.png`} alt="" width={22} height={16} className="shrink-0 rounded-[2px]" />
)}
{entry.langSlug && (
// eslint-disable-next-line @next/next/no-img-element
<img src={languageLogoUrl(entry.langSlug)} alt={entry.topLanguage ?? ""} title={entry.topLanguage ?? ""} width={18} height={18} className="shrink-0" />
)}
<span className="font-display w-9 shrink-0 text-center text-[11px] tracking-[.08em] text-ink-soft max-[520px]:hidden">
{entry.position}
</span>
<span
className="font-display w-10 shrink-0 rounded-md px-2 py-1 text-center text-[16px] font-black tabular-nums"
style={{ background: theme.chip, color: theme.ink }}
>
{entry.overall}
</span>
</Link>
);
}
41 changes: 41 additions & 0 deletions components/LeaderboardSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";

// Navigates to /leaderboard?focus=<login>. The server then scouts the login (so
// a never-seen user is added to the board) and renders their rank + neighbours.
export default function LeaderboardSearch({ initial = "" }: { initial?: string }) {
const router = useRouter();
const [value, setValue] = useState(initial);
const [isPending, startTransition] = useTransition();

const submit = (e: React.FormEvent) => {
e.preventDefault();
const login = value.trim().replace(/^@/, "");
if (!login) return;
startTransition(() => router.push(`/leaderboard?focus=${encodeURIComponent(login)}`));
};

return (
<form onSubmit={submit} className="mt-5 flex gap-2">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="find your rank — github username"
aria-label="GitHub username"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
className="h-11 flex-1 rounded-xl border border-white/12 bg-white/[.04] px-4 text-[15px] text-ink outline-none transition focus:border-brand/60"
/>
<button
type="submit"
disabled={isPending}
className="font-display h-11 rounded-xl bg-brand px-5 text-[15px] tracking-[.06em] text-[#04130a] transition hover:bg-brand-hi disabled:opacity-60"
>
{isPending ? "…" : "FIND"}
</button>
</form>
);
}
Loading