Init
This commit is contained in:
commit
b7a802a52c
134
.github/copilot-instructions.md
vendored
Normal file
134
.github/copilot-instructions.md
vendored
Normal 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
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.vscode/
|
||||
.idea/
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
146
README.md
Normal 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
45
app/index.html
Normal 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
122
app/script.js
Normal 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
191
app/styles.css
Normal 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
11
docker-compose.yml
Normal 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
12
index.html
Normal 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
44
nginx/nginx.conf
Normal 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
2608
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
42
tailwind.config.js
Normal file
42
tailwind.config.js
Normal 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
14
vite.config.js
Normal 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
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user