This commit is contained in:
Marc Wieland 2025-12-31 01:17:12 +01:00
commit b7a802a52c
34 changed files with 4567 additions and 0 deletions

134
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,134 @@
# AI Coding Agent Instructions
## Project Overview
Volleyball Team Manager - Eine moderne React-Webapp zur Verwaltung von Volleyball-Teams in zwei vordefinierten Gruppen (Bundesliga & Champions League). Fokus auf hohe Usability und User Experience mit responsivem Design, Animations und intuitiver Bedienung. Lokale Browser-Speicherung via localStorage, kein Backend nötig.
## Architecture & Key Components
- **Frontend** (`src/`): React 18 + Vite + Tailwind CSS
- `components/`: Modulare UI-Components (Header, GroupSection, TeamForm, TeamCard, Toast)
- `hooks/useTeams.js`: Custom Hook für localStorage-Management und Team-Logik
- `App.jsx`: Main App mit State & Event-Handling
- `index.css`: Global Styles mit Tailwind Utilities
- **Data Flow**: User Input → React State (useTeams hook) → localStorage sync → Component Re-render
- **Build**: Vite für schnelle Entwicklung mit Hot Module Replacement
- **Container**: Docker Multi-stage Build (Node builder → nginx production)
## Developer Workflows
### Local Development
```bash
npm install
npm run dev
# http://localhost:5173 mit Hot Reload
```
### Production Build
```bash
npm run build # Output: dist/
npm run preview # Test production build lokal
```
### Docker (Production)
```bash
docker-compose up --build
# http://localhost:3000
```
### Building Image
```bash
docker build -t team-manager .
# Multi-stage: Node build stage (install deps, build) → nginx serve stage
```
## Project Conventions
### Code Style
- **React**: Functional Components mit Hooks (useState, useEffect), keine Class Components
- **JavaScript**: Modern ES6+, keine async/await nötig (synchrone localStorage)
- **CSS**: Tailwind CSS Utility-First, keine custom CSS außer in `index.css` @layer directives
- **Naming**: camelCase für Functions/Variables, PascalCase für Components
### File Organization
```
src/
├── components/ # Dumb/presentational components
├── hooks/ # Custom hooks (state logic)
├── App.jsx # Container component
├── main.jsx # Vite entry point
└── index.css # Global styles + Tailwind
```
### Component Pattern
```jsx
// src/components/MyComponent.jsx
export default function MyComponent({ prop1, onEvent }) {
const [state, setState] = useState()
return (
<div className="...">
{/* JSX */}
</div>
)
}
```
### Data Pattern: useTeams Hook
```javascript
// In any component
const { teams, addTeam, deleteTeam, resetAll } = useTeams()
// teams structure: { bundesliga: [{id, name, chant, createdAt}], championsleague: [...] }
// Alle Änderungen auto-persist zu localStorage
```
### Tailwind + Custom Components
Häufige Klassen im `@layer components` in `index.css`:
- `.btn-primary`, `.btn-danger`, `.btn-sm`
- `.input-field`
- `.card` für weiße Boxes mit Shadow
## Integration Points
- **Tailwind CSS**: Konfiguriert in `tailwind.config.js` mit custom colors (primary #667eea, secondary #764ba2)
- **PostCSS**: Auto-processing für Tailwind in `postcss.config.js`
- **localStorage API**: `useTeams` hook handelt alle Read/Write (key: 'teamManagerData')
- **Vite**: Schneller dev server + production build optimizer
- **Docker**: Node:20-alpine build + nginx:alpine serve (optimierte Layer)
## Common Patterns
### Team hinzufügen (Form → Hook)
1. TeamForm mit Name/Chant-Inputs (mit Validation)
2. onSubmit ruft `addTeam(group, name, chant)` auf
3. Hook erstellt Team-Objekt (id=timestamp, createdAt)
4. setState → localStorage.setItem('teamManagerData', JSON.stringify(newTeams))
5. Component re-render mit aktualisierten teams
### Error Handling & Validation
- Form-Validierung in TeamForm (nicht leer, max length)
- Error-Messages inline unter Inputs anzeigen
- Toast für Bestätigungen (success/error/info)
- Try-catch in useTeams.loadData() für corrupted localStorage
### Toast Notifications
```jsx
const [toast, setToast] = useState(null)
showToast('Message', 'success') // success|error|info
// Toast auto-dismissed nach 3s
```
### Animations
Tailwind animate-* in `tailwind.config.js` definiert:
- `.animate-fade-in` → opacity transition
- `.animate-slide-in/down/up` → transform transitions
- Auf Components anwenden: `className="animate-fade-in"`
## When You're Stuck
1. Check `src/hooks/useTeams.js` für localStorage pattern
2. Check `src/components/` für Best-Practice Component-Struktur (Props, Event Handlers)
3. Tailwind docs für Styling (keine custom CSS nötig!)
4. `README.md` für Commands & Architecture Übersicht
5. Browser DevTools → Application → localStorage um 'teamManagerData' zu inspizieren

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
node_modules/
dist/
*.log
.env
.env.local
.vscode/
.idea/

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# Stage 1: Build React App
FROM node:20-alpine AS builder
WORKDIR /app
# Dependencies installieren
COPY package*.json ./
RUN npm ci
# App bauen
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
# nginx.conf kopieren
COPY nginx/nginx.conf /etc/nginx/nginx.conf
# Built app aus builder stage kopieren
COPY --from=builder /app/dist /usr/share/nginx/html
# Port 80 exponieren
EXPOSE 80
# nginx starten
CMD ["nginx", "-g", "daemon off;"]

146
README.md Normal file
View File

@ -0,0 +1,146 @@
# Volleyball Team Manager - React Web App
Eine moderne React-Webapp zur Verwaltung von Volleyball-Teams in zwei vordefinierten Gruppen (Bundesliga & Champions League). Mit hoher Usability, responsivem Design und lokaler Datenspeicherung.
## Features
- 🏐 Zwei vordefinierte Gruppen: Volleyball Bundesliga & Champions League
- 🎽 Teams mit Namen und Schlachtruf anlegen
- 💾 Lokale Speicherung im Browser (localStorage)
- 📱 Vollständig responsive (Mobil, Tablet, Desktop)
- ✨ Moderne UI mit Tailwind CSS
- 🎬 Smooth Animations & Übergänge
- 🚀 Schnell (Vite Build)
- 🐳 Docker Multi-stage Build
## Tech Stack
- **Frontend**: React 18 + Vite
- **Styling**: Tailwind CSS 3
- **Storage**: Browser localStorage
- **Server**: nginx (Alpine)
- **Container**: Docker & Docker Compose
## Schnellstart
### Mit Docker (Production)
```bash
docker-compose up --build
```
App läuft unter `http://localhost:3000`
### Lokal entwickeln
```bash
# Dependencies installieren
npm install
# Dev-Server starten (Hot Reload)
npm run dev
```
App läuft unter `http://localhost:5173`
### Production Build
```bash
npm run build
npm run preview
```
## Ordnerstruktur
```
.
├── src/
│ ├── components/ # React Components
│ │ ├── Header.jsx
│ │ ├── GroupSection.jsx
│ │ ├── TeamForm.jsx
│ │ ├── TeamCard.jsx
│ │ └── Toast.jsx
│ ├── hooks/ # Custom Hooks
│ │ └── useTeams.js # localStorage Management
│ ├── App.jsx # Main App Component
│ ├── main.jsx # Entry Point
│ └── index.css # Global Styles & Tailwind
├── nginx/
│ └── nginx.conf # Web-Server Konfiguration
├── public/ # Static Assets
├── package.json # Dependencies
├── vite.config.js # Vite Config
├── tailwind.config.js # Tailwind Config
├── postcss.config.js # PostCSS Config
├── Dockerfile # Multi-stage Docker Build
└── docker-compose.yml # Docker Compose Config
```
## Verwendung
1. **Team hinzufügen**: Klick auf "Team hinzufügen" → Name & Schlachtruf eingeben → Bestätigen
2. **Team löschen**: 🗑️ Button in der Team-Card
3. **Alle löschen**: "Alle Teams löschen" Button
Teams werden lokal im Browser gespeichert (localStorage) und bleiben erhalten bis der Cache geleert wird.
## Development
### Neue Component hinzufügen
```jsx
// src/components/MyComponent.jsx
export default function MyComponent() {
return <div>Hallo</div>
}
```
### Custom Hook für State-Logik
```jsx
// src/hooks/useMyLogic.js
import { useState } from 'react'
export function useMyLogic() {
const [state, setState] = useState()
return { state, setState }
}
```
### Tailwind Classes
```jsx
<button className="btn-primary">Primary</button>
<button className="btn-danger">Danger</button>
<input className="input-field" />
```
## Performance
- Vite: ~1.5s dev server startup
- React Fast Refresh: Code ändern → sofort im Browser sichtbar
- Docker: Optimierter Multi-stage Build (nur ~50MB production image)
## Browser-Unterstützung
Alle modernen Browser (Chrome, Firefox, Safari, Edge) mit ES6+ Support.
## Troubleshooting
### Docker Build schlägt fehl
```bash
# Cache löschen und neu bauen
docker-compose build --no-cache
# Oder komplett aufräumen
docker-compose down -v
docker system prune -a
```
### localhost:3000 nicht erreichbar
```bash
# Logs ansehen
docker-compose logs -f
# Container status
docker-compose ps
```
### npm install Fehler lokal
```bash
rm -rf node_modules package-lock.json
npm install
```

45
app/index.html Normal file
View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Manager - Bundesliga & Champions League</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>⚽ Team Manager</h1>
<p>Erstelle deine Teams für Bundesliga und Champions League</p>
</header>
<div class="groups-container">
<!-- Bundesliga -->
<section class="group">
<h2>🇩🇪 Bundesliga</h2>
<div class="form-group">
<input type="text" id="bundesliga-name" placeholder="Team Name" maxlength="50">
<input type="text" id="bundesliga-chant" placeholder="Schlachtruf" maxlength="100">
<button onclick="addTeam('bundesliga')">Team hinzufügen</button>
</div>
<div id="bundesliga-teams" class="teams-list"></div>
</section>
<!-- Champions League -->
<section class="group">
<h2>🏆 Champions League</h2>
<div class="form-group">
<input type="text" id="championsleague-name" placeholder="Team Name" maxlength="50">
<input type="text" id="championsleague-chant" placeholder="Schlachtruf" maxlength="100">
<button onclick="addTeam('championsleague')">Team hinzufügen</button>
</div>
<div id="championsleague-teams" class="teams-list"></div>
</section>
</div>
<button id="reset-btn" onclick="resetAll()" class="reset-button">Alle Teams löschen</button>
</div>
<script src="script.js"></script>
</body>
</html>

122
app/script.js Normal file
View File

@ -0,0 +1,122 @@
// Teams Daten (im localStorage speichern für Session-Persistierung)
const STORAGE_KEY = 'teamManagerData';
// Initialisieren
document.addEventListener('DOMContentLoaded', () => {
loadTeams();
});
// Team hinzufügen
function addTeam(group) {
const nameInput = document.getElementById(`${group}-name`);
const chantInput = document.getElementById(`${group}-chant`);
const name = nameInput.value.trim();
const chant = chantInput.value.trim();
// Validierung
if (!name) {
alert('Bitte gib einen Team-Namen ein!');
nameInput.focus();
return;
}
if (!chant) {
alert('Bitte gib einen Schlachtruf ein!');
chantInput.focus();
return;
}
// Daten laden
const data = loadData();
// Neues Team erstellen
const team = {
id: Date.now(),
name: name,
chant: chant,
createdAt: new Date().toLocaleString('de-DE')
};
// Zum entsprechenden Gruppe hinzufügen
if (!data[group]) {
data[group] = [];
}
data[group].push(team);
// Speichern
saveData(data);
// Eingabefelder leeren
nameInput.value = '';
chantInput.value = '';
nameInput.focus();
// UI aktualisieren
renderTeams(group);
}
// Team löschen
function deleteTeam(group, teamId) {
if (!confirm('Möchtest du dieses Team wirklich löschen?')) {
return;
}
const data = loadData();
data[group] = data[group].filter(team => team.id !== teamId);
saveData(data);
renderTeams(group);
}
// Teams rendern
function renderTeams(group) {
const data = loadData();
const container = document.getElementById(`${group}-teams`);
const teams = data[group] || [];
if (teams.length === 0) {
container.innerHTML = '<div class="empty-message">Noch keine Teams hinzugefügt</div>';
return;
}
container.innerHTML = teams.map(team => `
<div class="team-card">
<h3>🎽 ${escapeHtml(team.name)}</h3>
<p>"${escapeHtml(team.chant)}"</p>
<button onclick="deleteTeam('${group}', ${team.id})">Löschen</button>
</div>
`).join('');
}
// Alle Teams laden
function loadTeams() {
renderTeams('bundesliga');
renderTeams('championsleague');
}
// Alle Teams löschen
function resetAll() {
if (!confirm('Möchtest du wirklich ALLE Teams löschen?')) {
return;
}
localStorage.removeItem(STORAGE_KEY);
loadTeams();
}
// LocalStorage Hilfsfunktionen
function loadData() {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : { bundesliga: [], championsleague: [] };
}
function saveData(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
// XSS-Schutz
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

191
app/styles.css Normal file
View File

@ -0,0 +1,191 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
color: white;
margin-bottom: 40px;
}
header h1 {
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
header p {
font-size: 1.1em;
opacity: 0.9;
}
.groups-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 30px;
}
.group {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.group h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.8em;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 25px;
}
input {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 1em;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 5px rgba(102, 126, 234, 0.3);
}
button {
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #764ba2;
}
button:active {
transform: scale(0.98);
}
.teams-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.team-card {
background: #f5f5f5;
border-left: 5px solid #667eea;
padding: 15px;
border-radius: 6px;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.team-card h3 {
color: #333;
margin-bottom: 5px;
font-size: 1.2em;
}
.team-card p {
color: #666;
font-size: 0.95em;
font-style: italic;
margin-bottom: 10px;
}
.team-card button {
background: #e74c3c;
padding: 8px 15px;
font-size: 0.9em;
width: 100%;
}
.team-card button:hover {
background: #c0392b;
}
.reset-button {
width: 100%;
background: #e74c3c;
padding: 15px;
font-size: 1.1em;
margin-top: 20px;
}
.reset-button:hover {
background: #c0392b;
}
.empty-message {
color: #999;
text-align: center;
padding: 20px;
font-style: italic;
}
@media (max-width: 768px) {
.groups-container {
grid-template-columns: 1fr;
}
header h1 {
font-size: 2em;
}
.group {
padding: 20px;
}
}

11
docker-compose.yml Normal file
View File

@ -0,0 +1,11 @@
version: '3.8'
services:
web:
build: .
ports:
- "3000:80"
container_name: team-manager
restart: unless-stopped
environment:
- NODE_ENV=production

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

44
nginx/nginx.conf Normal file
View File

@ -0,0 +1,44 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA Fallback - alle Anfragen zu index.html routen
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# CSS und JS cachen
location ~* \.(css|js)$ {
expires 1d;
add_header Cache-Control "public, immutable";
}
}
}

2608
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "team-manager",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .js,.jsx"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^5.0.7"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

78
src/App.jsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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>
)
}

42
tailwind.config.js Normal file
View File

@ -0,0 +1,42 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,jsx}",
],
theme: {
extend: {
colors: {
primary: '#667eea',
secondary: '#764ba2',
},
fontFamily: {
sans: ['Segoe UI', 'Tahoma', 'Geneva', 'Verdana', 'sans-serif'],
},
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-in': {
'0%': { opacity: '0', transform: 'translateX(-10px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'slide-down': {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'slide-up': {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.3s ease-out',
'slide-in': 'slide-in 0.3s ease-out',
'slide-down': 'slide-down 0.3s ease-out',
'slide-up': 'slide-up 0.3s ease-out',
},
},
},
plugins: [],
}

14
vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
},
build: {
outDir: 'dist',
sourcemap: false
}
})