Replace competition picker bar with full-page tile home screen

- New CompetitionHome page shows all competitions as clickable tiles
  in a responsive grid (4 → 3 → 2 columns)
- Selecting a tile navigates to the standings page
- Standings page has a back button to return to the home screen
- Emblems pre-loaded on mount so tiles show logos immediately
- Remove CompetitionPicker component from standings view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 20:14:45 +01:00
parent 072a985ba0
commit b070b91115
4 changed files with 221 additions and 19 deletions

View File

@@ -1,24 +1,25 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import CompetitionHome from './components/CompetitionHome'
import StandingsTable from './components/StandingsTable' import StandingsTable from './components/StandingsTable'
import TeamMatchesModal from './components/TeamMatchesModal' import TeamMatchesModal from './components/TeamMatchesModal'
import CompetitionPicker from './components/CompetitionPicker' import COMPETITIONS from './competitions'
import { apiFetch } from './api' import { apiFetch } from './api'
import styles from './App.module.css' import styles from './App.module.css'
export default function App() { export default function App() {
const [selectedCode, setSelectedCode] = useState('DED') const [page, setPage] = useState('home') // 'home' | 'standings'
const [selectedCode, setSelectedCode] = useState(null)
const [standings, setStandings] = useState(null) const [standings, setStandings] = useState(null)
const [competition, setCompetition] = useState(null) const [competition, setCompetition] = useState(null)
const [season, setSeason] = useState(null) const [season, setSeason] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [selectedTeam, setSelectedTeam] = useState(null) const [selectedTeam, setSelectedTeam] = useState(null)
// Cache competition emblems keyed by code so the picker can show them
const [emblems, setEmblems] = useState({}) const [emblems, setEmblems] = useState({})
const closeModal = useCallback(() => setSelectedTeam(null), []) const closeModal = useCallback(() => setSelectedTeam(null), [])
// Load all competition emblems once on mount so the picker shows logos immediately // Load all competition emblems once on mount
useEffect(() => { useEffect(() => {
apiFetch('/competitions') apiFetch('/competitions')
.then((data) => { .then((data) => {
@@ -28,10 +29,12 @@ export default function App() {
} }
setEmblems(map) setEmblems(map)
}) })
.catch(() => {}) // non-critical — picker still works without logos .catch(() => {})
}, []) }, [])
// Fetch standings when a competition is selected
useEffect(() => { useEffect(() => {
if (!selectedCode) return
setLoading(true) setLoading(true)
setError(null) setError(null)
setStandings(null) setStandings(null)
@@ -51,29 +54,46 @@ export default function App() {
}) })
}, [selectedCode]) }, [selectedCode])
function handleSelect(code) {
setSelectedCode(code)
setPage('standings')
}
function handleBack() {
setPage('home')
setSelectedTeam(null)
}
if (page === 'home') {
return <CompetitionHome emblems={emblems} onSelect={handleSelect} />
}
const compMeta = COMPETITIONS.find((c) => c.code === selectedCode)
return ( return (
<div className={styles.app}> <div className={styles.app}>
<header className={styles.header}> <header className={styles.header}>
{competition?.emblem && ( <button className={styles.backBtn} onClick={handleBack} aria-label="Back">
<img src={competition.emblem} alt={competition.name} className={styles.emblem} /> &#8592;
</button>
{(competition?.emblem || emblems[selectedCode]) && (
<img
src={competition?.emblem ?? emblems[selectedCode]}
alt={competition?.name ?? compMeta?.name}
className={styles.emblem}
/>
)} )}
<div> <div>
<h1 className={styles.title}>{competition?.name ?? '—'}</h1> <h1 className={styles.title}>{competition?.name ?? compMeta?.name ?? '—'}</h1>
{season && ( {season && (
<p className={styles.season}> <p className={styles.season}>
{formatSeason(season)} {formatSeason(season)}
{season.currentMatchday != null && `Speelronde ${season.currentMatchday}`} {season.currentMatchday != null && `Matchday ${season.currentMatchday}`}
</p> </p>
)} )}
</div> </div>
</header> </header>
<CompetitionPicker
selected={selectedCode}
onChange={setSelectedCode}
emblems={emblems}
/>
<main className={styles.main}> <main className={styles.main}>
{loading && <Spinner />} {loading && <Spinner />}
{error && <ErrorMessage message={error} />} {error && <ErrorMessage message={error} />}
@@ -82,7 +102,7 @@ export default function App() {
)} )}
{!loading && !error && standings?.length === 0 && ( {!loading && !error && standings?.length === 0 && (
<p style={{ color: '#666', textAlign: 'center', padding: '3rem' }}> <p style={{ color: '#666', textAlign: 'center', padding: '3rem' }}>
Geen standen beschikbaar voor deze competitie. No standings available for this competition.
</p> </p>
)} )}
</main> </main>
@@ -120,7 +140,7 @@ function Spinner() {
}} }}
/> />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style> <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
<p>Laden</p> <p>Loading</p>
</div> </div>
) )
} }
@@ -138,7 +158,7 @@ function ErrorMessage({ message }) {
margin: '2rem auto', margin: '2rem auto',
}} }}
> >
<strong>Fout:</strong> {message} <strong>Error:</strong> {message}
</div> </div>
) )
} }

View File

@@ -33,6 +33,24 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.backBtn {
background: none;
border: 1px solid #2a2a2a;
border-radius: 8px;
color: #ccc;
font-size: 1.2rem;
cursor: pointer;
padding: 0.4rem 0.75rem;
line-height: 1;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s;
}
.backBtn:hover {
border-color: #e87722;
color: #fff;
}
.main { .main {
width: 100%; width: 100%;
} }

View File

@@ -0,0 +1,32 @@
import COMPETITIONS from '../competitions'
import styles from './CompetitionHome.module.css'
export default function CompetitionHome({ emblems, onSelect }) {
return (
<div className={styles.page}>
<header className={styles.header}>
<h1 className={styles.title}>Football Competitions</h1>
<p className={styles.subtitle}>Select a competition to view the standings</p>
</header>
<div className={styles.grid}>
{COMPETITIONS.map((comp) => (
<button
key={comp.code}
className={styles.tile}
onClick={() => onSelect(comp.code)}
>
<div className={styles.emblemWrap}>
{emblems[comp.code]
? <img src={emblems[comp.code]} alt={comp.name} className={styles.emblem} />
: <div className={styles.emblemPlaceholder}>{comp.code}</div>
}
</div>
<span className={styles.name}>{comp.name}</span>
<span className={styles.country}>{comp.country}</span>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
.page {
min-height: 100vh;
padding: 3rem 1.5rem 4rem;
max-width: 960px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 3rem;
}
.title {
font-size: 2.25rem;
font-weight: 800;
color: #fff;
letter-spacing: -0.03em;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1rem;
color: #666;
}
/* Tile grid */
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem 1rem;
background: #161616;
border: 1px solid #242424;
border-radius: 12px;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, background 0.15s;
text-align: center;
}
.tile:hover {
background: #1e1e1e;
border-color: #e87722;
transform: translateY(-3px);
}
.emblemWrap {
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
}
.emblem {
width: 72px;
height: 72px;
object-fit: contain;
}
.emblemPlaceholder {
width: 72px;
height: 72px;
background: #2a2a2a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: #555;
}
.name {
font-size: 0.95rem;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.country {
font-size: 0.78rem;
color: #666;
}
/* Tablet */
@media (max-width: 700px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
.title {
font-size: 1.75rem;
}
}
/* Mobile */
@media (max-width: 480px) {
.page {
padding: 2rem 1rem 3rem;
}
.grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.title {
font-size: 1.5rem;
}
.tile {
padding: 1.5rem 0.75rem;
}
.emblemWrap,
.emblem,
.emblemPlaceholder {
width: 52px;
height: 52px;
}
.name {
font-size: 0.82rem;
}
}