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:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_FOOTBALL_API_KEY=your_api_key_here
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Eredivisie</title>
|
||||||
|
<link rel="icon" href="https://crests.football-data.org/ED.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/App.jsx
Normal file
148
src/App.jsx
Normal 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
37
src/App.module.css
Normal 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
16
src/competitions.js
Normal 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
|
||||||
26
src/components/CompetitionPicker.jsx
Normal file
26
src/components/CompetitionPicker.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/components/CompetitionPicker.module.css
Normal file
68
src/components/CompetitionPicker.module.css
Normal 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;
|
||||||
|
}
|
||||||
118
src/components/StandingsTable.jsx
Normal file
118
src/components/StandingsTable.jsx
Normal 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 ''
|
||||||
|
}
|
||||||
147
src/components/StandingsTable.module.css
Normal file
147
src/components/StandingsTable.module.css
Normal 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;
|
||||||
|
}
|
||||||
137
src/components/TeamMatchesModal.jsx
Normal file
137
src/components/TeamMatchesModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
src/components/TeamMatchesModal.module.css
Normal file
198
src/components/TeamMatchesModal.module.css
Normal 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
12
src/index.css
Normal 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
10
src/main.jsx
Normal 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>,
|
||||||
|
)
|
||||||
15
vite.config.js
Normal file
15
vite.config.js
Normal file
@@ -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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user