Initial release: Disc Agenda frisbee tournament platform
Some checks failed
Build and Push / build (push) Failing after 8s

Full-stack tournament management app with real-time scoring:
- Go 1.26 backend with REST API and WebSocket live scoring
- React 19 + Vite 8 frontend with mobile-first design
- File-based JSON storage with JSONL audit logs
- Multi-stage Docker build with Gitea CI/CD pipeline
- Post-tournament questionnaire with spirit voting
- Technical documentation and project description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 14:48:15 +01:00
commit a7244406fd
38 changed files with 5749 additions and 0 deletions

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frisbee Tournament</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Barlow:wght@400;500;600;700&family=Barlow+Condensed:wght@600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>

1043
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.0.0"
}
}

View File

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" fill="none">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0d2818"/>
<stop offset="100%" stop-color="#166534"/>
</linearGradient>
<linearGradient id="grass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#22c55e"/>
<stop offset="100%" stop-color="#16a34a"/>
</linearGradient>
<linearGradient id="disc-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f97316"/>
<stop offset="100%" stop-color="#fb923c"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="800" height="400" fill="url(#sky)"/>
<!-- Grass field -->
<ellipse cx="400" cy="420" rx="500" ry="120" fill="url(#grass)" opacity="0.3"/>
<!-- Field lines -->
<line x1="100" y1="350" x2="700" y2="350" stroke="#22c55e" stroke-width="1" opacity="0.2"/>
<line x1="150" y1="330" x2="650" y2="330" stroke="#22c55e" stroke-width="1" opacity="0.15"/>
<!-- Player silhouette - layout dive -->
<g transform="translate(320, 180) rotate(-15)">
<!-- Body -->
<ellipse cx="0" cy="0" rx="18" ry="35" fill="white" opacity="0.9"/>
<!-- Head -->
<circle cx="-5" cy="-42" r="14" fill="white" opacity="0.9"/>
<!-- Extended arm (throwing) -->
<line x1="15" y1="-15" x2="65" y2="-35" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
<!-- Other arm -->
<line x1="-15" y1="-10" x2="-55" y2="10" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
<!-- Legs (diving) -->
<line x1="-5" y1="30" x2="-40" y2="70" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
<line x1="5" y1="30" x2="30" y2="75" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
</g>
<!-- Flying disc -->
<g transform="translate(480, 100) rotate(-20)">
<ellipse cx="0" cy="4" rx="32" ry="10" fill="url(#disc-grad)" opacity="0.3"/>
<ellipse cx="0" cy="0" rx="28" ry="28" fill="url(#disc-grad)"/>
<ellipse cx="0" cy="0" rx="18" ry="18" fill="none" stroke="white" stroke-width="1.5" opacity="0.4"/>
<ellipse cx="0" cy="0" rx="8" ry="8" fill="white" opacity="0.2"/>
<!-- Motion trails -->
<line x1="-40" y1="5" x2="-55" y2="8" stroke="#fb923c" stroke-width="2" opacity="0.5"/>
<line x1="-38" y1="12" x2="-52" y2="14" stroke="#fb923c" stroke-width="2" opacity="0.4"/>
<line x1="-42" y1="-2" x2="-54" y2="0" stroke="#fb923c" stroke-width="2" opacity="0.3"/>
</g>
<!-- Decorative dots -->
<circle cx="120" cy="80" r="2" fill="#4ade80" opacity="0.3"/>
<circle cx="680" cy="120" r="3" fill="#4ade80" opacity="0.2"/>
<circle cx="200" cy="300" r="2" fill="#38bdf8" opacity="0.2"/>
<circle cx="600" cy="280" r="2" fill="#38bdf8" opacity="0.15"/>
<circle cx="150" cy="200" r="1.5" fill="white" opacity="0.1"/>
<circle cx="650" cy="60" r="1.5" fill="white" opacity="0.15"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

31
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Header from './components/Header';
import Footer from './components/Footer';
import HomePage from './pages/HomePage';
import TournamentPage from './pages/TournamentPage';
import SchedulePage from './pages/SchedulePage';
import GamePage from './pages/GamePage';
import QuestionnairePage from './pages/QuestionnairePage';
import ResultsPage from './pages/ResultsPage';
import PastPage from './pages/PastPage';
export default function App() {
return (
<BrowserRouter>
<div className="app">
<Header />
<Routes>
<Route path="/" element={<Navigate to="/tournament/fujarna-14-3-2026" replace />} />
<Route path="/tournament/:id" element={<TournamentPage />} />
<Route path="/tournament/:id/schedule" element={<SchedulePage />} />
<Route path="/tournament/:id/game/:gid" element={<GamePage />} />
<Route path="/tournament/:id/questionnaire" element={<QuestionnairePage />} />
<Route path="/tournament/:id/results" element={<ResultsPage />} />
<Route path="/past" element={<PastPage />} />
</Routes>
<Footer />
</div>
</BrowserRouter>
);
}

38
frontend/src/api.js Normal file
View File

@@ -0,0 +1,38 @@
const API_BASE = '/api';
async function fetchJSON(path) {
const res = await fetch(`${API_BASE}${path}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
async function postJSON(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export const getTournaments = () => fetchJSON('/tournaments');
export const getTournament = (id) => fetchJSON(`/tournaments/${id}`);
export const getSchedule = (id) => fetchJSON(`/tournaments/${id}/schedule`);
export const getScore = (tid, gid) => fetchJSON(`/tournaments/${tid}/games/${gid}/score`);
export const updateScore = (tid, gid, u) => postJSON(`/tournaments/${tid}/games/${gid}/score`, u);
export const getAuditLog = (tid, gid) => fetchJSON(`/tournaments/${tid}/games/${gid}/audit`);
export const getQuestionnaire = (id) => fetchJSON(`/tournaments/${id}/questionnaire`);
export const submitQuestionnaire = (id, d) => postJSON(`/tournaments/${id}/questionnaire`, d);
export const getQuestionnaireResults = (id) => fetchJSON(`/tournaments/${id}/questionnaire/results`);
export const getResults = (id) => fetchJSON(`/tournaments/${id}/results`);
export function createGameWebSocket(tourneyId, gameId, userId = 'anon') {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return new WebSocket(`${proto}//${location.host}/ws/game/${tourneyId}/${gameId}?user_id=${userId}`);
}
export function createTournamentWebSocket(tourneyId) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return new WebSocket(`${proto}//${location.host}/ws/tournament/${tourneyId}`);
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
export default function Footer() {
return (
<footer className="site-footer">
<p>Disc Agenda &mdash; Turnajová platforma pro ultimate frisbee &copy; {new Date().getFullYear()}</p>
</footer>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { FlyingDiscIcon } from './Icons';
export default function Header() {
const { pathname } = useLocation();
const navLinks = [
{ to: '/tournament/fujarna-14-3-2026', label: 'Domů' },
{ to: '/past', label: 'Archiv' },
];
return (
<header className="site-header">
<div className="header-inner">
<Link to="/tournament/fujarna-14-3-2026" className="header-logo">
<FlyingDiscIcon size={36} />
</Link>
<nav className="header-nav">
{navLinks.map(({ to, label }) => (
<Link
key={to}
to={to}
className={pathname === to ? 'active' : ''}
>
{label}
</Link>
))}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
export function DiscIcon({ size = 36, className = '' }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="20" cy="22" rx="18" ry="8" fill="currentColor" opacity="0.15"/>
<ellipse cx="20" cy="18" rx="16" ry="16" fill="none" stroke="currentColor" strokeWidth="2"/>
<ellipse cx="20" cy="18" rx="10" ry="10" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.5"/>
<ellipse cx="20" cy="18" rx="4" ry="4" fill="currentColor" opacity="0.3"/>
<path d="M8 14 C12 8, 28 8, 32 14" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.4"/>
</svg>
);
}
export function FlyingDiscIcon({ size = 48, className = '' }) {
return (
<svg width={size} height={size} viewBox="0 0 60 60" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="rotate(-25, 30, 30)">
<ellipse cx="30" cy="30" rx="22" ry="8" fill="currentColor" opacity="0.2"/>
<ellipse cx="30" cy="26" rx="20" ry="20" fill="none" stroke="currentColor" strokeWidth="2.5"/>
<ellipse cx="30" cy="26" rx="12" ry="12" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.4"/>
<ellipse cx="30" cy="26" rx="5" ry="5" fill="currentColor" opacity="0.25"/>
</g>
{/* Motion lines */}
<line x1="8" y1="20" x2="2" y2="22" stroke="currentColor" strokeWidth="1.5" opacity="0.3"/>
<line x1="10" y1="26" x2="3" y2="27" stroke="currentColor" strokeWidth="1.5" opacity="0.4"/>
<line x1="9" y1="32" x2="4" y2="32" stroke="currentColor" strokeWidth="1.5" opacity="0.2"/>
</svg>
);
}
export function MapPinIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
}
export function CalendarIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
);
}
export function UsersIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
);
}
export function ClockIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
);
}
export function TrophyIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
</svg>
);
}
export function FieldIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="1"/><line x1="12" y1="4" x2="12" y2="20"/><circle cx="12" cy="12" r="3"/>
</svg>
);
}
export function ChevronRightIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
);
}

6
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/global.css';
createRoot(document.getElementById('root')).render(<App />);

View File

@@ -0,0 +1,197 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { getSchedule, getScore, getAuditLog, createGameWebSocket } from '../api';
function generateUserId() {
try {
const stored = sessionStorage.getItem('scorer_id');
if (stored) return stored;
const id = 'user_' + Math.random().toString(36).slice(2, 8);
sessionStorage.setItem('scorer_id', id);
return id;
} catch { return 'user_' + Math.random().toString(36).slice(2, 8); }
}
export default function GamePage() {
const { id, gid } = useParams();
const [game, setGame] = useState(null);
const [score, setScore] = useState({ home_score: 0, away_score: 0 });
const [gameStatus, setGameStatus] = useState('scheduled');
const [wsStatus, setWsStatus] = useState('connecting');
const [auditLog, setAuditLog] = useState([]);
const [showAudit, setShowAudit] = useState(false);
const [showSetModal, setShowSetModal] = useState(null);
const [setVal, setSetVal] = useState('');
const wsRef = useRef(null);
const userId = useRef(generateUserId());
useEffect(() => {
getSchedule(id).then(sched => {
const g = sched.games?.find(x => x.id === gid);
if (g) {
setGame(g);
setScore({ home_score: g.home_score, away_score: g.away_score });
if (g.status) setGameStatus(g.status);
}
}).catch(console.error);
// Fetch persisted score for latest state (schedule may lag behind)
getScore(id, gid).then(state => {
if (state && state.status) {
setScore({ home_score: state.home_score, away_score: state.away_score });
setGameStatus(state.status);
}
}).catch(() => {});
}, [id, gid]);
useEffect(() => {
let ws;
let reconnectTimer;
function connect() {
setWsStatus('connecting');
ws = createGameWebSocket(id, gid, userId.current);
wsRef.current = ws;
ws.onopen = () => setWsStatus('connected');
ws.onclose = () => { setWsStatus('disconnected'); reconnectTimer = setTimeout(connect, 3000); };
ws.onerror = () => ws.close();
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.state) {
setScore({ home_score: msg.state.home_score, away_score: msg.state.away_score });
if (msg.state.status) setGameStatus(msg.state.status);
}
if (msg.audit) setAuditLog(prev => [msg.audit, ...prev]);
} catch {}
};
}
connect();
return () => { clearTimeout(reconnectTimer); if (ws) ws.close(); };
}, [id, gid]);
useEffect(() => {
if (showAudit) {
getAuditLog(id, gid).then(entries => { if (entries) setAuditLog(entries.reverse()); }).catch(console.error);
}
}, [showAudit, id, gid]);
const sendAction = useCallback((action, team, value) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ action, team, value: value || 0 }));
}
}, []);
const handleSet = (team) => {
const v = parseInt(setVal, 10);
if (!isNaN(v) && v >= 0) { sendAction('set', team, v); setShowSetModal(null); setSetVal(''); }
};
if (!game) return <div className="loading"><div className="spinner" /> Načítání zápasu...</div>;
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}/schedule`} className="btn btn-ghost btn-sm">&larr; Rozpis</Link>
</div>
<div style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)' }}>
{game.round} &middot; {game.field}
</div>
<div className="scoreboard">
<div className="scoreboard-header">
{gameStatus === 'final' ? 'Konečné skóre' : 'Živé skóre'}
</div>
<div className="scoreboard-teams">
<div className="scoreboard-team-name">{game.home_team}</div>
<div className="scoreboard-score">
<span>{score.home_score}</span>
<span className="divider">:</span>
<span>{score.away_score}</span>
</div>
<div className="scoreboard-team-name">{game.away_team}</div>
</div>
{gameStatus !== 'final' && (
<div className="score-controls">
{['home', 'away'].map(team => (
<div key={team} className="score-controls-row">
<span className="score-team-label" style={{ textAlign: 'right' }}>{team === 'home' ? game.home_team : game.away_team}</span>
<button className="score-btn minus" onClick={() => sendAction('decrement', team)}></button>
<button className="score-btn plus" onClick={() => sendAction('increment', team)}>+</button>
<button className="score-btn" onClick={() => { setShowSetModal(team); setSetVal(String(team === 'home' ? score.home_score : score.away_score)); }} style={{ fontSize: '0.7rem', fontFamily: 'var(--font-heading)' }}>SET</button>
</div>
))}
</div>
)}
<div style={{ marginTop: '1rem', textAlign: 'center', display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
{gameStatus === 'scheduled' && (
<button className="btn btn-sm btn-primary" onClick={() => sendAction('set_status', 'live')}>
Zahájit zápas
</button>
)}
{gameStatus === 'live' && (
<button className="btn btn-sm btn-primary" onClick={() => sendAction('set_status', 'final')}>
Ukončit zápas
</button>
)}
{gameStatus === 'final' && (
<>
<button className="btn btn-sm btn-outline" onClick={() => sendAction('set_status', 'live')}>
Znovu otevřít zápas
</button>
<button className="btn btn-sm btn-ghost" onClick={() => sendAction('set_status', 'scheduled')}>
Zpět na naplánovaný
</button>
</>
)}
</div>
<div className="ws-status">
<span className={`ws-dot ${wsStatus}`} />
{wsStatus === 'connected' ? 'Připojeno' : wsStatus === 'connecting' ? 'Připojování...' : 'Obnovování spojení...'}
</div>
</div>
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
<QRCodeSVG value={window.location.href} size={160} />
</div>
{showSetModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setShowSetModal(null)}>
<div className="card card-body" style={{ minWidth: 280 }} onClick={e => e.stopPropagation()}>
<h3 style={{ fontFamily: 'var(--font-display)', fontSize: '1.3rem', marginBottom: '1rem' }}>
Nastavit skóre {showSetModal === 'home' ? game.home_team : game.away_team}
</h3>
<input type="number" min="0" className="form-input" value={setVal} onChange={e => setSetVal(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleSet(showSetModal)} />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button className="btn btn-primary" onClick={() => handleSet(showSetModal)}>Nastavit</button>
<button className="btn btn-ghost" onClick={() => setShowSetModal(null)}>Zrušit</button>
</div>
</div>
</div>
)}
<div style={{ marginTop: '2rem' }}>
<button className="btn btn-outline btn-sm" onClick={() => setShowAudit(!showAudit)}>
{showAudit ? 'Skrýt' : 'Zobrazit'} historii změn
</button>
{showAudit && (
<div className="card" style={{ marginTop: '1rem' }}>
<div className="card-header">
<span style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase' }}>Historie skóre</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{auditLog.length} záznamů</span>
</div>
<div className="audit-log">
{auditLog.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Zatím žádné změny.</div>
) : auditLog.map((e, i) => (
<div key={i} className="audit-entry">
<span><strong>{e.action}</strong> {e.team} {e.action === 'set' ? `${e.value}` : ''} ({e.old_home}:{e.old_away} {e.new_home}:{e.new_away})</span>
<span style={{ color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{e.user_id} · {new Date(e.timestamp).toLocaleTimeString('cs-CZ')}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getTournaments } from '../api';
import { MapPinIcon, CalendarIcon, UsersIcon, ChevronRightIcon } from '../components/Icons';
function statusLabel(s) {
if (s === 'in_progress') return { text: 'Právě probíhá', cls: 'live', dot: '' };
if (s === 'upcoming') return { text: 'Nadcházející', cls: 'upcoming', dot: 'orange' };
return { text: 'Ukončený', cls: 'completed', dot: 'slate' };
}
function formatDate(d) {
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', {
day: 'numeric', month: 'short', year: 'numeric',
});
}
export default function HomePage() {
const [tournaments, setTournaments] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getTournaments()
.then(setTournaments)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="loading"><div className="spinner" /> Načítání turnajů...</div>;
// Find active or nearest upcoming
const active = tournaments.find(t => t.status === 'in_progress');
const upcoming = tournaments.filter(t => t.status === 'upcoming').sort((a, b) => a.start_date.localeCompare(b.start_date));
const featured = active || upcoming[0];
return (
<>
{featured && <FeaturedHero tournament={featured} />}
<div className="page-content">
{upcoming.length > 0 && !active && upcoming.length > 1 && (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Další nadcházející</h2>
<div className="tournament-grid">
{upcoming.slice(1).map(t => <TournamentCard key={t.id} tournament={t} />)}
</div>
</section>
)}
{active && upcoming.length > 0 && (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Nadcházející turnaje</h2>
<div className="tournament-grid">
{upcoming.map(t => <TournamentCard key={t.id} tournament={t} />)}
</div>
</section>
)}
<section>
<Link to="/past" className="btn btn-outline" style={{ marginTop: '1rem' }}>
Zobrazit minulé turnaje <ChevronRightIcon size={16} />
</Link>
</section>
</div>
</>
);
}
function FeaturedHero({ tournament: t }) {
const st = statusLabel(t.status);
return (
<div className="hero">
<div className="hero-inner">
<div className="hero-badge">
<span className={`dot ${st.dot}`} />
{st.text}
</div>
<h1>{t.name}</h1>
<p>{t.description}</p>
<div className="hero-meta">
<div className="hero-meta-item">
<CalendarIcon /> {formatDate(t.start_date)} {formatDate(t.end_date)}
</div>
<div className="hero-meta-item">
<MapPinIcon /> {t.venue}, {t.location}
</div>
<div className="hero-meta-item">
<UsersIcon /> {t.teams?.length || 0} týmů
</div>
</div>
<div style={{ marginTop: '2rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/tournament/${t.id}`} className="btn btn-primary btn-lg">
Detail turnaje <ChevronRightIcon size={16} />
</Link>
<Link to={`/tournament/${t.id}/schedule`} className="btn btn-secondary btn-lg">
Rozpis
</Link>
</div>
</div>
</div>
);
}
function TournamentCard({ tournament: t }) {
const st = statusLabel(t.status);
return (
<Link to={`/tournament/${t.id}`} className="card game-link" style={{ position: 'relative' }}>
<div className="tournament-card-status">
<span className={`status-badge ${st.cls}`}>
<span className={`dot ${st.dot}`} style={{ width: 6, height: 6, borderRadius: '50%', display: 'inline-block' }} />
{st.text}
</span>
</div>
<div className="tournament-card-header">
<h3>{t.name}</h3>
<div className="tournament-card-date">{formatDate(t.start_date)} {formatDate(t.end_date)}</div>
</div>
<div className="tournament-card-body">
<div className="tournament-card-meta">
<span><MapPinIcon size={16} /> {t.location}</span>
<span><UsersIcon size={16} /> {t.teams?.length || 0} týmů</span>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getTournaments } from '../api';
import { MapPinIcon, CalendarIcon, UsersIcon, TrophyIcon, ChevronRightIcon } from '../components/Icons';
function formatDate(d) {
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', { day: 'numeric', month: 'short', year: 'numeric' });
}
export default function PastPage() {
const [tournaments, setTournaments] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getTournaments()
.then(ts => setTournaments(ts.filter(t => t.status === 'completed')))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
return (
<div className="page-content">
<h1 className="section-title">Minulé turnaje</h1>
{tournaments.length === 0 ? (
<div className="card card-body" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-secondary)' }}>
Zatím žádné minulé turnaje.
</div>
) : (
<div className="tournament-grid">
{tournaments.map(t => (
<div key={t.id} className="card" style={{ position: 'relative' }}>
<div className="tournament-card-header" style={{ background: 'linear-gradient(135deg, var(--slate-700), var(--slate-600))' }}>
<h3>{t.name}</h3>
<div className="tournament-card-date">{formatDate(t.start_date)} {formatDate(t.end_date)}</div>
</div>
<div className="tournament-card-body">
<div className="tournament-card-meta">
<span><MapPinIcon size={16} /> {t.location}</span>
<span><UsersIcon size={16} /> {t.teams?.length || 0} týmů</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<Link to={"/tournament/" + t.id} className="btn btn-outline btn-sm">
Detail <ChevronRightIcon size={14} />
</Link>
<Link to={"/tournament/" + t.id + "/results"} className="btn btn-secondary btn-sm">
<TrophyIcon size={14} /> Výsledky
</Link>
</div>
</div>
</div>
))}
</div>
)}
<div style={{ marginTop: '2rem' }}>
<Link to="/" className="btn btn-ghost">&larr; Zpět na hlavní stránku</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getQuestionnaire, submitQuestionnaire } from '../api';
export default function QuestionnairePage() {
const { id } = useParams();
const [config, setConfig] = useState(null);
const [teams, setTeams] = useState([]);
const [loading, setLoading] = useState(true);
const [submitted, setSubmitted] = useState(false);
const [myTeam, setMyTeam] = useState('');
const [spiritWinner, setSpiritWinner] = useState('');
const [attendNext, setAttendNext] = useState(false);
const [customAnswers, setCustomAnswers] = useState({});
useEffect(() => {
getQuestionnaire(id).then(data => { setConfig(data.config); setTeams(data.teams || []); }).catch(console.error).finally(() => setLoading(false));
}, [id]);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await submitQuestionnaire(id, { my_team: myTeam, spirit_winner: spiritWinner, attend_next: attendNext, custom_answers: customAnswers });
setSubmitted(true);
} catch (err) { alert('Chyba: ' + err.message); }
};
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
if (submitted) return (
<div className="page-content" style={{ textAlign: 'center', paddingTop: '4rem' }}>
<div className="success-msg" style={{ maxWidth: 500, margin: '0 auto' }}>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>🥏</div>
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>Díky!</h2>
<p style={{ color: 'var(--text-secondary)' }}>Tvoje odpověď byla zaznamenána.</p>
</div>
<Link to={`/tournament/${id}`} className="btn btn-primary" style={{ marginTop: '2rem' }}>Zpět na turnaj</Link>
</div>
);
const customQs = config?.custom_questions || [];
return (
<div className="page-content" style={{ maxWidth: 640, margin: '0 auto' }}>
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">&larr; Zpět</Link>
</div>
<h1 className="section-title">Dotazník po turnaji</h1>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>Pomoz nám se zlepšit! Odpovědi jsou anonymní.</p>
<form onSubmit={handleSubmit}>
<div className="card card-body" style={{ marginBottom: '1.5rem' }}>
<div className="form-group">
<label className="form-label">Můj tým *</label>
<select className="form-select" value={myTeam} onChange={e => setMyTeam(e.target.value)} required>
<option value="">Vyber svůj tým...</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Spirit winner *</label>
<select className="form-select" value={spiritWinner} onChange={e => setSpiritWinner(e.target.value)} required>
<option value="">Vyber tým se spirit...</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>Který tým měl nejlepší spirit?</div>
</div>
<div className="form-group">
<label className="form-check">
<input type="checkbox" checked={attendNext} onChange={e => setAttendNext(e.target.checked)} />
<span>Chci se zúčastnit příštího turnaje</span>
</label>
</div>
</div>
{customQs.length > 0 && (
<div className="card card-body" style={{ marginBottom: '1.5rem' }}>
{customQs.map(q => (
<div key={q.id} className="form-group">
<label className="form-label">{q.text} {q.required && '*'}</label>
{q.type === 'select' || q.type === 'radio' ? (
<select className="form-select" value={customAnswers[q.id] || ''} onChange={e => setCustomAnswers(p => ({ ...p, [q.id]: e.target.value }))} required={q.required}>
<option value="">Vyber...</option>
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<textarea className="form-textarea" value={customAnswers[q.id] || ''} onChange={e => setCustomAnswers(p => ({ ...p, [q.id]: e.target.value }))} required={q.required} placeholder="Tvoje odpověď..." />
)}
</div>
))}
</div>
)}
<button type="submit" className="btn btn-primary btn-lg" style={{ width: '100%' }}>Odeslat odpověď</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getResults } from '../api';
import { TrophyIcon } from '../components/Icons';
function medal(pos) {
if (pos === 1) return <span className="medal-1">🥇</span>;
if (pos === 2) return <span className="medal-2">🥈</span>;
if (pos === 3) return <span className="medal-3">🥉</span>;
return <span>{pos}</span>;
}
export default function ResultsPage() {
const { id } = useParams();
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getResults(id).then(setResults).catch(console.error).finally(() => setLoading(false));
}, [id]);
if (loading) return <div className="loading"><div className="spinner" /> Načítání výsledků...</div>;
const standings = results?.standings || [];
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">&larr; Zpět na turnaj</Link>
</div>
<h1 className="section-title"><TrophyIcon size={28} /> Konečné výsledky</h1>
{standings.length === 0 ? (
<div className="card card-body" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-secondary)' }}>Výsledky zatím nejsou k dispozici.</div>
) : (
<div className="card" style={{ overflow: 'auto' }}>
<table className="results-table">
<thead><tr><th>#</th><th>Tým</th><th>V</th><th>P</th><th>R</th><th>Body+</th><th>Body-</th><th>+/-</th><th>Spirit</th></tr></thead>
<tbody>
{standings.map(s => (
<tr key={s.team_id}>
<td className="position">{medal(s.position)}</td>
<td className="team-name">{s.team_name}</td>
<td>{s.wins}</td><td>{s.losses}</td><td>{s.draws}</td>
<td>{s.points_for}</td><td>{s.points_against}</td>
<td style={{ color: s.points_for - s.points_against > 0 ? 'var(--green-600)' : 'var(--red-500)', fontWeight: 600 }}>
{s.points_for - s.points_against > 0 ? '+' : ''}{s.points_for - s.points_against}
</td>
<td>{s.spirit_score?.toFixed(1) || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{standings.length > 0 && (() => {
const sw = [...standings].sort((a, b) => (b.spirit_score || 0) - (a.spirit_score || 0))[0];
return sw?.spirit_score ? (
<div className="card card-body" style={{ marginTop: '1.5rem', textAlign: 'center', background: 'var(--green-50)', borderColor: 'var(--green-400)' }}>
<div style={{ fontSize: '2rem', marginBottom: '0.25rem' }}>🕊</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, textTransform: 'uppercase', fontSize: '0.8rem', letterSpacing: '0.06em', color: 'var(--green-700)' }}>Spirit Award</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.8rem' }}>{sw.team_name}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Spirit skóre: {sw.spirit_score.toFixed(1)}</div>
</div>
) : null;
})()}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getSchedule, createTournamentWebSocket } from '../api';
import { ClockIcon } from '../components/Icons';
function formatTime(dt) {
return new Date(dt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' });
}
function groupBySection(games) {
const poolA = [];
const poolB = [];
const placement = [];
for (const g of games) {
if (g.round.startsWith('Pool A')) poolA.push(g);
else if (g.round.startsWith('Pool B')) poolB.push(g);
else placement.push(g);
}
const sections = [];
if (poolA.length) sections.push(['Skupina A', poolA]);
if (poolB.length) sections.push(['Skupina B', poolB]);
if (placement.length) sections.push(['Pavouk', placement]);
return sections;
}
function hasScore(g) {
return g.home_score > 0 || g.away_score > 0;
}
export default function SchedulePage() {
const { id } = useParams();
const [schedule, setSchedule] = useState(null);
const [loading, setLoading] = useState(true);
const fetchSchedule = useCallback(() => {
getSchedule(id).then(setSchedule).catch(console.error).finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchSchedule();
const onVisibility = () => { if (document.visibilityState === 'visible') fetchSchedule(); };
document.addEventListener('visibilitychange', onVisibility);
// Live updates via tournament WebSocket
let ws = null;
let reconnectTimer = null;
function connect() {
ws = createTournamentWebSocket(id);
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === 'score_update' && msg.state) {
setSchedule(prev => {
if (!prev) return prev;
return {
...prev,
games: prev.games.map(g =>
g.id === msg.state.game_id
? { ...g, home_score: msg.state.home_score, away_score: msg.state.away_score, status: msg.state.status }
: g
),
};
});
}
};
ws.onclose = () => {
reconnectTimer = setTimeout(connect, 3000);
};
}
connect();
return () => {
document.removeEventListener('visibilitychange', onVisibility);
clearTimeout(reconnectTimer);
if (ws) { ws.onclose = null; ws.close(); }
};
}, [fetchSchedule, id]);
if (loading) return <div className="loading"><div className="spinner" /> Načítání rozpisu...</div>;
const games = schedule?.games || [];
const sections = groupBySection(games);
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">&larr; Zpět na turnaj</Link>
</div>
<h1 className="section-title">Rozpis</h1>
{sections.length === 0 && <p style={{ color: 'var(--text-secondary)' }}>Zatím žádné naplánované zápasy.</p>}
<div className="schedule-grid">
{sections.map(([section, sectionGames]) => (
<div key={section} className="schedule-round">
<div className="schedule-round-title">{section}</div>
{sectionGames.map(g => (
<Link to={`/tournament/${id}/game/${g.id}`} key={g.id} className="game-row game-link" style={g.status === 'live' ? { borderColor: 'orange', borderWidth: '2px' } : undefined}>
<div className="game-time" style={{ fontWeight: 700, fontSize: '1.05rem', minWidth: '3.2rem' }}>
{formatTime(g.start_time)}
</div>
<div className="game-team home">{g.home_team}</div>
<div className="game-score" style={{
color: g.status === 'final' ? 'var(--text-primary)' : g.status === 'live' ? 'var(--green-700)' : undefined
}}>
{g.status === 'scheduled' && !hasScore(g) ? ' : ' : `${g.home_score} : ${g.away_score}`}
</div>
<div className="game-team away">{g.away_team}</div>
<span className={`status-badge ${g.status === 'live' ? 'live' : g.status === 'final' ? 'completed' : 'scheduled'}`}>
{g.status}
</span>
</Link>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { getTournament } from '../api';
import { MapPinIcon, CalendarIcon, UsersIcon, DiscIcon, ChevronRightIcon } from '../components/Icons';
function formatDate(d) {
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', {
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
});
}
export default function TournamentPage() {
const { id } = useParams();
const [t, setT] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getTournament(id).then(setT).catch(console.error).finally(() => setLoading(false));
}, [id]);
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
if (!t) return <div className="page-content"><p>Turnaj nenalezen.</p></div>;
const questionnaireUrl = `${window.location.origin}/tournament/${id}/questionnaire`;
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to="/" className="btn btn-ghost btn-sm">&larr; Zpět</Link>
</div>
<h1 className="section-title" style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>{t.name}</h1>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem', maxWidth: 700 }}>{t.description}</p>
{/* Info Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem', marginBottom: '2.5rem' }}>
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<CalendarIcon size={24} />
<div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Datum</div>
<div style={{ fontWeight: 600 }}>{formatDate(t.start_date)} {formatDate(t.end_date)}</div>
</div>
</div>
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<MapPinIcon size={24} />
<div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Místo</div>
<div style={{ fontWeight: 600 }}>{t.venue}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{t.location}</div>
</div>
</div>
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<UsersIcon size={24} />
<div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Týmy</div>
<div style={{ fontWeight: 600 }}>{t.teams?.length || 0} registrovaných</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '2.5rem' }}>
<Link to={`/tournament/${id}/schedule`} className="btn btn-primary btn-lg">
Rozpis <ChevronRightIcon size={16} />
</Link>
<Link to={`/tournament/${id}/results`} className="btn btn-secondary btn-lg">
Výsledky
</Link>
<Link to={`/tournament/${id}/questionnaire`} className="btn btn-outline btn-lg">
Dotazník
</Link>
</div>
{/* Teams */}
{t.teams && t.teams.length > 0 && (() => {
const poolAIds = ['fuj-1', 'kocicaci', 'spitalska', 'sunset', 'hoko-coko-diskyto'];
const poolA = t.teams.filter(team => poolAIds.includes(team.id));
const poolB = t.teams.filter(team => !poolAIds.includes(team.id));
return (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Zúčastněné týmy</h2>
{poolA.length > 0 && (
<>
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.5rem' }}>Skupina A</h3>
<div className="teams-grid" style={{ marginBottom: '1.25rem' }}>
{poolA.map(team => (
<div key={team.id} className="team-chip">
<DiscIcon size={20} />
{team.name}
</div>
))}
</div>
</>
)}
{poolB.length > 0 && (
<>
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.5rem' }}>Skupina B</h3>
<div className="teams-grid">
{poolB.map(team => (
<div key={team.id} className="team-chip">
<DiscIcon size={20} />
{team.name}
</div>
))}
</div>
</>
)}
</section>
);
})()}
{/* Rules */}
{t.rules && (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Pravidla</h2>
<div className="card card-body">
<p>{t.rules}</p>
</div>
</section>
)}
{/* QR Code for questionnaire */}
<section>
<h2 className="section-title">Dotazník</h2>
<div className="qr-section">
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
Naskenuj QR kód pro otevření dotazníku na telefonu:
</p>
<QRCodeSVG value={questionnaireUrl} size={180} level="M" bgColor="transparent" fgColor="#14532d" />
<p style={{ marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>
{questionnaireUrl}
</p>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,944 @@
:root {
/* Core palette — grass field greens + energetic accents */
--green-900: #0d2818;
--green-800: #14532d;
--green-700: #166534;
--green-600: #16a34a;
--green-500: #22c55e;
--green-400: #4ade80;
--green-100: #dcfce7;
--green-50: #f0fdf4;
--sky-500: #0ea5e9;
--sky-400: #38bdf8;
--sky-300: #7dd3fc;
--sky-100: #e0f2fe;
--orange-500: #f97316;
--orange-400: #fb923c;
--orange-300: #fdba74;
--slate-900: #0f172a;
--slate-800: #1e293b;
--slate-700: #334155;
--slate-600: #475569;
--slate-400: #94a3b8;
--slate-300: #cbd5e1;
--slate-200: #e2e8f0;
--slate-100: #f1f5f9;
--slate-50: #f8fafc;
--white: #ffffff;
--red-500: #ef4444;
--red-600: #dc2626;
/* Semantic */
--bg-primary: var(--green-900);
--bg-card: var(--white);
--bg-surface: var(--slate-50);
--text-primary: var(--slate-900);
--text-secondary: var(--slate-600);
--text-on-dark: var(--green-50);
--accent: var(--orange-500);
--accent-hover: var(--orange-400);
--border: var(--slate-200);
/* Typography */
--font-display: 'Bebas Neue', 'Impact', sans-serif;
--font-heading: 'Barlow Condensed', 'Arial Narrow', sans-serif;
--font-body: 'Barlow', 'Helvetica Neue', sans-serif;
/* Spacing */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-xl: 24px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
--shadow-xl: 0 20px 60px rgba(0,0,0,0.15);
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
color: var(--text-primary);
background: var(--bg-surface);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ---- LAYOUT ---- */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.page-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
width: 100%;
}
/* ---- HEADER / NAV ---- */
.site-header {
background: var(--green-900);
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 20px rgba(0,0,0,0.3);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
}
.header-logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-on-dark);
}
.header-logo svg {
width: 36px;
height: 36px;
}
.header-logo-text {
font-family: var(--font-display);
font-size: 1.75rem;
letter-spacing: 0.05em;
line-height: 1;
}
.header-nav {
display: flex;
gap: 0.25rem;
}
.header-nav a {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--green-400);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.header-nav a:hover,
.header-nav a.active {
background: rgba(255,255,255,0.1);
color: var(--white);
}
/* ---- HERO ---- */
.hero {
background: linear-gradient(135deg, var(--green-900) 0%, var(--green-800) 50%, var(--green-700) 100%);
color: var(--text-on-dark);
padding: 4rem 1.5rem;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 50% at 80% 20%, rgba(34, 197, 94, 0.15), transparent),
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(14, 165, 233, 0.1), transparent);
pointer-events: none;
}
.hero-inner {
max-width: 1200px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(255,255,255,0.1);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.15);
padding: 0.35rem 1rem;
border-radius: 100px;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 1.25rem;
}
.hero-badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green-400);
animation: pulse 2s infinite;
}
.hero-badge .dot.orange { background: var(--orange-400); animation: none; }
.hero-badge .dot.slate { background: var(--slate-400); animation: none; }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.3); }
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2.5rem, 6vw, 4.5rem);
letter-spacing: 0.03em;
line-height: 1;
margin-bottom: 1rem;
}
.hero p {
font-size: 1.15rem;
color: rgba(255,255,255,0.75);
max-width: 600px;
line-height: 1.6;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin-top: 2rem;
}
.hero-meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.95rem;
}
.hero-meta-item svg {
width: 18px;
height: 18px;
opacity: 0.7;
}
/* ---- CARDS ---- */
.card {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border);
overflow: hidden;
transition: box-shadow 0.25s, transform 0.25s;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-body {
padding: 1.5rem;
}
.card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
/* ---- SECTION ---- */
.section-title {
font-family: var(--font-display);
font-size: 2rem;
letter-spacing: 0.04em;
margin-bottom: 1.5rem;
color: var(--green-800);
}
/* ---- BUTTONS ---- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.65rem 1.5rem;
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.06em;
text-decoration: none;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: var(--white);
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--green-700);
color: var(--white);
}
.btn-secondary:hover {
background: var(--green-600);
}
.btn-outline {
background: transparent;
border: 2px solid var(--green-600);
color: var(--green-700);
}
.btn-outline:hover {
background: var(--green-600);
color: var(--white);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--slate-100);
}
.btn-lg {
padding: 0.85rem 2rem;
font-size: 1rem;
}
.btn-sm {
padding: 0.4rem 0.85rem;
font-size: 0.8rem;
}
.btn-icon {
width: 44px;
height: 44px;
padding: 0;
border-radius: 50%;
}
/* ---- STATUS BADGES ---- */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.75rem;
border-radius: 100px;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-badge.live {
background: rgba(34, 197, 94, 0.12);
color: var(--green-700);
}
.status-badge.upcoming {
background: rgba(14, 165, 233, 0.12);
color: var(--sky-500);
}
.status-badge.completed {
background: var(--slate-100);
color: var(--slate-600);
}
.status-badge.scheduled {
background: var(--slate-100);
color: var(--slate-600);
}
/* ---- SCHEDULE TABLE ---- */
.schedule-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.schedule-round {
margin-bottom: 1rem;
}
.schedule-round-title {
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--green-700);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--green-100);
}
.game-row {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto;
align-items: center;
gap: 1rem;
padding: 0.85rem 1.25rem;
background: var(--white);
border-radius: var(--radius-md);
border: 1px solid var(--border);
transition: all 0.15s;
}
.game-row:hover {
border-color: var(--green-400);
box-shadow: var(--shadow-sm);
}
.game-team {
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.05rem;
}
.game-team.home { text-align: right; }
.game-team.away { text-align: left; }
.game-score {
font-family: var(--font-display);
font-size: 1.5rem;
min-width: 80px;
text-align: center;
letter-spacing: 0.05em;
}
.game-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 0.8rem;
color: var(--text-secondary);
gap: 0.15rem;
}
.game-link {
text-decoration: none;
color: inherit;
}
/* ---- SCOREBOARD ---- */
.scoreboard {
background: var(--green-900);
border-radius: var(--radius-xl);
padding: 2.5rem;
color: var(--text-on-dark);
text-align: center;
box-shadow: var(--shadow-xl);
position: relative;
overflow: hidden;
}
.scoreboard::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 0%, rgba(34, 197, 94, 0.15), transparent 70%);
pointer-events: none;
}
.scoreboard-header {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.6;
margin-bottom: 0.5rem;
}
.scoreboard-teams {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 2rem;
position: relative;
z-index: 1;
}
.scoreboard-team-name {
font-family: var(--font-display);
font-size: clamp(1.2rem, 3vw, 2rem);
letter-spacing: 0.04em;
}
.scoreboard-score {
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 6rem);
letter-spacing: 0.05em;
line-height: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.scoreboard-score .divider {
opacity: 0.3;
font-size: 0.6em;
}
.score-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
margin-top: 1.5rem;
position: relative;
z-index: 1;
}
.score-controls-row {
display: flex;
gap: 0.75rem;
align-items: center;
}
.score-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.08);
color: var(--white);
font-family: var(--font-display);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.score-btn:hover {
background: rgba(255,255,255,0.2);
border-color: rgba(255,255,255,0.4);
transform: scale(1.08);
}
.score-btn:active {
transform: scale(0.95);
}
.score-btn.plus { border-color: var(--green-500); color: var(--green-400); }
.score-btn.plus:hover { background: rgba(34, 197, 94, 0.25); }
.score-btn.minus { border-color: var(--red-500); color: var(--red-500); }
.score-btn.minus:hover { background: rgba(239, 68, 68, 0.2); }
.score-team-label {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.5;
min-width: 120px;
}
.ws-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-family: var(--font-heading);
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
margin-top: 1.5rem;
}
.ws-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.ws-dot.connected { background: var(--green-400); }
.ws-dot.disconnected { background: var(--red-500); }
/* ---- QUESTIONNAIRE ---- */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.form-select,
.form-input,
.form-textarea {
width: 100%;
padding: 0.7rem 1rem;
font-family: var(--font-body);
font-size: 0.95rem;
border: 2px solid var(--border);
border-radius: var(--radius-md);
background: var(--white);
color: var(--text-primary);
transition: border-color 0.2s;
}
.form-select:focus,
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: var(--green-500);
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
.form-check {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.form-check input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--green-600);
}
/* ---- RESULTS TABLE ---- */
.results-table {
width: 100%;
border-collapse: collapse;
}
.results-table th {
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 2px solid var(--green-100);
}
.results-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.95rem;
}
.results-table tr:hover td {
background: var(--green-50);
}
.results-table .position {
font-family: var(--font-display);
font-size: 1.3rem;
color: var(--green-700);
width: 50px;
}
.results-table .team-name {
font-family: var(--font-heading);
font-weight: 600;
font-size: 1.05rem;
}
.medal-1 { color: #d4a017; }
.medal-2 { color: #9ca3af; }
.medal-3 { color: #b87333; }
/* ---- TOURNAMENT CARDS ---- */
.tournament-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.5rem;
}
.tournament-card-status {
position: absolute;
top: 1rem;
right: 1rem;
}
.tournament-card-header {
padding: 2rem 1.5rem 1.5rem;
background: linear-gradient(135deg, var(--green-800), var(--green-700));
color: var(--white);
position: relative;
}
.tournament-card-header h3 {
font-family: var(--font-display);
font-size: 1.6rem;
letter-spacing: 0.03em;
margin-bottom: 0.25rem;
}
.tournament-card-date {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.85rem;
opacity: 0.75;
}
.tournament-card-body {
padding: 1.25rem 1.5rem;
}
.tournament-card-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.tournament-card-meta span {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ---- TEAMS GRID ---- */
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.team-chip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--green-50);
border: 1px solid var(--green-100);
border-radius: var(--radius-md);
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.9rem;
color: var(--green-800);
}
.team-chip .disc-icon {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--green-600);
flex-shrink: 0;
}
/* ---- QR CODE ---- */
.qr-section {
text-align: center;
padding: 2rem;
background: var(--white);
border-radius: var(--radius-lg);
border: 2px dashed var(--border);
}
/* ---- FOOTER ---- */
.site-footer {
background: var(--green-900);
color: var(--green-400);
padding: 2rem 1.5rem;
text-align: center;
font-family: var(--font-heading);
font-size: 0.85rem;
margin-top: auto;
}
/* ---- AUDIT LOG ---- */
.audit-log {
max-height: 300px;
overflow-y: auto;
font-size: 0.8rem;
font-family: monospace;
}
.audit-entry {
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 1rem;
}
.audit-entry:nth-child(even) {
background: var(--slate-50);
}
/* ---- TABS ---- */
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.25rem;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
background: none;
border-top: none;
border-left: none;
border-right: none;
}
.tab:hover {
color: var(--green-700);
}
.tab.active {
color: var(--green-700);
border-bottom-color: var(--green-600);
}
/* ---- LOADING ---- */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
font-family: var(--font-heading);
font-size: 1rem;
gap: 0.75rem;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid var(--border);
border-top-color: var(--green-600);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- SUCCESS MESSAGE ---- */
.success-msg {
background: var(--green-50);
border: 1px solid var(--green-400);
color: var(--green-800);
padding: 1.25rem;
border-radius: var(--radius-md);
text-align: center;
font-family: var(--font-heading);
font-weight: 600;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.header-inner { height: 56px; }
.header-nav a { padding: 0.4rem 0.6rem; font-size: 0.8rem; }
.hero { padding: 2.5rem 1rem; }
.page-content { padding: 1.25rem 1rem; }
.game-row {
grid-template-columns: 1fr auto 1fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.game-meta { display: none; }
.game-row .btn { display: none; }
.scoreboard { padding: 1.5rem 1rem; }
.scoreboard-teams { gap: 1rem; }
.score-btn { width: 48px; height: 48px; font-size: 1.25rem; }
.tournament-grid { grid-template-columns: 1fr; }
.teams-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}
@media (max-width: 480px) {
.header-logo-text { font-size: 1.3rem; }
.header-nav a { padding: 0.35rem 0.45rem; font-size: 0.7rem; }
.score-btn { width: 44px; height: 44px; }
}

19
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:8080',
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
build: {
outDir: 'dist',
},
})