Initial release: Disc Agenda frisbee tournament platform
Some checks failed
Build and Push / build (push) Failing after 8s
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:
31
frontend/src/App.jsx
Normal file
31
frontend/src/App.jsx
Normal 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
38
frontend/src/api.js
Normal 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}`);
|
||||
}
|
||||
9
frontend/src/components/Footer.jsx
Normal file
9
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<p>Disc Agenda — Turnajová platforma pro ultimate frisbee © {new Date().getFullYear()}</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/Header.jsx
Normal file
33
frontend/src/components/Header.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/Icons.jsx
Normal file
87
frontend/src/components/Icons.jsx
Normal 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
6
frontend/src/main.jsx
Normal 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 />);
|
||||
197
frontend/src/pages/GamePage.jsx
Normal file
197
frontend/src/pages/GamePage.jsx
Normal 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">← 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} · {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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/pages/HomePage.jsx
Normal file
123
frontend/src/pages/HomePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
frontend/src/pages/PastPage.jsx
Normal file
61
frontend/src/pages/PastPage.jsx
Normal 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">← Zpět na hlavní stránku</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/pages/QuestionnairePage.jsx
Normal file
95
frontend/src/pages/QuestionnairePage.jsx
Normal 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">← 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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/pages/ResultsPage.jsx
Normal file
68
frontend/src/pages/ResultsPage.jsx
Normal 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">← 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>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/SchedulePage.jsx
Normal file
119
frontend/src/pages/SchedulePage.jsx
Normal 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">← 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>
|
||||
);
|
||||
}
|
||||
138
frontend/src/pages/TournamentPage.jsx
Normal file
138
frontend/src/pages/TournamentPage.jsx
Normal 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">← 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>
|
||||
);
|
||||
}
|
||||
944
frontend/src/styles/global.css
Normal file
944
frontend/src/styles/global.css
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user