Klassement: kop met periode en een keuze uit rondes, een podium voor de top drie (winnaar in het midden, het hoogst), en een lijst met rang, portret of initialen, naam, subregel en score — de eigen rij uitgelicht, met paginering. Getallen en datums in de taal van het document. Componeert select en button.
Weekklassement
7 sep 2026 – 13 sep 2026
Ronde
- Leo Harrison268.100 pt
- Ava Turner289.400 pt
- Rowan de Wit246.800 pt
- 1Ava TurnerNiveau 42289.400 pt
- 2Leo HarrisonNiveau 40268.100 pt
- 3Rowan de WitNiveau 38246.800 pt
- 4Maya ChenNiveau 36225.500 pt
- 5JijjijNiveau 34204.200 pt
- 6Noor BakkerNiveau 32182.900 pt
1 / 2
Podium voor de top drie, jouw rij uitgelicht, twee pagina's van zes.
blocks/leaderboard/leaderboard.tsx
/**
* Leaderboard — een klassement: kop met periode en een keuze uit rondes,
* een podium voor de top drie (de winnaar in het midden, hoger), en een
* lijst met rang, portret of initialen, naam, subregel en score, met de
* eigen rij uitgelicht en paginering. Getallen in NL-notatie (Intl).
*
* Geoogst uit 21st.dev Leaderboard Card (trophyso): de twee losse delen
* (LeaderboardPodium, LeaderboardRankings) kwamen niet mee uit de API en
* zijn hier opnieuw gebouwd; de native <select> → select (FC-0074), de
* paginering → button (FC-0003), toLocaleDateString(undefined) → de taal
* van het document, 'You'/'Leaderboard' → NL-labels.
*/
"use client";
import { useMemo, useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { Select } from "../../primitives/ui/select";
export interface LeaderboardEntry {
userId: string;
userName: string;
rank: number;
value: number;
byline?: string;
avatar?: string;
}
export interface LeaderboardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "children"> {
title?: string;
fromDate: string | Date;
toDate: string | Date;
/** Top drie voor het podium; zonder: de eerste drie uit `rankings`. */
podium?: readonly LeaderboardEntry[];
rankings: readonly LeaderboardEntry[];
currentUserId?: string;
runs?: readonly { id: string; label: string }[];
selectedRunId?: string;
onRunChange?: (id: string) => void;
pageSize?: number;
/** Eenheid achter de score, bv. "pt". */
unit?: string;
labels?: { runs?: string; previous?: string; next?: string; you?: string; empty?: string };
}
const datum = (d: string | Date) => {
const p = d instanceof Date ? d : new Date(d);
return Number.isNaN(p.getTime()) ? "" : p.toLocaleDateString(typeof document !== "undefined" ? document.documentElement.lang || "nl-NL" : "nl-NL", { day: "numeric", month: "short", year: "numeric" });
};
const initialen = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
function Portret({ e, groot }: { e: LeaderboardEntry; groot?: boolean }) {
return e.avatar ? (
// eslint-disable-next-line @next/next/no-img-element -- portretten van het project
<img className={groot ? "fp-lb__portret fp-lb__portret--groot" : "fp-lb__portret"} src={e.avatar} alt="" />
) : (
<span className={groot ? "fp-lb__portret fp-lb__portret--groot fp-lb__portret--letters" : "fp-lb__portret fp-lb__portret--letters"} aria-hidden="true">{initialen(e.userName)}</span>
);
}
export function Leaderboard({ title = "Klassement", fromDate, toDate, podium, rankings, currentUserId, runs, selectedRunId, onRunChange, pageSize = 10, unit, labels, className, ...rest }: LeaderboardProps) {
const t = { runs: "Ronde", previous: "Vorige", next: "Volgende", you: "jij", empty: "Nog geen scores.", ...labels };
const [eigenRun, setEigenRun] = useState(selectedRunId ?? runs?.[0]?.id ?? "");
const run = selectedRunId ?? eigenRun;
const [pagina, setPagina] = useState(0);
const nf = useMemo(() => new Intl.NumberFormat(typeof document !== "undefined" ? document.documentElement.lang || "nl-NL" : "nl-NL"), []);
const top = (podium ?? rankings.slice(0, 3)).slice(0, 3);
const volgorde = [top[1], top[0], top[2]].filter(Boolean) as LeaderboardEntry[];
const paginas = Math.max(1, Math.ceil(rankings.length / pageSize));
const zichtbaar = rankings.slice(pagina * pageSize, (pagina + 1) * pageSize);
return (
<div {...rest} className={["fp-lb", className].filter(Boolean).join(" ")}>
<div className="fp-lb__kop">
<div>
<h3 className="fp-lb__titel">{title}</h3>
<p className="fp-lb__periode">{datum(fromDate)} – {datum(toDate)}</p>
</div>
{runs && runs.length > 0 && (
<Select
label={t.runs}
options={runs.map((r) => ({ value: r.id, label: r.label }))}
value={run}
onChange={(v) => {
if (!v) return;
setEigenRun(v);
onRunChange?.(v);
setPagina(0);
}}
/>
)}
</div>
{volgorde.length > 0 && (
<ol className="fp-lb__podium" aria-label="Podium">
{volgorde.map((e) => (
<li key={e.userId} className="fp-lb__trede" data-rank={e.rank}>
<Portret e={e} groot />
<span className="fp-lb__trede-naam">{e.userName}</span>
<span className="fp-lb__trede-score">{nf.format(e.value)}{unit ? ` ${unit}` : ""}</span>
<span className="fp-lb__blok" aria-hidden="true">{e.rank}</span>
</li>
))}
</ol>
)}
{zichtbaar.length === 0 ? (
<p className="fp-lb__leeg">{t.empty}</p>
) : (
<ol className="fp-lb__lijst">
{zichtbaar.map((e) => (
<li key={e.userId} className="fp-lb__rij" data-jij={e.userId === currentUserId ? "" : undefined}>
<span className="fp-lb__rang">{e.rank}</span>
<Portret e={e} />
<span className="fp-lb__wie">
<span className="fp-lb__naam">
{e.userName}
{e.userId === currentUserId && <span className="fp-lb__jij">{t.you}</span>}
</span>
{e.byline && <span className="fp-lb__sub">{e.byline}</span>}
</span>
<span className="fp-lb__score">{nf.format(e.value)}{unit ? ` ${unit}` : ""}</span>
</li>
))}
</ol>
)}
{paginas > 1 && (
<div className="fp-lb__paginering">
<Button variant="ghost" disabled={pagina === 0} onPress={() => setPagina((p) => Math.max(0, p - 1))}>{t.previous}</Button>
<span className="fp-lb__pagina">{pagina + 1} / {paginas}</span>
<Button variant="ghost" disabled={pagina >= paginas - 1} onPress={() => setPagina((p) => Math.min(paginas - 1, p + 1))}>{t.next}</Button>
</div>
)}
</div>
);
}
blocks/leaderboard/leaderboard.css
/** Leaderboard — importeren in de app-root (registry-site: app/globals.css). */
.fp-lb { width: 100%; max-width: 36rem; box-sizing: border-box; padding: 1.5rem; border-radius: calc(var(--radius) * 2); border: 1px solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); box-shadow: var(--shadow-overlay); }
.fp-lb__kop { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }
.fp-lb__titel { margin: 0; font: inherit; font-size: 1.25rem; font-weight: 600; }
.fp-lb__periode { margin: 0.25rem 0 0; font: inherit; font-size: 0.875rem; color: var(--muted-foreground); }
/* Podium: drie treden, de winnaar in het midden en het hoogst. */
.fp-lb__podium { list-style: none; margin: 0 0 1.5rem; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); align-items: end; gap: 0.5rem; }
.fp-lb__trede { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; text-align: center; min-width: 0; }
.fp-lb__trede-naam { font: inherit; font-size: 0.8125rem; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-lb__trede-score { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
.fp-lb__blok { display: flex; align-items: flex-start; justify-content: center; width: 100%; padding-top: 0.5rem; box-sizing: border-box; border-radius: var(--radius) var(--radius) 0 0; background: var(--muted); color: var(--muted-foreground); font: inherit; font-size: 1.25rem; font-weight: 700; }
.fp-lb__trede[data-rank="1"] .fp-lb__blok { height: 5rem; background: var(--primary); color: var(--primary-foreground); }
.fp-lb__trede[data-rank="2"] .fp-lb__blok { height: 3.75rem; }
.fp-lb__trede[data-rank="3"] .fp-lb__blok { height: 2.75rem; }
.fp-lb__portret { width: 2.25rem; height: 2.25rem; flex: 0 0 auto; border-radius: 50%; object-fit: cover; background: var(--muted); }
.fp-lb__portret--groot { width: 3rem; height: 3rem; }
.fp-lb__portret--letters { display: inline-flex; align-items: center; justify-content: center; font: inherit; font-size: 0.75rem; font-weight: 600; color: var(--muted-foreground); }
.fp-lb__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; }
.fp-lb__rij { display: grid; grid-template-columns: 2rem auto 1fr auto; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); }
.fp-lb__rij[data-jij] { background: color-mix(in srgb, var(--primary) 10%, transparent); outline: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); }
.fp-lb__rang { font: inherit; font-size: 0.875rem; font-weight: 600; color: var(--muted-foreground); font-variant-numeric: tabular-nums; text-align: center; }
.fp-lb__wie { display: flex; flex-direction: column; min-width: 0; }
.fp-lb__naam { display: inline-flex; align-items: center; gap: 0.5rem; font: inherit; font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-lb__jij { padding: 0.0625rem 0.5rem; border-radius: 999px; background: var(--primary); color: var(--primary-foreground); font-size: 0.6875rem; font-weight: 600; }
.fp-lb__sub { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-lb__score { font: inherit; font-size: 0.875rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.fp-lb__leeg { margin: 0; padding: 1rem; text-align: center; font: inherit; font-size: 0.875rem; color: var(--muted-foreground); }
.fp-lb__paginering { display: flex; align-items: center; justify-content: center; gap: 0.75rem; margin-top: 1rem; }
.fp-lb__pagina { font: inherit; font-size: 0.8125rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }