Initial commit: football competition standings viewer

Vite + React app using football-data.org API to display standings
and match details for 12 competitions. Supports competition switching,
team match history modal, and Vite proxy to handle CORS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 07:33:50 +01:00
commit d8fba41ea5
16 changed files with 967 additions and 0 deletions

148
src/App.jsx Normal file
View File

@@ -0,0 +1,148 @@
import { useState, useEffect, useCallback } from 'react'
import StandingsTable from './components/StandingsTable'
import TeamMatchesModal from './components/TeamMatchesModal'
import CompetitionPicker from './components/CompetitionPicker'
import styles from './App.module.css'
const API_KEY = import.meta.env.VITE_FOOTBALL_API_KEY
export default function App() {
const [selectedCode, setSelectedCode] = useState('DED')
const [standings, setStandings] = useState(null)
const [competition, setCompetition] = useState(null)
const [season, setSeason] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [selectedTeam, setSelectedTeam] = useState(null)
// Cache competition emblems keyed by code so the picker can show them
const [emblems, setEmblems] = useState({})
const closeModal = useCallback(() => setSelectedTeam(null), [])
useEffect(() => {
if (!API_KEY) {
setError('No API key found. Create a .env file with VITE_FOOTBALL_API_KEY=your_key')
setLoading(false)
return
}
setLoading(true)
setError(null)
setStandings(null)
setSelectedTeam(null)
fetch(`/api/competitions/${selectedCode}/standings`, {
headers: { 'X-Auth-Token': API_KEY },
})
.then((res) => {
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`)
return res.json()
})
.then((data) => {
const total = data.standings?.find((s) => s.type === 'TOTAL')
setStandings(total?.table ?? [])
setCompetition(data.competition)
setSeason(data.season)
// Cache the emblem for this competition
if (data.competition?.emblem) {
setEmblems((prev) => ({ ...prev, [selectedCode]: data.competition.emblem }))
}
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [selectedCode])
return (
<div className={styles.app}>
<header className={styles.header}>
{competition?.emblem && (
<img src={competition.emblem} alt={competition.name} className={styles.emblem} />
)}
<div>
<h1 className={styles.title}>{competition?.name ?? '—'}</h1>
{season && (
<p className={styles.season}>
{formatSeason(season)}
{season.currentMatchday != null && ` — Speelronde ${season.currentMatchday}`}
</p>
)}
</div>
</header>
<CompetitionPicker
selected={selectedCode}
onChange={setSelectedCode}
emblems={emblems}
/>
<main className={styles.main}>
{loading && <Spinner />}
{error && <ErrorMessage message={error} />}
{standings && standings.length > 0 && (
<StandingsTable rows={standings} onTeamClick={setSelectedTeam} />
)}
{!loading && !error && standings?.length === 0 && (
<p style={{ color: '#666', textAlign: 'center', padding: '3rem' }}>
Geen standen beschikbaar voor deze competitie.
</p>
)}
</main>
{selectedTeam && season && (
<TeamMatchesModal
team={selectedTeam}
season={season}
competitionCode={selectedCode}
onClose={closeModal}
/>
)}
</div>
)
}
function formatSeason(season) {
const start = new Date(season.startDate).getFullYear()
const end = new Date(season.endDate).getFullYear()
return start === end ? `${start}` : `${start}${end}`
}
function Spinner() {
return (
<div style={{ textAlign: 'center', padding: '4rem', color: '#e87722' }}>
<div
style={{
width: 48,
height: 48,
border: '4px solid #333',
borderTop: '4px solid #e87722',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
margin: '0 auto 1rem',
}}
/>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
<p>Laden</p>
</div>
)
}
function ErrorMessage({ message }) {
return (
<div
style={{
background: '#1e1e1e',
border: '1px solid #c0392b',
borderRadius: 8,
padding: '1.5rem',
color: '#e74c3c',
maxWidth: 600,
margin: '2rem auto',
}}
>
<strong>Fout:</strong> {message}
</div>
)
}

37
src/App.module.css Normal file
View File

@@ -0,0 +1,37 @@
.app {
max-width: 960px;
margin: 0 auto;
padding: 0 1rem 4rem;
}
.header {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 2rem 0 1.75rem;
border-bottom: 2px solid #e87722;
margin-bottom: 2rem;
}
.emblem {
width: 64px;
height: 64px;
object-fit: contain;
}
.title {
font-size: 1.75rem;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.02em;
}
.season {
font-size: 0.875rem;
color: #888;
margin-top: 0.25rem;
}
.main {
width: 100%;
}

16
src/competitions.js Normal file
View File

@@ -0,0 +1,16 @@
const COMPETITIONS = [
{ code: 'PL', name: 'Premier League', country: 'England' },
{ code: 'CL', name: 'Champions League', country: 'Europe' },
{ code: 'BL1', name: 'Bundesliga', country: 'Germany' },
{ code: 'SA', name: 'Serie A', country: 'Italy' },
{ code: 'PD', name: 'Primera División', country: 'Spain' },
{ code: 'FL1', name: 'Ligue 1', country: 'France' },
{ code: 'DED', name: 'Eredivisie', country: 'Netherlands' },
{ code: 'PPL', name: 'Primeira Liga', country: 'Portugal' },
{ code: 'ELC', name: 'Championship', country: 'England' },
{ code: 'BSA', name: 'Brasileirão Série A', country: 'Brazil' },
{ code: 'EC', name: 'European Championship', country: 'Europe' },
{ code: 'WC', name: 'FIFA World Cup', country: 'World' },
]
export default COMPETITIONS

View File

@@ -0,0 +1,26 @@
import COMPETITIONS from '../competitions'
import styles from './CompetitionPicker.module.css'
export default function CompetitionPicker({ selected, onChange, emblems }) {
return (
<nav className={styles.nav}>
<ul className={styles.list}>
{COMPETITIONS.map((comp) => (
<li key={comp.code}>
<button
className={`${styles.btn} ${selected === comp.code ? styles.active : ''}`}
onClick={() => onChange(comp.code)}
title={comp.country}
>
{emblems[comp.code] && (
<img src={emblems[comp.code]} alt="" className={styles.emblem} />
)}
<span className={styles.name}>{comp.name}</span>
<span className={styles.country}>{comp.country}</span>
</button>
</li>
))}
</ul>
</nav>
)
}

View File

@@ -0,0 +1,68 @@
.nav {
overflow-x: auto;
margin-bottom: 2rem;
/* hide scrollbar but keep scrollability */
scrollbar-width: none;
}
.nav::-webkit-scrollbar { display: none; }
.list {
display: flex;
gap: 0.5rem;
list-style: none;
padding-bottom: 0.25rem;
min-width: max-content;
}
.btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.65rem 0.9rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
min-width: 80px;
}
.btn:hover {
background: #222;
border-color: #444;
}
.btn.active {
background: #1c2a3a;
border-color: #e87722;
}
.emblem {
width: 28px;
height: 28px;
object-fit: contain;
}
.name {
font-size: 0.7rem;
font-weight: 600;
color: #ccc;
white-space: nowrap;
text-align: center;
line-height: 1.2;
}
.btn.active .name {
color: #fff;
}
.country {
font-size: 0.62rem;
color: #555;
white-space: nowrap;
}
.btn.active .country {
color: #e87722;
}

View File

@@ -0,0 +1,118 @@
import styles from './StandingsTable.module.css'
const PROMOTION_SPOTS = 1 // Champions League direct
const EUROPA_SPOTS = 3 // Europa League / Conference spots (positions 2-4)
const RELEGATION_PLAYOFF = 16 // Relegation playoff
const RELEGATION_DIRECT = 18 // Direct relegation
export default function StandingsTable({ rows, onTeamClick }) {
return (
<div className={styles.wrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.pos}>#</th>
<th className={styles.team}>Club</th>
<th title="Gespeeld">G</th>
<th title="Gewonnen">W</th>
<th title="Gelijk">G</th>
<th title="Verloren">V</th>
<th title="Doelpunten voor">DV</th>
<th title="Doelpunten tegen">DT</th>
<th title="Doelsaldo">DS</th>
<th className={styles.pts} title="Punten">Ptn</th>
<th className={styles.form} title="Laatste 5 wedstrijden">Vorm</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.team.id}
className={`${rowClass(row.position, styles)} ${styles.clickable}`}
onClick={() => onTeamClick(row.team)}
>
<td className={styles.pos}>
<span className={`${styles.posIndicator} ${posColor(row.position)}`}>
{row.position}
</span>
</td>
<td className={styles.team}>
<img
src={row.team.crest}
alt={row.team.shortName}
className={styles.crest}
loading="lazy"
/>
<span className={styles.teamName}>{row.team.shortName ?? row.team.name}</span>
</td>
<td>{row.playedGames}</td>
<td>{row.won}</td>
<td>{row.draw}</td>
<td>{row.lost}</td>
<td>{row.goalsFor}</td>
<td>{row.goalsAgainst}</td>
<td className={goalDiffClass(row.goalDifference, styles)}>
{row.goalDifference > 0 ? `+${row.goalDifference}` : row.goalDifference}
</td>
<td className={styles.pts}>{row.points}</td>
<td className={styles.form}>
{row.form
? row.form.split(',').map((r, i) => (
<span key={i} className={`${styles.formBadge} ${formClass(r, styles)}`}>
{r}
</span>
))
: '—'}
</td>
</tr>
))}
</tbody>
</table>
<div className={styles.legend}>
<LegendItem color="var(--cl)" label="Champions League" />
<LegendItem color="var(--el)" label="Europa League / Conference" />
<LegendItem color="var(--rp)" label="Degradatie play-off" />
<LegendItem color="var(--rel)" label="Degradatie" />
</div>
</div>
)
}
function LegendItem({ color, label }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.78rem', color: '#888' }}>
<span style={{ width: 10, height: 10, borderRadius: 2, background: color, flexShrink: 0 }} />
{label}
</div>
)
}
function rowClass(pos) {
if (pos <= PROMOTION_SPOTS) return styles.rowCl
if (pos <= EUROPA_SPOTS + PROMOTION_SPOTS) return styles.rowEl
if (pos === RELEGATION_PLAYOFF) return styles.rowRp
if (pos >= RELEGATION_DIRECT) return styles.rowRel
return ''
}
function posColor(pos) {
if (pos <= PROMOTION_SPOTS) return styles.dotCl
if (pos <= EUROPA_SPOTS + PROMOTION_SPOTS) return styles.dotEl
if (pos === RELEGATION_PLAYOFF) return styles.dotRp
if (pos >= RELEGATION_DIRECT) return styles.dotRel
return styles.dotNeutral
}
function goalDiffClass(diff, styles) {
if (diff > 0) return styles.positive
if (diff < 0) return styles.negative
return ''
}
function formClass(result, styles) {
if (result === 'W') return styles.formW
if (result === 'D') return styles.formD
if (result === 'L') return styles.formL
return ''
}

View File

@@ -0,0 +1,147 @@
:root {
--cl: #1a6fb5;
--el: #e87722;
--rp: #8e44ad;
--rel: #c0392b;
}
.wrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
white-space: nowrap;
}
.table thead tr {
border-bottom: 1px solid #2a2a2a;
}
.table th {
text-align: center;
padding: 0.6rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #666;
}
.table th.pos,
.table th.team {
text-align: left;
}
.table td {
text-align: center;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid #1a1a1a;
color: #ccc;
}
.table tbody tr:hover {
background: #161616;
}
.clickable {
cursor: pointer;
}
/* Position column */
.pos {
width: 48px;
text-align: left !important;
}
.posIndicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 700;
color: #fff;
}
.dotCl { background: var(--cl); }
.dotEl { background: var(--el); }
.dotRp { background: var(--rp); }
.dotRel { background: var(--rel); }
.dotNeutral { background: #333; color: #aaa; }
/* Row highlight via left border */
.rowCl { border-left: 3px solid var(--cl); }
.rowEl { border-left: 3px solid var(--el); }
.rowRp { border-left: 3px solid var(--rp); }
.rowRel { border-left: 3px solid var(--rel); }
/* Team column */
.team {
display: flex !important;
align-items: center;
gap: 0.6rem;
text-align: left !important;
min-width: 180px;
}
.crest {
width: 22px;
height: 22px;
object-fit: contain;
flex-shrink: 0;
}
.teamName {
color: #fff;
font-weight: 500;
}
/* Points */
.pts {
font-weight: 700;
color: #fff !important;
}
/* Goal difference */
.positive { color: #2ecc71 !important; }
.negative { color: #e74c3c !important; }
/* Form badges */
.form {
display: flex !important;
gap: 3px;
justify-content: center;
align-items: center;
min-width: 90px;
}
.formBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 700;
color: #fff;
}
.formW { background: #27ae60; }
.formD { background: #555; }
.formL { background: #c0392b; }
/* Legend */
.legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #1f1f1f;
}

View File

@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react'
import styles from './TeamMatchesModal.module.css'
const API_KEY = import.meta.env.VITE_FOOTBALL_API_KEY
export default function TeamMatchesModal({ team, season, competitionCode, onClose }) {
const [matches, setMatches] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const seasonYear = new Date(season.startDate).getFullYear()
fetch(`/api/teams/${team.id}/matches?competitions=${competitionCode}&season=${seasonYear}`, {
headers: { 'X-Auth-Token': API_KEY },
})
.then((res) => {
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`)
return res.json()
})
.then((data) => {
setMatches(data.matches ?? [])
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [team.id, season, competitionCode])
// Close on backdrop click
function handleBackdrop(e) {
if (e.target === e.currentTarget) onClose()
}
// Close on Escape
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const played = matches?.filter((m) => m.status === 'FINISHED') ?? []
const upcoming = matches?.filter((m) => m.status !== 'FINISHED') ?? []
return (
<div className={styles.backdrop} onClick={handleBackdrop}>
<div className={styles.modal}>
<header className={styles.header}>
<div className={styles.teamInfo}>
<img src={team.crest} alt={team.shortName} className={styles.crest} />
<h2>{team.name ?? team.shortName}</h2>
</div>
<button className={styles.close} onClick={onClose} aria-label="Sluiten"></button>
</header>
<div className={styles.body}>
{loading && <ModalSpinner />}
{error && <p className={styles.error}>{error}</p>}
{matches && (
<>
{upcoming.length > 0 && (
<Section title="Aankomende wedstrijden" matches={upcoming} team={team} upcoming />
)}
<Section title="Gespeelde wedstrijden" matches={[...played].reverse()} team={team} />
</>
)}
</div>
</div>
</div>
)
}
function Section({ title, matches, team, upcoming = false }) {
if (matches.length === 0) return null
return (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{title}</h3>
<ul className={styles.matchList}>
{matches.map((m) => (
<MatchRow key={m.id} match={m} teamId={team.id} upcoming={upcoming} />
))}
</ul>
</div>
)
}
function MatchRow({ match, teamId, upcoming }) {
const isHome = match.homeTeam.id === teamId
const home = match.homeTeam
const away = match.awayTeam
const date = new Date(match.utcDate)
const dateStr = date.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' })
const timeStr = date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
let result = null
let resultClass = ''
if (!upcoming && match.score?.fullTime) {
const { home: gh, away: ga } = match.score.fullTime
const scored = isHome ? gh : ga
const conceded = isHome ? ga : gh
result = `${gh} ${ga}`
if (scored > conceded) resultClass = styles.win
else if (scored === conceded) resultClass = styles.draw
else resultClass = styles.loss
}
return (
<li className={styles.matchRow}>
<span className={styles.matchDate}>{dateStr}{upcoming && ` ${timeStr}`}</span>
<span className={styles.matchday}>R{match.matchday}</span>
<span className={`${styles.team} ${styles.homeTeam}`}>
<img src={home.crest} alt={home.shortName} className={styles.miniCrest} />
<span className={isHome ? styles.highlighted : ''}>{home.shortName ?? home.name}</span>
</span>
<span className={`${styles.score} ${resultClass}`}>
{result ?? (upcoming ? 'vs' : '')}
</span>
<span className={`${styles.team} ${styles.awayTeam}`}>
<span className={!isHome ? styles.highlighted : ''}>{away.shortName ?? away.name}</span>
<img src={away.crest} alt={away.shortName} className={styles.miniCrest} />
</span>
</li>
)
}
function ModalSpinner() {
return (
<div className={styles.spinner}>
<div className={styles.spinnerCircle} />
<p>Laden</p>
</div>
)
}

View File

@@ -0,0 +1,198 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: #161616;
border: 1px solid #2a2a2a;
border-radius: 12px;
width: 100%;
max-width: 680px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.teamInfo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.teamInfo h2 {
font-size: 1.15rem;
font-weight: 700;
color: #fff;
}
.crest {
width: 40px;
height: 40px;
object-fit: contain;
}
.close {
background: none;
border: none;
color: #666;
font-size: 1.1rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
line-height: 1;
}
.close:hover {
color: #fff;
background: #2a2a2a;
}
/* Body */
.body {
overflow-y: auto;
padding: 1.25rem 1.5rem;
flex: 1;
}
.error {
color: #e74c3c;
padding: 1rem 0;
}
/* Sections */
.section + .section {
margin-top: 1.75rem;
}
.sectionTitle {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #666;
margin-bottom: 0.75rem;
}
/* Match rows */
.matchList {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.matchRow {
display: grid;
grid-template-columns: 56px 32px 1fr 64px 1fr;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
color: #bbb;
background: #1a1a1a;
}
.matchRow:hover {
background: #202020;
}
.matchDate {
font-size: 0.78rem;
color: #666;
white-space: nowrap;
}
.matchday {
font-size: 0.72rem;
color: #555;
text-align: center;
}
/* Teams */
.team {
display: flex;
align-items: center;
gap: 0.4rem;
overflow: hidden;
}
.homeTeam {
justify-content: flex-end;
}
.awayTeam {
justify-content: flex-start;
}
.homeTeam span,
.awayTeam span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miniCrest {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.highlighted {
color: #fff;
font-weight: 600;
}
/* Score */
.score {
text-align: center;
font-weight: 700;
font-size: 0.9rem;
color: #888;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.win { color: #2ecc71; }
.draw { color: #888; }
.loss { color: #e74c3c; }
/* Spinner */
.spinner {
text-align: center;
padding: 3rem;
color: #e87722;
}
.spinnerCircle {
width: 36px;
height: 36px;
border: 3px solid #333;
border-top-color: #e87722;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

12
src/index.css Normal file
View File

@@ -0,0 +1,12 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #0f1117;
color: #e8e8e8;
min-height: 100vh;
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)