Frontend v.0.1

This commit is contained in:
2026-02-12 17:29:24 +01:00 Verified
parent f8fe3eb991
commit c12d962da0
20 changed files with 2087 additions and 181 deletions
+143
View File
@@ -0,0 +1,143 @@
// frontend/src/pages/Dashboard.jsx
import { Calendar, ChevronDown, ChevronUp, History, Loader2, Plus, SlidersHorizontal, Users } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import TournamentForm from '../components/Forms/TournamentForm';
import Modal from '../components/UI/Modal';
import api from '../services/api';
// --- EXACT COPY OF YOUR DASHCARD ---
const DashCard = ({ t, isAdmin, onClick, onEdit }) => (
<div onClick={onClick} className="bg-white dark:bg-zinc-900 rounded-3xl p-5 shadow-sm border border-zinc-200 dark:border-zinc-800 cursor-pointer hover:shadow-2xl hover:-translate-y-1.5 transition-all relative overflow-hidden group">
<div className="absolute top-0 left-0 w-2 h-full bg-orange-600 group-hover:w-3 transition-all"></div>
<div className="flex justify-between items-start mb-4">
<h3 className="font-black text-xl text-zinc-900 dark:text-white truncate pr-4 leading-tight">{t.name}</h3>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); onEdit(t); }}
className="text-zinc-300 hover:text-orange-500 transition p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-2xl shrink-0"
>
<SlidersHorizontal size={18} strokeWidth={2.5} />
</button>
)}
</div>
<div className="space-y-2.5">
<div className="flex items-center gap-2.5 text-zinc-700 dark:text-zinc-300 font-bold text-sm tracking-tight">
<Calendar size={16} className="text-orange-600 shrink-0" />
{/* Use safe date parsing */}
<span>{t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'} <span className="text-zinc-300 dark:text-zinc-700 mx-1">/</span> {t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5 text-zinc-700 dark:text-zinc-300 font-bold text-sm tracking-tight">
<Users size={16} className="text-orange-600 shrink-0" />
<span>{t.team_count} Teams</span>
</div>
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-widest border border-zinc-200 dark:border-zinc-700 text-zinc-500">
{t.type}
</span>
</div>
</div>
</div>
);
export default function Dashboard() {
const { setNavTitle, isAdmin } = useOutletContext();
const [tournaments, setTournaments] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [showPast, setShowPast] = useState(false);
const navigate = useNavigate();
useEffect(() => {
setNavTitle('Dashboard');
loadTournaments();
}, []);
const loadTournaments = async () => {
try {
const data = await api.get('/tournaments');
setTournaments(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
// --- REPLICATED GROUPING LOGIC ---
const now = new Date();
const groups = { live: [], future: [], past: [] };
tournaments.forEach(t => {
const tDate = new Date(t.timestamp);
const isToday = tDate.toDateString() === now.toDateString();
if (tDate > now && !isToday) groups.future.push(t);
else if (isToday) groups.live.push(t);
else groups.past.push(t);
});
// Sort
groups.future.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
groups.live.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
groups.past.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
return (
<div className="container mx-auto max-w-5xl p-6 pb-24 space-y-16 animate-in slide-in-from-bottom-4 duration-500">
{/* Create Button only if Admin */}
<div className="flex justify-end">
{isAdmin && (
<button onClick={() => { setEditTarget(null); setShowCreate(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition">
<Plus size={16} strokeWidth={4} /> Create
</button>
)}
</div>
{groups.live.length > 0 && (
<section>
<h2 className="text-[10px] font-black text-green-500 uppercase tracking-[0.4em] mb-6 flex items-center gap-3">
<div className="w-2.5 h-2.5 bg-green-500 rounded-full animate-ping shadow-lg shadow-green-500/50" /> Live Events
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)}
</div>
</section>
)}
<section>
<h2 className="text-[10px] font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.4em] mb-6 flex items-center gap-3">
<Calendar size={18} /> Upcoming
</h2>
{groups.future.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{groups.future.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)}
</div>
) : (
<div className="p-16 text-center rounded-3xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 text-zinc-400 text-xs font-black uppercase tracking-[0.3em]">No Upcoming Events</div>
)}
</section>
{groups.past.length > 0 && (
<section>
<button onClick={() => setShowPast(!showPast)} className="w-full flex items-center justify-between group py-6 border-t border-zinc-300 dark:border-zinc-800 transition-colors hover:border-zinc-400">
<h2 className="text-[10px] font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.4em] mb-6 flex items-center gap-3"><History size={18} />Archive</h2>
{showPast ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
{showPast && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 opacity-75 hover:opacity-100 transition-opacity">
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)}
</div>
)}
</section>
)}
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title={editTarget ? "Modify Event" : "New Tournament"}>
<TournamentForm initialData={editTarget} isEdit={!!editTarget} onSuccess={(newT) => { setShowCreate(false); loadTournaments(); }} />
</Modal>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
// frontend/src/pages/Login.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Volleyball, ArrowRight, Loader2 } from 'lucide-react';
import api from '../services/api';
export default function Login() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.target);
try {
// The backend expects x-www-form-urlencoded for OAuth2
const res = await api.postForm('/auth/token', formData);
localStorage.setItem('volleyToken', res.access_token);
navigate('/');
} catch (err) {
setError('Invalid credentials. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
<div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-3xl shadow-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-8">
<div className="flex justify-center mb-8">
<div className="p-4 bg-orange-600 rounded-2xl shadow-lg shadow-orange-600/30 rotate-12">
<Volleyball className="text-white" size={40} />
</div>
</div>
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">Admin Access</h1>
<p className="text-center text-zinc-500 text-sm font-medium mb-8">Enter your credentials to manage events</p>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-bold uppercase tracking-wide rounded-xl text-center border border-red-100 dark:border-red-900/50">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Username</label>
<input
name="username"
placeholder="admin"
required
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-4 rounded-xl font-bold dark:text-white outline-none focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Password</label>
<input
name="password"
type="password"
placeholder="••••••••"
required
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-4 rounded-xl font-bold dark:text-white outline-none focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-orange-600 hover:bg-orange-500 text-white p-4 rounded-xl font-black uppercase tracking-widest text-xs shadow-xl shadow-orange-600/20 transition active:scale-95 flex items-center justify-center gap-2 mt-4"
>
{loading ? <Loader2 className="animate-spin" size={18} /> : <>Sign In <ArrowRight size={18} /></>}
</button>
</form>
</div>
<div className="bg-zinc-50 dark:bg-zinc-950/50 p-4 text-center border-t border-zinc-200 dark:border-zinc-800">
<a href="/" className="text-[10px] font-black uppercase tracking-widest text-zinc-400 hover:text-orange-600 transition">Back to Dashboard</a>
</div>
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
// frontend/src/pages/TournamentPage.jsx
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useOutletContext, useParams } from 'react-router-dom';
import BracketView from '../components/Bracket/BracketView';
import TournamentForm from '../components/Forms/TournamentForm';
import ScheduleView from '../components/Schedule/ScheduleView';
import ScoreModal from '../components/Tournament/ScoreModal';
import Modal from '../components/UI/Modal';
import api, { WS_URL } from '../services/api';
export default function TournamentPage() {
const { id } = useParams();
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
const [details, setDetails] = useState(null);
const [nodes, setNodes] = useState([]);
const [view, setView] = useState('bracket'); // 'bracket' | 'schedule'
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [scoreMatchNode, setScoreMatchNode] = useState(null);
const fetchData = async () => {
try {
// 1. Fetch Metadata
const meta = await api.get(`/tournaments/${id}`);
setDetails(meta);
setNavTitle(meta.name);
setNavSubtitle(new Date(meta.timestamp).toLocaleDateString());
// 2. Fetch Structure (Nodes)
const bracketData = await api.get(`/tournaments/${id}/bracket`);
setNodes(bracketData);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// WebSocket for Live Updates
const ws = new WebSocket(WS_URL);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'tournament_update' && msg.id === id) {
fetchData();
}
};
return () => ws.close();
}, [id]);
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
return (
<div className="h-full flex flex-col">
{/* Toolbar */}
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur px-6 py-3 flex justify-between items-center shrink-0">
<div className="flex gap-2">
<button
onClick={() => setView('bracket')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'bracket' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}
>
<Network size={16} /> Bracket
</button>
<button
onClick={() => setView('schedule')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}
>
<CalendarDays size={16} /> Schedule
</button>
</div>
{isAdmin && (
<button onClick={() => setShowSettings(true)} className="p-2 text-zinc-400 hover:text-orange-600 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800">
<Settings size={20} />
</button>
)}
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-hidden relative">
{view === 'bracket'
? <BracketView nodes={nodes} onMatchClick={setScoreMatchNode} />
: <ScheduleView nodes={nodes} onMatchClick={setScoreMatchNode} />
}
</div>
{scoreMatchNode && (
<ScoreModal
isOpen={!!scoreMatchNode}
onClose={() => setScoreMatchNode(null)}
node={scoreMatchNode}
tournamentId={id}
isAdmin={isAdmin}
/>
)}
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
<TournamentForm initialData={details} onSuccess={() => { setShowSettings(false); fetchData(); }} isEdit />
</Modal>
</div>
);
}