commit d8fba41ea59a699bfddd626277c71aaafbcef4e0 Author: henry Date: Sat Mar 14 07:33:50 2026 +0100 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2fed63 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_FOOTBALL_API_KEY=your_api_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deed335 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/index.html b/index.html new file mode 100644 index 0000000..da38e83 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Eredivisie + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ff30517 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "eredivisie-app", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.0" + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..2851b6c --- /dev/null +++ b/src/App.jsx @@ -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 ( +
+
+ {competition?.emblem && ( + {competition.name} + )} +
+

{competition?.name ?? '—'}

+ {season && ( +

+ {formatSeason(season)} + {season.currentMatchday != null && ` — Speelronde ${season.currentMatchday}`} +

+ )} +
+
+ + + +
+ {loading && } + {error && } + {standings && standings.length > 0 && ( + + )} + {!loading && !error && standings?.length === 0 && ( +

+ Geen standen beschikbaar voor deze competitie. +

+ )} +
+ + {selectedTeam && season && ( + + )} +
+ ) +} + +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 ( +
+
+ +

Laden…

+
+ ) +} + +function ErrorMessage({ message }) { + return ( +
+ Fout: {message} +
+ ) +} diff --git a/src/App.module.css b/src/App.module.css new file mode 100644 index 0000000..8600eaf --- /dev/null +++ b/src/App.module.css @@ -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%; +} diff --git a/src/competitions.js b/src/competitions.js new file mode 100644 index 0000000..11206d7 --- /dev/null +++ b/src/competitions.js @@ -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 diff --git a/src/components/CompetitionPicker.jsx b/src/components/CompetitionPicker.jsx new file mode 100644 index 0000000..2a1e71b --- /dev/null +++ b/src/components/CompetitionPicker.jsx @@ -0,0 +1,26 @@ +import COMPETITIONS from '../competitions' +import styles from './CompetitionPicker.module.css' + +export default function CompetitionPicker({ selected, onChange, emblems }) { + return ( + + ) +} diff --git a/src/components/CompetitionPicker.module.css b/src/components/CompetitionPicker.module.css new file mode 100644 index 0000000..f126e71 --- /dev/null +++ b/src/components/CompetitionPicker.module.css @@ -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; +} diff --git a/src/components/StandingsTable.jsx b/src/components/StandingsTable.jsx new file mode 100644 index 0000000..86dd4fa --- /dev/null +++ b/src/components/StandingsTable.jsx @@ -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 ( +
+ + + + + + + + + + + + + + + + + + {rows.map((row) => ( + onTeamClick(row.team)} + > + + + + + + + + + + + + + ))} + +
#ClubGWGVDVDTDSPtnVorm
+ + {row.position} + + + {row.team.shortName} + {row.team.shortName ?? row.team.name} + {row.playedGames}{row.won}{row.draw}{row.lost}{row.goalsFor}{row.goalsAgainst} + {row.goalDifference > 0 ? `+${row.goalDifference}` : row.goalDifference} + {row.points} + {row.form + ? row.form.split(',').map((r, i) => ( + + {r} + + )) + : '—'} +
+ +
+ + + + +
+
+ ) +} + +function LegendItem({ color, label }) { + return ( +
+ + {label} +
+ ) +} + +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 '' +} diff --git a/src/components/StandingsTable.module.css b/src/components/StandingsTable.module.css new file mode 100644 index 0000000..f546478 --- /dev/null +++ b/src/components/StandingsTable.module.css @@ -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; +} diff --git a/src/components/TeamMatchesModal.jsx b/src/components/TeamMatchesModal.jsx new file mode 100644 index 0000000..eb73ece --- /dev/null +++ b/src/components/TeamMatchesModal.jsx @@ -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 ( +
+
+
+
+ {team.shortName} +

{team.name ?? team.shortName}

+
+ +
+ +
+ {loading && } + {error &&

{error}

} + {matches && ( + <> + {upcoming.length > 0 && ( +
+ )} +
+ + )} +
+
+
+ ) +} + +function Section({ title, matches, team, upcoming = false }) { + if (matches.length === 0) return null + return ( +
+

{title}

+
    + {matches.map((m) => ( + + ))} +
+
+ ) +} + +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 ( +
  • + {dateStr}{upcoming && ` ${timeStr}`} + R{match.matchday} + + + {home.shortName} + {home.shortName ?? home.name} + + + + {result ?? (upcoming ? 'vs' : '–')} + + + + {away.shortName ?? away.name} + {away.shortName} + +
  • + ) +} + +function ModalSpinner() { + return ( +
    +
    +

    Laden…

    +
    + ) +} diff --git a/src/components/TeamMatchesModal.module.css b/src/components/TeamMatchesModal.module.css new file mode 100644 index 0000000..d688019 --- /dev/null +++ b/src/components/TeamMatchesModal.module.css @@ -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); } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..6af4f1e --- /dev/null +++ b/src/index.css @@ -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; +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/src/main.jsx @@ -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( + + + , +) diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..358e86e --- /dev/null +++ b/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'https://api.football-data.org/v4', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, +})