Init
This commit is contained in:
78
src/App.jsx
Normal file
78
src/App.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Header from './components/Header'
|
||||
import FieldsSettings from './components/FieldsSettings'
|
||||
import GroupSection from './components/GroupSection'
|
||||
import Toast from './components/Toast'
|
||||
import { useTeams } from './hooks/useTeams'
|
||||
|
||||
export default function App() {
|
||||
const { teams, addTeam, deleteTeam, updateFields, resetAll } = useTeams()
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const handleAddTeam = (group, clubName, teamName, chant) => {
|
||||
if (!clubName.trim() || !teamName.trim() || !chant.trim()) {
|
||||
showToast('Bitte alle Felder ausfüllen!', 'error')
|
||||
return
|
||||
}
|
||||
addTeam(group, clubName, teamName, chant)
|
||||
showToast(`${teamName} hinzugefügt! 🎉`, 'success')
|
||||
}
|
||||
|
||||
const handleDeleteTeam = (group, teamId) => {
|
||||
deleteTeam(group, teamId)
|
||||
showToast('Team gelöscht', 'info')
|
||||
}
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (window.confirm('Alle Teams wirklich löschen?')) {
|
||||
resetAll()
|
||||
showToast('Alle Teams gelöscht', 'info')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary to-secondary p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Header />
|
||||
|
||||
<FieldsSettings
|
||||
fields={teams.fields}
|
||||
onUpdateFields={updateFields}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 my-8">
|
||||
<GroupSection
|
||||
title="<22> Volleyball Bundesliga"
|
||||
group="bundesliga"
|
||||
teams={teams.bundesliga}
|
||||
onAddTeam={handleAddTeam}
|
||||
onDeleteTeam={handleDeleteTeam}
|
||||
/>
|
||||
<GroupSection
|
||||
title="🏆 Volleyball Champions League"
|
||||
group="championsleague"
|
||||
teams={teams.championsleague}
|
||||
onAddTeam={handleAddTeam}
|
||||
onDeleteTeam={handleDeleteTeam}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={handleResetAll}
|
||||
className="btn-danger"
|
||||
>
|
||||
🗑️ Alle Teams löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toast && <Toast message={toast.message} type={toast.type} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
src/components/FieldGroup.jsx
Normal file
54
src/components/FieldGroup.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import MatchCard from './MatchCard'
|
||||
|
||||
export default function FieldGroup({ groupName, fields, matches, waitingList }) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="sticky top-0 bg-gradient-to-r from-primary to-secondary text-white p-4 rounded-t-lg">
|
||||
<h2 className="text-2xl font-bold">{groupName}</h2>
|
||||
<p className="text-sm opacity-90">Felder: {fields} | Teams: {matches.length * 2 + waitingList.length}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{/* Matches */}
|
||||
{matches.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-700 mb-3">⚡ Aktuelle Paarungen</h3>
|
||||
<div className="space-y-2">
|
||||
{matches.map((match) => (
|
||||
<div key={match.id}>
|
||||
<div className="text-xs font-semibold text-gray-500 mb-1">
|
||||
Feld {match.fieldNumber}
|
||||
</div>
|
||||
<MatchCard match={match} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg">Keine Teams in dieser Gruppe</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting List */}
|
||||
{waitingList.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t-2 border-gray-300">
|
||||
<h3 className="text-lg font-bold text-gray-700 mb-3">⏳ Warteliste ({waitingList.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{waitingList.map((team) => (
|
||||
<div key={team.id} className="bg-yellow-50 border-l-4 border-yellow-400 p-3 rounded">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase">
|
||||
{team.clubName}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-gray-800">
|
||||
{team.teamName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/components/FieldsSettings.jsx
Normal file
65
src/components/FieldsSettings.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function FieldsSettings({ fields, onUpdateFields }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(fields)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const value = parseInt(inputValue) || 3
|
||||
if (value < 1) {
|
||||
alert('Mindestens 1 Feld erforderlich!')
|
||||
setInputValue(fields)
|
||||
return
|
||||
}
|
||||
if (value > 100) {
|
||||
alert('Maximal 100 Felder!')
|
||||
setInputValue(fields)
|
||||
return
|
||||
}
|
||||
onUpdateFields(value)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">⚙️ Feld-Konfiguration</h2>
|
||||
<p className="text-gray-600">Verfügbare Volleyball-Felder: <span className="font-bold text-2xl text-primary">{fields}</span></p>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputValue(fields)
|
||||
setIsEditing(true)
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
✏️ Bearbeiten
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="input-field w-20 text-center"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleSubmit} className="btn-primary btn-sm">
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="btn-sm px-3 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold rounded-lg"
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/components/GroupSection.jsx
Normal file
51
src/components/GroupSection.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import TeamForm from './TeamForm'
|
||||
import TeamCard from './TeamCard'
|
||||
|
||||
export default function GroupSection({ title, group, teams, onAddTeam, onDeleteTeam }) {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<section className="card p-6 md:p-8 animate-fade-in">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6 pb-3 border-b-4 border-primary">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{!isFormOpen ? (
|
||||
<button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="btn-primary w-full mb-6"
|
||||
>
|
||||
➕ Team hinzufügen
|
||||
</button>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<TeamForm
|
||||
group={group}
|
||||
onSubmit={(clubName, teamName, chant) => {
|
||||
onAddTeam(group, clubName, teamName, chant)
|
||||
setIsFormOpen(false)
|
||||
}}
|
||||
onCancel={() => setIsFormOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{teams && teams.length > 0 ? (
|
||||
teams.map(team => (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
team={team}
|
||||
onDelete={() => onDeleteTeam(group, team.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 italic">
|
||||
Noch keine Teams 🏜️
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
11
src/components/Header.jsx
Normal file
11
src/components/Header.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="text-center text-white py-8 md:py-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-3 drop-shadow-lg">
|
||||
🏐 NVJ-Spielfest Planer
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl opacity-90">
|
||||
</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
32
src/components/MatchCard.jsx
Normal file
32
src/components/MatchCard.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
export default function MatchCard({ match }) {
|
||||
return (
|
||||
<div className="card p-4 rounded-lg bg-white border-l-4 border-primary hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Team 1 */}
|
||||
<div className="flex-1 text-right min-w-0">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase truncate">
|
||||
{match.team1.clubName}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-gray-800 truncate">
|
||||
{match.team1.teamName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* VS */}
|
||||
<div className="flex-shrink-0 px-4">
|
||||
<span className="text-xl font-bold text-gray-400">VS</span>
|
||||
</div>
|
||||
|
||||
{/* Team 2 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase truncate">
|
||||
{match.team2.clubName}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-gray-800 truncate">
|
||||
{match.team2.teamName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
src/components/ScoreboardModal.jsx
Normal file
58
src/components/ScoreboardModal.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
export default function ScoreboardModal({ isOpen, onClose, matches, groupName }) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-lg shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto border border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-primary to-secondary p-6 border-b border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-white">📊 Scoreboard</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white text-2xl hover:text-gray-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-100 mt-2">{groupName}</p>
|
||||
</div>
|
||||
|
||||
{/* Matches */}
|
||||
<div className="p-6 space-y-3">
|
||||
{matches.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Keine Spiele
|
||||
</div>
|
||||
) : (
|
||||
matches.map(match => (
|
||||
<div key={match.id} className="bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-primary transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Team 1 */}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-400 uppercase tracking-wide">{match.team1.clubName}</p>
|
||||
<p className="font-bold text-lg text-white">{match.team1.teamName}</p>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="text-center px-6">
|
||||
<div className="text-4xl font-mono font-bold text-primary">
|
||||
{match.score1 !== undefined ? match.score1 : '-'} : {match.score2 !== undefined ? match.score2 : '-'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Feld {match.fieldNumber}</p>
|
||||
</div>
|
||||
|
||||
{/* Team 2 */}
|
||||
<div className="flex-1 text-right">
|
||||
<p className="text-sm text-gray-400 uppercase tracking-wide">{match.team2.clubName}</p>
|
||||
<p className="font-bold text-lg text-white">{match.team2.teamName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/components/Stopwatch.jsx
Normal file
49
src/components/Stopwatch.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useStopwatch } from '../hooks/useStopwatch'
|
||||
|
||||
export default function Stopwatch() {
|
||||
const { time, isRunning, inputMinutes, togglePlay, reset, setMinutes, formatTime } = useStopwatch()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-gray-900 px-4 py-2 rounded-lg border border-gray-700">
|
||||
{/* Zeit Eingabe */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="99"
|
||||
value={inputMinutes}
|
||||
onChange={(e) => setMinutes(e.target.value)}
|
||||
disabled={isRunning}
|
||||
className="w-12 bg-gray-800 text-white text-center rounded px-2 py-1 text-sm border border-gray-600 focus:border-primary disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-gray-400 text-xs">min</span>
|
||||
</div>
|
||||
|
||||
{/* Zeitanzeige */}
|
||||
<div className="text-2xl font-mono font-bold text-primary min-w-[80px] text-center">
|
||||
{formatTime(time)}
|
||||
</div>
|
||||
|
||||
{/* Play/Pause Button */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all ${
|
||||
isRunning
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
} text-white font-bold shadow-lg hover:shadow-xl`}
|
||||
>
|
||||
{isRunning ? '⏸' : '▶'}
|
||||
</button>
|
||||
|
||||
{/* Reset Button */}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white text-lg transition-all shadow-lg hover:shadow-xl"
|
||||
title="Reset"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/components/TeamCard.jsx
Normal file
23
src/components/TeamCard.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export default function TeamCard({ team, onDelete }) {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-l-4 border-primary rounded-lg p-4 hover:shadow-md transition-shadow animate-slide-in">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">
|
||||
{team.clubName}
|
||||
</p>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-2">🏐 {team.teamName}</h3>
|
||||
<p className="text-gray-600 italic mb-2">"{team.chant}"</p>
|
||||
<p className="text-xs text-gray-400">{team.createdAt}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="btn-danger btn-sm ml-3 flex-shrink-0"
|
||||
title="Team löschen"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
src/components/TeamForm.jsx
Normal file
97
src/components/TeamForm.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function TeamForm({ group, onSubmit, onCancel }) {
|
||||
const [clubName, setClubName] = useState('')
|
||||
const [teamName, setTeamName] = useState('')
|
||||
const [chant, setChant] = useState('')
|
||||
const [errors, setErrors] = useState({})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
const newErrors = {}
|
||||
|
||||
if (!clubName.trim()) newErrors.clubName = 'Vereinsname erforderlich'
|
||||
if (!teamName.trim()) newErrors.teamName = 'Team-Name erforderlich'
|
||||
if (!chant.trim()) newErrors.chant = 'Schlachtruf erforderlich'
|
||||
if (clubName.length > 50) newErrors.clubName = 'Max. 50 Zeichen'
|
||||
if (teamName.length > 50) newErrors.teamName = 'Max. 50 Zeichen'
|
||||
if (chant.length > 100) newErrors.chant = 'Max. 100 Zeichen'
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors)
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(clubName, teamName, chant)
|
||||
setClubName('')
|
||||
setTeamName('')
|
||||
setChant('')
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 animate-slide-down">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vereinsname"
|
||||
value={clubName}
|
||||
onChange={(e) => {
|
||||
setClubName(e.target.value)
|
||||
if (errors.clubName) setErrors({ ...errors, clubName: '' })
|
||||
}}
|
||||
maxLength={50}
|
||||
className="input-field"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.clubName && <p className="text-red-500 text-sm mt-1">{errors.clubName}</p>}
|
||||
<p className="text-gray-400 text-xs mt-1">{clubName.length}/50</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Team Name"
|
||||
value={teamName}
|
||||
onChange={(e) => {
|
||||
setTeamName(e.target.value)
|
||||
if (errors.teamName) setErrors({ ...errors, teamName: '' })
|
||||
}}
|
||||
maxLength={50}
|
||||
className="input-field"
|
||||
/>
|
||||
{errors.teamName && <p className="text-red-500 text-sm mt-1">{errors.teamName}</p>}
|
||||
<p className="text-gray-400 text-xs mt-1">{teamName.length}/50</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schlachtruf"
|
||||
value={chant}
|
||||
onChange={(e) => {
|
||||
setChant(e.target.value)
|
||||
if (errors.chant) setErrors({ ...errors, chant: '' })
|
||||
}}
|
||||
maxLength={100}
|
||||
className="input-field"
|
||||
/>
|
||||
{errors.chant && <p className="text-red-500 text-sm mt-1">{errors.chant}</p>}
|
||||
<p className="text-gray-400 text-xs mt-1">{chant.length}/100</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className="btn-primary flex-1">
|
||||
✅ Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold rounded-lg transition-colors"
|
||||
>
|
||||
❌ Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
13
src/components/Toast.jsx
Normal file
13
src/components/Toast.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function Toast({ message, type = 'success' }) {
|
||||
const bgColor = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
info: 'bg-blue-500'
|
||||
}[type] || 'bg-green-500'
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg animate-slide-up`}>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/hooks/useMatches.js
Normal file
75
src/hooks/useMatches.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useMatches(teams, fields, group) {
|
||||
const [matches, setMatches] = useState([])
|
||||
const [waitingList, setWaitingList] = useState([])
|
||||
|
||||
// Matches generieren wenn Teams oder Felder sich ändern
|
||||
useEffect(() => {
|
||||
generateMatches()
|
||||
}, [teams, fields, group])
|
||||
|
||||
const generateMatches = () => {
|
||||
const groupTeams = teams[group] || []
|
||||
|
||||
if (groupTeams.length < 2) {
|
||||
setMatches([])
|
||||
setWaitingList([])
|
||||
return
|
||||
}
|
||||
|
||||
// Matches berechnen: jedes Team spielt gegen jedes andere Team
|
||||
// Pro Runde: Teams / 2 = Matches gleichzeitig
|
||||
const matchesNeeded = Math.ceil(groupTeams.length / 2)
|
||||
const canPlayPerRound = Math.min(matchesNeeded, fields)
|
||||
const teamsPerRound = canPlayPerRound * 2
|
||||
const waitingCount = groupTeams.length - teamsPerRound
|
||||
|
||||
// Erste Runde: Teams pairen
|
||||
const currentMatches = []
|
||||
for (let i = 0; i < canPlayPerRound; i++) {
|
||||
if (groupTeams[i * 2] && groupTeams[i * 2 + 1]) {
|
||||
currentMatches.push({
|
||||
id: `match-${group}-${i}`,
|
||||
fieldNumber: i + 1,
|
||||
team1: groupTeams[i * 2],
|
||||
team2: groupTeams[i * 2 + 1],
|
||||
status: 'upcoming', // upcoming, playing, finished
|
||||
score1: 0,
|
||||
score2: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const waiting = groupTeams.slice(teamsPerRound)
|
||||
|
||||
setMatches(currentMatches)
|
||||
setWaitingList(waiting)
|
||||
}
|
||||
|
||||
const updateMatchScore = (matchId, score1, score2) => {
|
||||
setMatches(matches.map(m =>
|
||||
m.id === matchId ? { ...m, score1, score2 } : m
|
||||
))
|
||||
}
|
||||
|
||||
const startMatch = (matchId) => {
|
||||
setMatches(matches.map(m =>
|
||||
m.id === matchId ? { ...m, status: 'playing' } : m
|
||||
))
|
||||
}
|
||||
|
||||
const finishMatch = (matchId) => {
|
||||
setMatches(matches.map(m =>
|
||||
m.id === matchId ? { ...m, status: 'finished' } : m
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
matches,
|
||||
waitingList,
|
||||
updateMatchScore,
|
||||
startMatch,
|
||||
finishMatch
|
||||
}
|
||||
}
|
||||
53
src/hooks/useStopwatch.js
Normal file
53
src/hooks/useStopwatch.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useStopwatch() {
|
||||
const [time, setTime] = useState(0)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [inputMinutes, setInputMinutes] = useState('5')
|
||||
|
||||
useEffect(() => {
|
||||
let interval
|
||||
if (isRunning && time > 0) {
|
||||
interval = setInterval(() => {
|
||||
setTime(t => t - 1)
|
||||
}, 1000)
|
||||
} else if (time === 0 && isRunning) {
|
||||
setIsRunning(false)
|
||||
}
|
||||
return () => clearInterval(interval)
|
||||
}, [isRunning, time])
|
||||
|
||||
const togglePlay = () => {
|
||||
setIsRunning(!isRunning)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setIsRunning(false)
|
||||
const minutes = parseInt(inputMinutes) || 5
|
||||
setTime(minutes * 60)
|
||||
}
|
||||
|
||||
const setMinutes = (minutes) => {
|
||||
const m = parseInt(minutes) || 5
|
||||
setInputMinutes(m.toString())
|
||||
if (!isRunning) {
|
||||
setTime(m * 60)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return {
|
||||
time,
|
||||
isRunning,
|
||||
inputMinutes,
|
||||
togglePlay,
|
||||
reset,
|
||||
setMinutes,
|
||||
formatTime
|
||||
}
|
||||
}
|
||||
94
src/hooks/useTeams.js
Normal file
94
src/hooks/useTeams.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'teamManagerData'
|
||||
|
||||
const initialState = {
|
||||
bundesliga: [],
|
||||
championsleague: [],
|
||||
fields: 3 // Standard: 3 Felder
|
||||
}
|
||||
|
||||
export function useTeams() {
|
||||
const [teams, setTeams] = useState(initialState)
|
||||
|
||||
// Daten aus localStorage laden beim Mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored)
|
||||
// Fallback für alte Version ohne fields
|
||||
setTeams({
|
||||
bundesliga: data.bundesliga || [],
|
||||
championsleague: data.championsleague || [],
|
||||
fields: data.fields !== undefined ? data.fields : 3
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Teams:', error)
|
||||
setTeams(initialState)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Daten in localStorage speichern bei Änderungen
|
||||
const saveToStorage = (newTeams) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newTeams))
|
||||
}
|
||||
|
||||
const addTeam = (group, clubName, teamName, chant) => {
|
||||
const newTeam = {
|
||||
id: Date.now(),
|
||||
clubName: clubName.trim(),
|
||||
teamName: teamName.trim(),
|
||||
chant: chant.trim(),
|
||||
createdAt: new Date().toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const newTeams = {
|
||||
...teams,
|
||||
[group]: [...teams[group], newTeam]
|
||||
}
|
||||
|
||||
setTeams(newTeams)
|
||||
saveToStorage(newTeams)
|
||||
}
|
||||
|
||||
const deleteTeam = (group, teamId) => {
|
||||
const newTeams = {
|
||||
...teams,
|
||||
[group]: teams[group].filter(team => team.id !== teamId)
|
||||
}
|
||||
|
||||
setTeams(newTeams)
|
||||
saveToStorage(newTeams)
|
||||
}
|
||||
|
||||
const updateFields = (fieldCount) => {
|
||||
const newTeams = {
|
||||
...teams,
|
||||
fields: Math.max(1, parseInt(fieldCount) || 3)
|
||||
}
|
||||
|
||||
setTeams(newTeams)
|
||||
saveToStorage(newTeams)
|
||||
}
|
||||
|
||||
const resetAll = () => {
|
||||
setTeams(initialState)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
teams,
|
||||
addTeam,
|
||||
deleteTeam,
|
||||
updateFields,
|
||||
resetAll
|
||||
}
|
||||
}
|
||||
25
src/index.css
Normal file
25
src/index.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-primary hover:bg-secondary text-white font-bold rounded-lg transition-colors duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-bold rounded-lg transition-colors duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1 text-sm;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-4 py-2 border-2 border-gray-200 rounded-lg focus:outline-none focus:border-primary transition-colors duration-200;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-200;
|
||||
}
|
||||
}
|
||||
19
src/main.jsx
Normal file
19
src/main.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import SetupPage from './pages/SetupPage'
|
||||
import TournamentPage from './pages/TournamentPage'
|
||||
import ScoringPage from './pages/ScoringPage'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<SetupPage />} />
|
||||
<Route path="/tournament" element={<TournamentPage />} />
|
||||
<Route path="/scoring" element={<ScoringPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
131
src/pages/ScoringPage.jsx
Normal file
131
src/pages/ScoringPage.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTeams } from '../hooks/useTeams'
|
||||
import { useMatches } from '../hooks/useMatches'
|
||||
import Header from '../components/Header'
|
||||
|
||||
export default function ScoringPage() {
|
||||
const navigate = useNavigate()
|
||||
const { teams } = useTeams()
|
||||
const { matches: championsMatches } = useMatches(teams.championsleague, teams.fields)
|
||||
const { matches: bundesligaMatches } = useMatches(teams.bundesliga, teams.fields)
|
||||
const [scores, setScores] = useState({})
|
||||
const [selectedGroup, setSelectedGroup] = useState('championsleague')
|
||||
|
||||
const allMatches = selectedGroup === 'championsleague' ? championsMatches : bundesligaMatches
|
||||
|
||||
const handleScoreChange = (matchId, team, value) => {
|
||||
setScores(prev => ({
|
||||
...prev,
|
||||
[matchId]: {
|
||||
...prev[matchId],
|
||||
[team]: parseInt(value) || 0
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSaveScore = (matchId) => {
|
||||
// TODO: Hier könnte man die Scores speichern
|
||||
console.log('Scores gespeichert:', scores[matchId])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-900 to-gray-800">
|
||||
<Header />
|
||||
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">📊 Punkte Eintragen</h1>
|
||||
<button
|
||||
onClick={() => navigate('/tournament')}
|
||||
className="btn-primary"
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gruppen Toggle */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setSelectedGroup('championsleague')}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition-all ${
|
||||
selectedGroup === 'championsleague'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
🏆 Champions League
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedGroup('bundesliga')}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition-all ${
|
||||
selectedGroup === 'bundesliga'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
⚽ Bundesliga
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Matches zum Scores eingeben */}
|
||||
<div className="space-y-4">
|
||||
{allMatches.length === 0 ? (
|
||||
<div className="bg-gray-800 rounded-lg p-6 text-center text-gray-400">
|
||||
Keine Spiele in dieser Gruppe
|
||||
</div>
|
||||
) : (
|
||||
allMatches.map(match => (
|
||||
<div key={match.id} className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Team 1 */}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-400">{match.team1.clubName}</p>
|
||||
<p className="font-bold text-white">{match.team1.teamName}</p>
|
||||
</div>
|
||||
|
||||
{/* Score Input */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="999"
|
||||
value={scores[match.id]?.team1 || ''}
|
||||
onChange={(e) => handleScoreChange(match.id, 'team1', e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-16 bg-gray-700 text-white text-center text-xl font-bold rounded px-2 py-1 border border-gray-600 focus:border-primary"
|
||||
/>
|
||||
<span className="text-xl font-bold text-gray-400">:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="999"
|
||||
value={scores[match.id]?.team2 || ''}
|
||||
onChange={(e) => handleScoreChange(match.id, 'team2', e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-16 bg-gray-700 text-white text-center text-xl font-bold rounded px-2 py-1 border border-gray-600 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team 2 */}
|
||||
<div className="flex-1 text-right">
|
||||
<p className="text-sm text-gray-400">{match.team2.clubName}</p>
|
||||
<p className="font-bold text-white">{match.team2.teamName}</p>
|
||||
</div>
|
||||
|
||||
{/* Speichern Button */}
|
||||
<button
|
||||
onClick={() => handleSaveScore(match.id)}
|
||||
className="ml-4 btn-primary btn-sm"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/pages/SetupPage.jsx
Normal file
95
src/pages/SetupPage.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Header from '../components/Header'
|
||||
import FieldsSettings from '../components/FieldsSettings'
|
||||
import GroupSection from '../components/GroupSection'
|
||||
import Toast from '../components/Toast'
|
||||
import { useTeams } from '../hooks/useTeams'
|
||||
|
||||
export default function SetupPage() {
|
||||
const { teams, addTeam, deleteTeam, updateFields, resetAll } = useTeams()
|
||||
const [toast, setToast] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const handleAddTeam = (group, clubName, teamName, chant) => {
|
||||
if (!clubName.trim() || !teamName.trim() || !chant.trim()) {
|
||||
showToast('Bitte alle Felder ausfüllen!', 'error')
|
||||
return
|
||||
}
|
||||
addTeam(group, clubName, teamName, chant)
|
||||
showToast(`${teamName} hinzugefügt! 🎉`, 'success')
|
||||
}
|
||||
|
||||
const handleDeleteTeam = (group, teamId) => {
|
||||
deleteTeam(group, teamId)
|
||||
showToast('Team gelöscht', 'info')
|
||||
}
|
||||
|
||||
const handleStartTournament = () => {
|
||||
const totalTeams = teams.bundesliga.length + teams.championsleague.length
|
||||
if (totalTeams < 2) {
|
||||
showToast('Du brauchst mindestens 2 Teams!', 'error')
|
||||
return
|
||||
}
|
||||
navigate('/tournament')
|
||||
}
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (window.confirm('Alle Teams wirklich löschen?')) {
|
||||
resetAll()
|
||||
showToast('Alle Teams gelöscht', 'info')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary to-secondary p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Header />
|
||||
|
||||
<FieldsSettings
|
||||
fields={teams.fields}
|
||||
onUpdateFields={updateFields}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 my-8">
|
||||
<GroupSection
|
||||
title="🏐 Volleyball Bundesliga"
|
||||
group="bundesliga"
|
||||
teams={teams.bundesliga}
|
||||
onAddTeam={handleAddTeam}
|
||||
onDeleteTeam={handleDeleteTeam}
|
||||
/>
|
||||
<GroupSection
|
||||
title="🏆 Volleyball Champions League"
|
||||
group="championsleague"
|
||||
teams={teams.championsleague}
|
||||
onAddTeam={handleAddTeam}
|
||||
onDeleteTeam={handleDeleteTeam}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 justify-center flex-wrap">
|
||||
<button
|
||||
onClick={handleStartTournament}
|
||||
className="btn-primary text-lg px-8 py-3"
|
||||
>
|
||||
🎮 Turnier Starten
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetAll}
|
||||
className="btn-danger text-lg px-8 py-3"
|
||||
>
|
||||
🗑️ Alle Teams löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toast && <Toast message={toast.message} type={toast.type} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
src/pages/TournamentPage.jsx
Normal file
110
src/pages/TournamentPage.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTeams } from '../hooks/useTeams'
|
||||
import { useMatches } from '../hooks/useMatches'
|
||||
import FieldGroup from '../components/FieldGroup'
|
||||
import Stopwatch from '../components/Stopwatch'
|
||||
import ScoreboardModal from '../components/ScoreboardModal'
|
||||
|
||||
export default function TournamentPage() {
|
||||
const navigate = useNavigate()
|
||||
const { teams } = useTeams()
|
||||
const [scoreboardOpen, setScoreboardOpen] = useState(false)
|
||||
const [scoreboardGroup, setScoreboardGroup] = useState('championsleague')
|
||||
|
||||
const bundesliga = useMatches(teams, teams.fields, 'bundesliga')
|
||||
const championsleague = useMatches(teams, teams.fields, 'championsleague')
|
||||
|
||||
const handleShowScoreboard = (group) => {
|
||||
setScoreboardGroup(group)
|
||||
setScoreboardOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-900 text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-primary to-secondary p-4 shadow-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl md:text-3xl font-bold">🏐 Volleyball Turnier Manager</h1>
|
||||
|
||||
{/* Center Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/scoring')}
|
||||
className="btn-primary"
|
||||
title="Punkte eintragen"
|
||||
>
|
||||
📝 Punkte
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleShowScoreboard('championsleague')}
|
||||
className="btn-primary"
|
||||
title="Scoreboard anzeigen"
|
||||
>
|
||||
📊 Ergebnisse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<Stopwatch />
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="btn-primary"
|
||||
title="Zurück zur Team-Verwaltung"
|
||||
>
|
||||
⏮️ Zurück
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Two Column Layout */}
|
||||
<div className="flex-1 overflow-hidden grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
|
||||
{/* Champions League */}
|
||||
<div className="bg-white text-gray-800 rounded-lg shadow-xl overflow-hidden flex flex-col">
|
||||
<FieldGroup
|
||||
groupName="🏆 Champions League"
|
||||
fields={teams.fields}
|
||||
matches={championsleague.matches}
|
||||
waitingList={championsleague.waitingList}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bundesliga */}
|
||||
<div className="bg-white text-gray-800 rounded-lg shadow-xl overflow-hidden flex flex-col">
|
||||
<FieldGroup
|
||||
groupName="🏐 Bundesliga"
|
||||
fields={teams.fields}
|
||||
matches={bundesliga.matches}
|
||||
waitingList={bundesliga.waitingList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="bg-gray-800 border-t border-gray-700 p-4 flex justify-around text-sm">
|
||||
<div>
|
||||
<p className="text-gray-400">Champions League</p>
|
||||
<p className="text-lg font-bold">{championsleague.matches.length} aktiv | {championsleague.waitingList.length} warten</p>
|
||||
</div>
|
||||
<div className="border-l border-gray-700 pl-4">
|
||||
<p className="text-gray-400">Bundesliga</p>
|
||||
<p className="text-lg font-bold">{bundesliga.matches.length} aktiv | {bundesliga.waitingList.length} warten</p>
|
||||
</div>
|
||||
<div className="border-l border-gray-700 pl-4">
|
||||
<p className="text-gray-400">Verfügbare Felder</p>
|
||||
<p className="text-lg font-bold">{teams.fields}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scoreboard Modal */}
|
||||
<ScoreboardModal
|
||||
isOpen={scoreboardOpen}
|
||||
onClose={() => setScoreboardOpen(false)}
|
||||
matches={scoreboardGroup === 'championsleague' ? championsleague.matches : bundesliga.matches}
|
||||
groupName={scoreboardGroup === 'championsleague' ? '🏆 Champions League' : '🏐 Bundesliga'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user