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:
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>,
|
||||
)
|
||||
Reference in New Issue
Block a user