General clean-up
This commit is contained in:
@@ -168,6 +168,8 @@ class BracketGenerator:
|
||||
self.match_counter += 1
|
||||
gf.next_loss = reset
|
||||
gf.next_loss_slot = 0
|
||||
gf.next_win = reset
|
||||
gf.next_win_slot = 1
|
||||
|
||||
def _resolve_byes(self, num_players: int) -> None:
|
||||
"""
|
||||
|
||||
+24
-8
@@ -6,7 +6,7 @@ from uuid import uuid4
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .constants import MatchStatus, TournamentTypes
|
||||
from .constants import BracketTypes, MatchStatus, TournamentTypes
|
||||
from .core.brackets import BracketGenerator
|
||||
|
||||
|
||||
@@ -181,6 +181,25 @@ def advance_winner(db: Session, match: models.Match, winner_id: int):
|
||||
|
||||
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
||||
|
||||
if (
|
||||
match.bracket_type == BracketTypes.FINALS
|
||||
and match.winner_next_match
|
||||
and match.winner_next_match.bracket_type == BracketTypes.FINALS
|
||||
):
|
||||
if winner_id == match.p1_team_id:
|
||||
reset_match = match.winner_next_match
|
||||
if reset_match.winner_team_id:
|
||||
undo_advancement(db, reset_match)
|
||||
reset_match.winner_team_id = None
|
||||
reset_match.sets = []
|
||||
reset_match.p1_team_id = None
|
||||
reset_match.p2_team_id = None
|
||||
reset_match.status = MatchStatus.SCHEDULED
|
||||
|
||||
db.add(reset_match)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
def update_next_match(
|
||||
next_match: models.Match, team_id: int, target_slot: int | None
|
||||
):
|
||||
@@ -214,9 +233,7 @@ def undo_advancement(db: Session, match: models.Match):
|
||||
winner_id = match.winner_team_id
|
||||
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
||||
|
||||
def clear_from_next(
|
||||
next_match: models.Match, team_id: int, target_slot: int | None
|
||||
):
|
||||
def clear_from_next(next_match: models.Match, target_slot: int | None):
|
||||
if not next_match or target_slot is None:
|
||||
return
|
||||
|
||||
@@ -230,11 +247,10 @@ def undo_advancement(db: Session, match: models.Match):
|
||||
elif target_slot == 1:
|
||||
next_match.p2_team_id = None
|
||||
|
||||
if next_match.status == MatchStatus.PENDING:
|
||||
next_match.status = MatchStatus.SCHEDULED
|
||||
next_match.status = MatchStatus.SCHEDULED
|
||||
|
||||
db.add(next_match)
|
||||
|
||||
clear_from_next(match.winner_next_match, winner_id, match.winner_next_match_slot)
|
||||
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
|
||||
if match.loser_next_match and loser_id:
|
||||
clear_from_next(match.loser_next_match, loser_id, match.loser_next_match_slot)
|
||||
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import MatchCard from "./MatchCard";
|
||||
import Podium from './Podium';
|
||||
|
||||
|
||||
export default function BracketView({ matches, onMatchClick }) {
|
||||
const containerRef = useRef(null);
|
||||
const [lines, setLines] = useState([]);
|
||||
@@ -14,23 +13,58 @@ export default function BracketView({ matches, onMatchClick }) {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current.getBoundingClientRect();
|
||||
const newLines = [];
|
||||
|
||||
matches.forEach(m => {
|
||||
if (!m.winner_next_match_id) return;
|
||||
const sEl = document.getElementById(`match-${m.id}`);
|
||||
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
|
||||
if (sEl && eEl) {
|
||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
||||
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
|
||||
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
|
||||
const c1 = sx + (ex - sx) / 2;
|
||||
newLines.push(<path key={`${m.id}-${m.winner_next_match_id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 fill-none stroke-[1.5px]" />);
|
||||
if (m.winner_next_match_id) {
|
||||
const sEl = document.getElementById(`match-${m.id}`);
|
||||
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
|
||||
if (sEl && eEl) {
|
||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
||||
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
|
||||
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
|
||||
const c1 = sx + (ex - sx) / 2;
|
||||
newLines.push(
|
||||
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:!stroke-zinc-400 fill-none stroke-[1.5px]" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (m.loser_next_match_id) {
|
||||
const targetMatch = matches.find(x => x.id === m.loser_next_match_id);
|
||||
if (targetMatch && targetMatch.bracket === 'Finals') {
|
||||
const sEl = document.getElementById(`match-${m.id}`);
|
||||
const eEl = document.getElementById(`match-${targetMatch.id}`);
|
||||
if (sEl && eEl) {
|
||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
||||
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
|
||||
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
|
||||
const c1 = sx + (ex - sx) / 2;
|
||||
newLines.push(
|
||||
<path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:!stroke-zinc-400 fill-none stroke-[1.5px]" />
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
setLines(newLines);
|
||||
};
|
||||
|
||||
const t = setTimeout(draw, 100);
|
||||
window.addEventListener('resize', draw);
|
||||
return () => { clearTimeout(t); window.removeEventListener('resize', draw); };
|
||||
|
||||
const handlePrint = () => { draw(); setTimeout(draw, 100); };
|
||||
const mql = window.matchMedia('print');
|
||||
mql.addEventListener('change', handlePrint);
|
||||
window.addEventListener('beforeprint', handlePrint);
|
||||
window.addEventListener('afterprint', draw);
|
||||
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('resize', draw);
|
||||
mql.removeEventListener('change', handlePrint);
|
||||
window.removeEventListener('beforeprint', handlePrint);
|
||||
window.removeEventListener('afterprint', draw);
|
||||
};
|
||||
}, [matches]);
|
||||
|
||||
const renderRound = (list) => {
|
||||
@@ -66,43 +100,64 @@ export default function BracketView({ matches, onMatchClick }) {
|
||||
});
|
||||
};
|
||||
|
||||
const isDoubleElim = matches.some(m => m.bracket === 'Loser');
|
||||
const wb = matches.filter(m => m.bracket === 'Winner');
|
||||
const lb = matches.filter(m => m.bracket === 'Loser');
|
||||
const finals = matches.filter(m => m.bracket === 'Finals');
|
||||
|
||||
let displayWb = [...wb];
|
||||
let displayFinals = [...finals];
|
||||
|
||||
if (!isDoubleElim && displayWb.length > 0) {
|
||||
const maxRound = Math.max(...displayWb.map(m => m.round));
|
||||
const gfIndex = displayWb.findIndex(m => m.round === maxRound);
|
||||
|
||||
if (gfIndex !== -1) {
|
||||
const gfMatch = displayWb.splice(gfIndex, 1)[0];
|
||||
displayFinals.unshift(gfMatch);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-auto bg-[#f8f9fa] dark:bg-zinc-950 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#27272a_1px,transparent_1px)] [background-size:24px_24px]">
|
||||
<div className="w-full h-full overflow-auto print:overflow-visible print:h-auto print:w-auto bg-[#f8f9fa] dark:bg-zinc-950 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#27272a_1px,transparent_1px)] [background-size:24px_24px] print:!bg-white print:!bg-none">
|
||||
<style>
|
||||
{`@media print {
|
||||
@page { size: landscape; margin: 0.5cm; }
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: white !important; }
|
||||
}`}
|
||||
</style>
|
||||
|
||||
<div ref={containerRef} className="relative min-w-max min-h-full p-12 flex gap-20 items-center">
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0">
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0 print:overflow-visible">
|
||||
{lines}
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-col gap-24">
|
||||
<div className="relative">
|
||||
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Winners Bracket</div>
|
||||
<div className="flex gap-20">{renderRound(wb)}</div>
|
||||
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:!text-black">Winners Bracket</div>
|
||||
<div className="flex gap-20">{renderRound(displayWb)}</div>
|
||||
</div>
|
||||
{matches.some(m => m.bracket === 'Loser') && (
|
||||
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800">
|
||||
<div className="absolute top-0 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Losers Bracket</div>
|
||||
<div className="flex gap-20 mt-8">{renderRound(lb)}</div>
|
||||
{isDoubleElim && (
|
||||
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:!border-zinc-400">
|
||||
<div className="absolute top-4 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:!text-black">Losers Bracket</div>
|
||||
<div className="flex gap-20 mt-4">{renderRound(lb)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center gap-6 z-10">
|
||||
{matches.some(m => m.bracket === 'Finals') && (
|
||||
<>
|
||||
<div className="text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm mx-auto">Championship</div>
|
||||
{finals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
|
||||
</>
|
||||
{displayFinals.length > 0 && (
|
||||
<div className="relative flex flex-col gap-6">
|
||||
<div className="absolute -top-10 left-1/2 -translate-x-1/2 text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 print:!bg-transparent print:!border-black print:!text-black px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm whitespace-nowrap">
|
||||
Championship
|
||||
</div>
|
||||
{displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-6 z-10">
|
||||
<Podium matches={matches} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// frontend/src/components/Bracket/MatchCard.jsx
|
||||
|
||||
import { Check } from 'lucide-react';
|
||||
import { stringToColor } from '../../utils/helpers';
|
||||
import { printName, stringToColor } from '../../utils/helpers';
|
||||
|
||||
export default function MatchCard({ match, onClick }) {
|
||||
const isFinished = match.status === "Finished";
|
||||
@@ -11,7 +11,6 @@ export default function MatchCard({ match, onClick }) {
|
||||
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
|
||||
|
||||
const canInteract = match.hasTeams;
|
||||
|
||||
const cursorClass = canInteract
|
||||
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
|
||||
: 'cursor-default opacity-100';
|
||||
@@ -20,44 +19,44 @@ export default function MatchCard({ match, onClick }) {
|
||||
<div
|
||||
id={`match-${match.id}`}
|
||||
onClick={() => canInteract && onClick(match)}
|
||||
className={`w-64 bg-white dark:bg-zinc-900 rounded-lg border ${borderClass} shadow-sm transition-all duration-200 relative z-10 flex flex-col ${cursorClass}`}
|
||||
className={`w-64 bg-white dark:bg-zinc-900 print:!bg-white rounded-lg border ${borderClass} print:!border-zinc-400 print:!shadow-none shadow-sm transition-all duration-200 print:transition-none relative z-10 flex flex-col ${cursorClass}`}
|
||||
>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 rounded-t-lg">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900/50 print:!bg-transparent px-3 py-1.5 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 print:!border-zinc-400 rounded-t-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] font-bold text-zinc-400"># {match.number}</span>
|
||||
<span className="font-mono text-[10px] font-bold text-zinc-400 print:!text-zinc-600"># {match.number}</span>
|
||||
{match.time && (
|
||||
<span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded-sm uppercase" style={{ background: badgeColor }}>
|
||||
<span className="text-[9px] font-black text-white print:!text-zinc-800 px-1.5 py-0.5 rounded-sm uppercase print:!border print:!border-zinc-400 print:!bg-transparent" style={{ background: badgeColor }}>
|
||||
{match.court}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isFinished ? (
|
||||
<Check className="text-orange-500" size={14} strokeWidth={3} />
|
||||
<Check className="text-orange-500 print:hidden" size={14} strokeWidth={3} />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-zinc-500 font-mono">{match.time || 'TBD'}</span>
|
||||
<span className="text-[10px] font-bold text-zinc-500 print:!text-zinc-600 font-mono print:hidden">{match.time || 'TBD'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{[
|
||||
{
|
||||
n: match.p1,
|
||||
s: match.p1_sets,
|
||||
win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id,
|
||||
real: match.p1_team_id
|
||||
},
|
||||
{
|
||||
n: match.p2,
|
||||
s: match.p2_sets,
|
||||
win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id,
|
||||
real: match.p2_team_id
|
||||
}
|
||||
{ n: match.p1, s: match.p1_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id, real: match.p1_is_real },
|
||||
{ n: match.p2, s: match.p2_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id, real: match.p2_is_real }
|
||||
].map((p, i) => (
|
||||
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-900 dark:text-white font-black' : p.real ? 'text-zinc-600 dark:text-zinc-300' : 'text-zinc-400 italic'}`}>
|
||||
<span className="truncate text-xs uppercase tracking-tight">{p.n}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${p.win ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>
|
||||
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-900 dark:text-white print:!text-black font-black' : p.real ? 'text-zinc-600 dark:text-zinc-300 print:!text-black' : 'text-zinc-400 print:!text-zinc-600 italic'}`}>
|
||||
|
||||
<span className="truncate text-xs tracking-tight pr-2 print:hidden">{p.n}</span>
|
||||
|
||||
<span className="hidden print:inline-block print:whitespace-normal print:overflow-visible text-xs tracking-tight pr-2">
|
||||
{printName(p.n)}
|
||||
</span>
|
||||
|
||||
<span className={`print:hidden px-2 py-0.5 rounded text-[10px] font-bold ${p.win ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>
|
||||
{p.s}
|
||||
</span>
|
||||
|
||||
<span className="hidden print:flex w-5 h-5 border border-zinc-300 rounded-[3px] shrink-0 items-center justify-center text-[10px] font-bold text-black">
|
||||
{isFinished ? p.s : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// frontend/src/components/Bracket/Podium.jsx
|
||||
|
||||
import React from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
export default function Podium({ matches }) {
|
||||
const isDoubleElim = matches.some(m => m.bracket === 'Loser');
|
||||
|
||||
const wb = matches.filter(m => m.bracket === 'Winner').sort((a, b) => a.round - b.round);
|
||||
const lb = matches.filter(m => m.bracket === 'Loser').sort((a, b) => a.round - b.round);
|
||||
const finals = matches.filter(m => m.bracket === 'Finals').sort((a, b) => a.round - b.round);
|
||||
@@ -28,21 +28,23 @@ export default function Podium({ matches }) {
|
||||
{ rank: 3, label: "3rd", team: "TBD", isReal: false, color: "bg-amber-600 text-amber-50 dark:text-amber-50 shadow-amber-600/50", hidden: false }
|
||||
];
|
||||
|
||||
// --- CALCULATE 3RD PLACE ---
|
||||
if (tpMatch) {
|
||||
if (tpMatch.isFinished && tpMatch.winnerName) {
|
||||
if (isDoubleElim) {
|
||||
podium[2].team = tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1;
|
||||
} else {
|
||||
podium[2].team = tpMatch.winnerName;
|
||||
}
|
||||
// If match is done, grab the actual team name
|
||||
podium[2].team = isDoubleElim
|
||||
? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1) // DE: Loser of LB Final
|
||||
: tpMatch.winnerName; // SE: Winner of 3rd Place Match
|
||||
podium[2].isReal = true;
|
||||
} else {
|
||||
// Set the exact string expected by the print formatter
|
||||
podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`;
|
||||
}
|
||||
} else {
|
||||
podium[2].hidden = true;
|
||||
}
|
||||
|
||||
// --- CALCULATE 1ST & 2ND PLACE ---
|
||||
if (resetMatch && resetMatch.isFinished && resetMatch.winnerName) {
|
||||
podium[0].team = resetMatch.winnerName;
|
||||
podium[0].isReal = true;
|
||||
@@ -50,39 +52,56 @@ export default function Podium({ matches }) {
|
||||
podium[1].isReal = true;
|
||||
} else if (gfMatch) {
|
||||
if (gfMatch.isFinished && gfMatch.winnerName) {
|
||||
// Has the loser bracket champ won, forcing a reset?
|
||||
const isResetForced = resetMatch && resetMatch.hasTeams;
|
||||
|
||||
if (!isResetForced) {
|
||||
// GF Winner is 1st, GF Loser is 2nd
|
||||
podium[0].team = gfMatch.winnerName;
|
||||
podium[0].isReal = true;
|
||||
podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1;
|
||||
podium[1].isReal = true;
|
||||
} else {
|
||||
// Reset forced, wait for the final match
|
||||
podium[0].team = `Winner of #${resetMatch.number}`;
|
||||
podium[1].team = `Loser of #${resetMatch.number}`;
|
||||
}
|
||||
} else {
|
||||
// GF not finished yet
|
||||
podium[0].team = `Winner of #${gfMatch.number}`;
|
||||
podium[1].team = `Loser of #${gfMatch.number}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for rendering print strings (Takes "Winner of #7" -> "W#7: _______")
|
||||
const printName = (name) => {
|
||||
if (!name) return '';
|
||||
if (name.startsWith('Winner of #')) return name.replace('Winner of #', 'W#') + ': _______';
|
||||
if (name.startsWith('Loser of #')) return name.replace('Loser of #', 'L#') + ': _______';
|
||||
return name;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-8 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-sm w-64 overflow-hidden z-10">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900/50 p-3 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 text-center flex items-center justify-center gap-2">
|
||||
<Trophy size={14} className="text-orange-500" /> Final Standings
|
||||
<div className="bg-white dark:bg-zinc-900 print:!bg-white border border-zinc-200 dark:border-zinc-800 print:!border-zinc-400 rounded-xl shadow-sm w-64 overflow-hidden z-10 print:!shadow-none">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900/50 print:!bg-transparent p-3 border-b border-zinc-200 dark:border-zinc-800 print:!border-zinc-400">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 print:!text-zinc-600 text-center flex items-center justify-center gap-2">
|
||||
<Trophy size={14} className="text-orange-500 print:!text-black" /> Final Standings
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{podium.filter(p => !p.hidden).map(p => (
|
||||
<div key={p.rank} className="flex items-center gap-3">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center font-black text-xs shrink-0 shadow-sm ${p.color}`}>
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center font-black text-xs shrink-0 shadow-sm print:!shadow-none print:!bg-white print:!border print:!border-zinc-400 print:!text-black ${p.color}`}>
|
||||
{p.rank}
|
||||
</div>
|
||||
<div className={`text-sm truncate ${p.isReal ? 'font-bold text-zinc-900 dark:text-white' : 'font-medium italic text-zinc-400'}`} title={p.team}>
|
||||
{/* Web Label */}
|
||||
<div className={`text-sm truncate print:hidden ${p.isReal ? 'font-bold text-zinc-900 dark:text-white print:!text-black' : 'font-medium italic text-zinc-400 print:!text-zinc-600'}`} title={p.team}>
|
||||
{p.team}
|
||||
</div>
|
||||
{/* Print Label */}
|
||||
<div className={`hidden print:block text-sm print:whitespace-normal print:overflow-visible ${p.isReal ? 'font-bold print:!text-black' : 'font-medium italic print:!text-zinc-600'}`}>
|
||||
{printName(p.team)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// frontend/src/components/Dashboard/DashCard.jsx
|
||||
|
||||
import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
|
||||
|
||||
export default function DashCard({ t, isAdmin, onSelect, onEdit }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(t.id)}
|
||||
className="block bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border border-zinc-200 dark:border-zinc-800 relative group hover:shadow-md hover:scale-[1.02] transition-all duration-200 will-change-transform transform-gpu cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="min-w-0 pr-4">
|
||||
<h2 className="text-xl font-bold truncate text-zinc-900 dark:text-white group-hover:text-orange-500 dark:group-hover:text-orange-400 transition">
|
||||
{t.name}
|
||||
</h2>
|
||||
<div className="text-xs text-zinc-400 dark:text-zinc-500 mt-1 font-mono flex items-center gap-2">
|
||||
<span>{t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'}</span>
|
||||
<span>{t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-[10px] px-2 py-1 rounded font-mono border border-orange-200 dark:border-orange-800 uppercase tracking-tight shrink-0">
|
||||
{t.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm text-zinc-500 dark:text-zinc-400 items-center">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Users size={16} className="text-orange-500" />
|
||||
{t.team_count} Teams
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<MapPin size={16} className="text-orange-500" />
|
||||
{t.court_count} Courts
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(t); }}
|
||||
className="ml-auto hover:text-orange-500 transition z-10 h-8 w-8 flex items-center justify-center rounded-full text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
title="Tournament Settings"
|
||||
>
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
|
||||
|
||||
try {
|
||||
if (tournamentId) {
|
||||
// UPDATE: Dispatch the 3 separated PATCH endpoints concurrently
|
||||
const basePayload = {
|
||||
name,
|
||||
code,
|
||||
@@ -78,7 +77,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
|
||||
api.patch(`/tournaments/${tournamentId}/courts`, courts)
|
||||
]);
|
||||
} else {
|
||||
// CREATE: Send the full monolithic payload to POST /tournaments
|
||||
const fullPayload = {
|
||||
name,
|
||||
code,
|
||||
@@ -121,7 +119,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
|
||||
}
|
||||
|
||||
return (
|
||||
// Add a key mapped to the ID so React completely rebuilds the form when data loads
|
||||
<form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
|
||||
@@ -169,7 +166,7 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
|
||||
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration (min)</label>
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
||||
<input
|
||||
type="number"
|
||||
name="duration"
|
||||
|
||||
@@ -21,20 +21,21 @@ export default function Layout({ darkMode, setDarkMode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 transition-colors selection:bg-orange-500/30 flex flex-col overflow-hidden">
|
||||
<Navbar
|
||||
title={navTitle}
|
||||
subtitle={navSubtitle}
|
||||
isAdmin={isAdmin}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 transition-colors selection:bg-orange-500/30 flex flex-col overflow-hidden print:static print:overflow-visible print:h-auto print:bg-white print:text-black">
|
||||
<div className="print:hidden shrink-0">
|
||||
<Navbar
|
||||
title={navTitle}
|
||||
subtitle={navSubtitle}
|
||||
isAdmin={isAdmin}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-hidden relative flex flex-col">
|
||||
<main className="flex-1 overflow-hidden relative flex flex-col print:overflow-visible print:h-auto print:block">
|
||||
<Outlet context={{ setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings }} />
|
||||
</main>
|
||||
|
||||
{/* Dark Mode FAB */}
|
||||
<div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40">
|
||||
<div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40 print:hidden">
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
title="Toggle Theme"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// frontend/src/components/Schedule/ScheduleRow.jsx
|
||||
|
||||
import { CheckCircle, Pencil, Plus, Trophy } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { printName, stringToColor } from '../../utils/helpers';
|
||||
|
||||
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
|
||||
const courtColor = stringToColor(m.court);
|
||||
const isFinished = m.isFinished;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 print:!bg-white p-4 print:p-3 rounded-2xl print:rounded-none border border-zinc-300 dark:border-zinc-800 print:!border-b print:!border-x-0 print:!border-t-0 print:!border-zinc-300 shadow-sm print:!shadow-none flex items-center justify-between transition-all hover:border-orange-500/30">
|
||||
<div className="flex gap-4 md:gap-6 print:gap-6 flex-1 min-w-0">
|
||||
<div className="flex flex-col gap-1 items-center shrink-0" style={{ minWidth: badgeWidth }}>
|
||||
<div className="text-lg md:text-xl print:text-xl font-black font-mono text-zinc-900 dark:text-white print:!text-black">{m.time}</div>
|
||||
<div className="text-tiny font-black text-white print:!text-zinc-800 px-2 py-1 rounded uppercase w-full truncate text-center print:!border print:!border-zinc-400 print:!bg-transparent" style={{ background: courtColor }}>
|
||||
{m.court}
|
||||
</div>
|
||||
<div className="block md:hidden print:hidden text-tiny font-black bg-zinc-100 dark:bg-zinc-800 w-full truncate text-center print:!bg-transparent text-zinc-400 print:!text-zinc-500 px-2 py-1 rounded w-fit">Match #{m.number}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-0.5 min-w-0 justify-center md:justify-between">
|
||||
<div className="flex flex-col md:flex-row md:items-end print:flex-row print:items-center gap-1 md:gap-2 print:gap-2">
|
||||
{[{ n: m.p1, win: m.winnerName === m.p1, real: m.p1_is_real },
|
||||
{ n: m.p2, win: m.winnerName === m.p2, real: m.p2_is_real }].map((p, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{p.win && <Trophy size={14} className="text-orange-500 shrink-0 print:hidden" />}
|
||||
<span className={`block print:hidden truncate text-sm md:text-base font-bold ${p.win ? 'text-orange-600' : p.real ? 'text-zinc-900 dark:text-zinc-100' : 'text-zinc-400 italic font-normal'}`}>{p.n}</span>
|
||||
<span className={`hidden print:inline-block print:whitespace-normal print:overflow-visible print:text-base font-bold ${p.real ? 'print:!text-black' : 'print:!text-zinc-600 italic font-normal'}`}>
|
||||
{printName(p.n)}
|
||||
</span>
|
||||
</div>
|
||||
{i === 0 && <span className="block print:block text-zinc-300 dark:text-zinc-700 print:!text-zinc-400 text-xs font-black md:pb-0.5">VS</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block print:block text-tiny font-black bg-zinc-100 dark:bg-zinc-800 print:!bg-transparent text-zinc-400 print:!text-zinc-500 px-2 py-1 rounded w-fit">Match #{m.number}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACTION BUTTON AREA */}
|
||||
{/* items-stretch ensures the button fills the height on mobile to push the scores apart */}
|
||||
<div className="ml-3 flex items-stretch shrink-0 print:hidden py-0.5">
|
||||
{isFinished ? (
|
||||
<button
|
||||
onClick={() => onMatchClick(m)}
|
||||
className="flex flex-col justify-between items-center md:justify-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-1.5 md:p-2 rounded-xl transition group/btn min-w-[32px] md:min-w-[80px] border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
|
||||
title="Edit Score"
|
||||
>
|
||||
{/* --- MOBILE VERTICAL STACK --- */}
|
||||
<div className="px-2 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden">
|
||||
{m.p1_sets}
|
||||
</div>
|
||||
|
||||
{/* Clean minimal vertical line for mobile */}
|
||||
<div className="w-[2px] h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
|
||||
|
||||
<div className="px-2 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden">
|
||||
{m.p2_sets}
|
||||
</div>
|
||||
|
||||
{/* --- DESKTOP VIEW (Default + Hover Edit) --- */}
|
||||
<div className="hidden md:flex group-hover/btn:hidden flex-col items-end">
|
||||
<div className="text-orange-500 font-black text-[10px] uppercase flex items-center gap-1">
|
||||
<CheckCircle size={12} strokeWidth={3} /> Finished
|
||||
</div>
|
||||
<div className="text-sm font-black font-mono text-zinc-900 dark:text-zinc-300">
|
||||
{m.p1_sets} - {m.p2_sets}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:group-hover/btn:flex flex-col items-end text-zinc-500 dark:text-zinc-400 animate-in fade-in zoom-in duration-200">
|
||||
<div className="text-[10px] font-black uppercase flex items-center gap-1">
|
||||
<Pencil size={12} strokeWidth={3} /> Edit
|
||||
</div>
|
||||
<div className="text-sm font-black font-mono">
|
||||
{m.p1_sets} - {m.p2_sets}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : m.isReady && (
|
||||
<button onClick={() => onMatchClick(m)} className="bg-orange-600 hover:bg-orange-500 text-white p-2 md:px-4 md:py-2 rounded-xl shadow-lg active:scale-95 transition-all flex items-center gap-2 group/btn self-center">
|
||||
<Plus size={18} strokeWidth={3} className="group-hover/btn:rotate-90 transition-transform duration-200" />
|
||||
<span className="text-xs font-bold uppercase hidden md:inline">Report score</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden print:flex items-center gap-4 shrink-0 ml-6">
|
||||
<div className="w-8 h-8 border-[1.5px] border-zinc-400 rounded-md flex items-center justify-center font-bold text-sm text-black">
|
||||
{isFinished ? m.p1_sets : ''}
|
||||
</div>
|
||||
<span className="text-zinc-400 font-bold">-</span>
|
||||
<div className="w-8 h-8 border-[1.5px] border-zinc-400 rounded-md flex items-center justify-center font-bold text-sm text-black">
|
||||
{isFinished ? m.p2_sets : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// frontend/src/components/Schedule/ScheduleView.jsx
|
||||
|
||||
import { CheckCircle, Pencil, Plus, Search, Trophy } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { stringToColor } from '../../utils/helpers';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import ScheduleRow from './ScheduleRow';
|
||||
|
||||
export default function ScheduleView({ schedule, onMatchClick }) {
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -12,26 +12,29 @@ export default function ScheduleView({ schedule, onMatchClick }) {
|
||||
return c.length > max.length ? c : max;
|
||||
}, "Court");
|
||||
|
||||
const badgeWidth = Math.max(100, longestCourt.length * 9);
|
||||
const badgeWidth = Math.max(80, longestCourt.length * 10);
|
||||
|
||||
const filteredAndSorted = schedule
|
||||
.filter(m => (m.p1 + m.p2 + m.number).toLowerCase().includes(filter.toLowerCase()))
|
||||
.filter(m => (m.p1 + m.p2 + m.number + `Match #${m.number}`).toLowerCase().includes(filter.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const timeA = a.start_time || a.timestamp || a.time || "";
|
||||
const timeB = b.start_time || b.timestamp || b.time || "";
|
||||
|
||||
if (timeA !== timeB) {
|
||||
return timeA.localeCompare(timeB);
|
||||
}
|
||||
|
||||
if (timeA !== timeB) return timeA.localeCompare(timeB);
|
||||
const courtA = a.court || "";
|
||||
const courtB = b.court || "";
|
||||
return courtA.localeCompare(courtB);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden relative flex flex-col">
|
||||
<div className="absolute top-0 inset-x-0 z-30 p-6 pb-2 bg-transparent pointer-events-none">
|
||||
<div className="h-full overflow-hidden print:overflow-visible print:h-auto print:block relative flex flex-col">
|
||||
<style>
|
||||
{`@media print {
|
||||
@page { size: portrait; margin: 1cm; }
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: white !important; }
|
||||
}`}
|
||||
</style>
|
||||
|
||||
<div className="absolute top-0 inset-x-0 z-30 p-6 pb-2 bg-transparent pointer-events-none print:hidden">
|
||||
<div className="relative group max-w-3xl mx-auto w-full pointer-events-auto">
|
||||
<input
|
||||
placeholder="Search matches..."
|
||||
@@ -43,69 +46,24 @@ export default function ScheduleView({ schedule, onMatchClick }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 pt-28 pb-32 [mask-image:linear-gradient(to_bottom,transparent_0px,transparent_60px,black_110px)]">
|
||||
<div className="max-w-4xl mx-auto w-full space-y-3">
|
||||
{filteredAndSorted.map(m => {
|
||||
const courtColor = stringToColor(m.court);
|
||||
const isFinished = m.isFinished;
|
||||
<div className="absolute top-0 left-0 right-3 h-32 bg-gradient-to-b from-zinc-50 via-zinc-50/95 to-transparent dark:from-zinc-950 dark:via-zinc-950/95 dark:to-transparent pointer-events-none z-20 print:hidden" />
|
||||
|
||||
return (
|
||||
<div key={m.id} className="bg-white dark:bg-zinc-900 p-4 rounded-2xl border border-zinc-300 dark:border-zinc-800 shadow-sm flex items-center justify-between transition-all hover:border-orange-500/30">
|
||||
<div className="flex gap-4 md:gap-6 items-center flex-1 min-w-0">
|
||||
{/* UNIFORM WIDTH METADATA COLUMN */}
|
||||
<div className="flex flex-col gap-1 items-center shrink-0" style={{ minWidth: badgeWidth }}>
|
||||
<div className="text-lg md:text-xl font-black font-mono text-zinc-900 dark:text-white">{m.time}</div>
|
||||
<div className="text-[9px] font-black text-white px-2 py-1 rounded uppercase w-full truncate text-center" style={{ background: courtColor }}>
|
||||
{m.court}
|
||||
</div>
|
||||
<div className="text-[9px] font-black bg-zinc-100 dark:bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded w-full md:hidden text-center">#{m.number}</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto print:overflow-visible p-6 pt-28 pb-32 print:p-0 print:pt-12 relative z-10">
|
||||
<div className="max-w-4xl mx-auto w-full space-y-3 print:space-y-0">
|
||||
|
||||
<div className="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-1 md:gap-2">
|
||||
{[{ n: m.p1, win: m.winnerName === m.p1, real: m.p1_is_real },
|
||||
{ n: m.p2, win: m.winnerName === m.p2, real: m.p2_is_real }].map((p, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{p.win && <Trophy size={14} className="text-orange-500 shrink-0" />}
|
||||
<span className={`truncate text-sm md:text-base font-bold ${p.win ? 'text-orange-600' : p.real ? 'text-zinc-900 dark:text-zinc-100' : 'text-zinc-400 italic font-normal'}`}>{p.n}</span>
|
||||
</div>
|
||||
{i === 0 && <span className="hidden md:block text-zinc-300 text-xs font-black px-1">VS</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block text-[10px] font-black bg-zinc-100 dark:bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded w-fit">Match #{m.number}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden print:block text-2xl font-black mb-6 text-black uppercase tracking-widest border-b-2 border-black pb-2">
|
||||
Tournament Schedule
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex items-center shrink-0">
|
||||
{isFinished ? (
|
||||
<button
|
||||
onClick={() => onMatchClick(m)}
|
||||
className="flex flex-col items-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-2 rounded-xl transition min-w-[80px] group cursor-pointer"
|
||||
title="Edit Score"
|
||||
>
|
||||
{/* Normal View: Finished + Score */}
|
||||
<div className="group-hover:hidden flex flex-col items-center md:items-end">
|
||||
<div className="text-orange-500 font-black text-[10px] uppercase flex items-center gap-1"><CheckCircle size={12} /> Finished</div>
|
||||
<div className="text-sm font-black font-mono text-zinc-900 dark:text-zinc-300">{m.p1_sets} - {m.p2_sets}</div>
|
||||
</div>
|
||||
{filteredAndSorted.map(m => (
|
||||
<ScheduleRow
|
||||
key={m.id}
|
||||
match={m}
|
||||
onMatchClick={onMatchClick}
|
||||
badgeWidth={badgeWidth}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hover View: Edit Icon */}
|
||||
<div className="hidden group-hover:flex flex-col items-center md:items-end text-zinc-500 dark:text-zinc-400 animate-in fade-in zoom-in duration-200">
|
||||
<div className="text-[10px] font-black uppercase flex items-center gap-1"><Pencil size={12} /> Edit</div>
|
||||
<div className="text-sm font-black font-mono">{m.p1_sets} - {m.p2_sets}</div>
|
||||
</div>
|
||||
</button>
|
||||
) : m.isReady && (
|
||||
<button onClick={() => onMatchClick(m)} className="bg-orange-600 hover:bg-orange-500 text-white p-2 md:px-4 md:py-2 rounded-xl shadow-lg active:scale-95 transition-all flex items-center gap-2">
|
||||
<Plus size={18} strokeWidth={3} /> <span className="text-xs font-bold uppercase hidden md:inline">Report score</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// frontend/src/components/Tournament/ScoreModal.jsx
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Eraser, Clock, MapPin } from 'lucide-react';
|
||||
import { Eraser, Clock, MapPin, Trophy } from 'lucide-react';
|
||||
import { stringToColor } from '../../utils/helpers';
|
||||
import Modal from '../UI/Modal';
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function ScoreModal({ isOpen, onClose, match, isAdmin, onSubmit,
|
||||
if (!isOpen || !match) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Match #${match.number}`}>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Match #${match.number}`} icon={Trophy}>
|
||||
<ScoreForm
|
||||
match={match}
|
||||
isAdmin={isAdmin}
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children }) {
|
||||
export default function Modal({ isOpen, onClose, title, icon: Icon, children }) {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/75 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-lg border border-zinc-300 dark:border-zinc-800 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white flex items-center gap-2 uppercase tracking-tight">{title}</h2>
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white flex items-start gap-2">
|
||||
{Icon && <Icon weight="duotone" className="text-orange-500 mt-1 shrink-0" size={24} />}
|
||||
<span>{title}</span>
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* frontend/src/index.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--text-tiny: 0.675rem;
|
||||
}
|
||||
@@ -1,56 +1,13 @@
|
||||
// frontend/src/pages/Dashboard.jsx
|
||||
|
||||
import { Calendar, ChevronDown, ChevronUp, History, Plus, SlidersHorizontal, Users, MapPin, Clock } from 'lucide-react';
|
||||
import { Calendar, ChevronDown, ChevronUp, History, Plus } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import DashCard from '../components/Dashboard/DashCard';
|
||||
import TournamentForm from '../components/Forms/TournamentForm';
|
||||
import Modal from '../components/UI/Modal';
|
||||
import api, { WS_URL } from '../services/api';
|
||||
|
||||
// --- RESTORED DASHCARD DESIGN ---
|
||||
const DashCard = ({ t, isAdmin, onSelect, onEdit }) => (
|
||||
<div
|
||||
onClick={() => onSelect(t.id)}
|
||||
className="block bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border border-zinc-200 dark:border-zinc-800 relative group hover:shadow-md hover:scale-[1.02] transition-all duration-200 will-change-transform transform-gpu cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="min-w-0 pr-4">
|
||||
<h2 className="text-xl font-bold truncate text-zinc-900 dark:text-white group-hover:text-orange-500 dark:group-hover:text-orange-400 transition">
|
||||
{t.name}
|
||||
</h2>
|
||||
<div className="text-xs text-zinc-400 dark:text-zinc-500 mt-1 font-mono flex items-center gap-2">
|
||||
{/* Parse date safely */}
|
||||
<span>{t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'}</span>
|
||||
<span>{t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-[10px] px-2 py-1 rounded font-mono border border-orange-200 dark:border-orange-800 uppercase tracking-tight shrink-0">
|
||||
{t.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm text-zinc-500 dark:text-zinc-400 items-center">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Users size={16} className="text-orange-500" />
|
||||
{t.team_count} Teams
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<MapPin size={16} className="text-orange-500" />
|
||||
{t.court_count} Courts
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(t); }}
|
||||
className="ml-auto hover:text-orange-500 transition z-10 h-8 w-8 flex items-center justify-center rounded-full text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
title="Tournament Settings"
|
||||
>
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { SlidersHorizontal, PlusCircle } from 'lucide-react';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext();
|
||||
@@ -140,8 +97,11 @@ export default function Dashboard() {
|
||||
|
||||
{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 className="text-xs font-black text-green-500 uppercase tracking-[0.2em] mb-6 flex items-center gap-3">
|
||||
<span class="relative flex size-3">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex size-3 rounded-full bg-green-500"></span>
|
||||
</span> 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} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
|
||||
@@ -149,8 +109,8 @@ export default function Dashboard() {
|
||||
</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">
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xs font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-6 flex items-center gap-3">
|
||||
<Calendar size={18} /> Upcoming
|
||||
</h2>
|
||||
{groups.future.length > 0 ? (
|
||||
@@ -160,7 +120,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
{groups.future.length > 4 && (
|
||||
<div className="mt-8 text-center">
|
||||
<button onClick={() => setShowAllFuture(!showAllFuture)} className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 hover:text-orange-500 transition border-b-2 border-transparent hover:border-orange-500 pb-1 flex items-center justify-center gap-1 mx-auto">
|
||||
<button onClick={() => setShowAllFuture(!showAllFuture)} className="text-xs font-black uppercase tracking-[0.1em] text-zinc-500 hover:text-orange-500 transition border-b-2 border-transparent hover:border-orange-500 pb-1 flex items-center justify-center gap-1 mx-auto">
|
||||
{showAllFuture ? 'Show Less' : `Show All (${groups.future.length})`}
|
||||
{showAllFuture ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
@@ -173,11 +133,11 @@ export default function Dashboard() {
|
||||
{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>
|
||||
<h2 className="text-xs font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.2em] 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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-75 hover:opacity-100 transition-opacity">
|
||||
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
|
||||
</div>
|
||||
)}
|
||||
@@ -185,7 +145,7 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showSettings} onClose={() => { setShowSettings(false); setEditTarget(null); }} title={editTarget ? 'Modify Event' : 'Initialize Event'}>
|
||||
<Modal isOpen={showSettings} onClose={() => { setShowSettings(false); setEditTarget(null); }} title={editTarget ? 'Tournament Settings' : 'New Tournament'} icon={editTarget ? SlidersHorizontal : PlusCircle}>
|
||||
<TournamentForm
|
||||
tournamentId={editTarget?.id}
|
||||
onSuccess={handleSuccess}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Loader2, Volleyball } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function Login() {
|
||||
@@ -37,7 +37,7 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">System Access</h1>
|
||||
<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 administrative credentials</p>
|
||||
|
||||
{error && (
|
||||
@@ -48,12 +48,12 @@ export default function Login() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Identity</label>
|
||||
<input name="username" placeholder="Admin UID" required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-2xl dark:text-white outline-none focus:border-orange-500 transition font-bold" />
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Username</label>
|
||||
<input name="username" placeholder="Username" required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-2xl dark:text-white outline-none focus:border-orange-500 transition font-bold" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Secret Key</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Password</label>
|
||||
<input name="password" type="password" placeholder="••••••••" required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-2xl dark:text-white outline-none focus:border-orange-500 transition font-bold" />
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,9 @@ export default function Login() {
|
||||
</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>
|
||||
<Link to="/" className="text-[10px] font-black uppercase tracking-widest text-zinc-400 hover:text-orange-600 transition">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// frontend/src/pages/Tournament.jsx
|
||||
|
||||
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
|
||||
import { CalendarDays, Loader2, Network, SlidersHorizontal } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useOutletContext, useParams } from 'react-router-dom';
|
||||
import BracketView from '../components/Bracket/BracketView';
|
||||
@@ -53,6 +53,7 @@ export default function Tournament() {
|
||||
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
|
||||
const isFinished = m.status === "Finished";
|
||||
const isReady = hasTeams && !isFinished;
|
||||
const winnerName = m.winner_team_id ? teamMap[m.winner_team_id] : null;
|
||||
|
||||
return {
|
||||
...m,
|
||||
@@ -61,6 +62,7 @@ export default function Tournament() {
|
||||
number: m.match_number,
|
||||
p1,
|
||||
p2,
|
||||
winnerName,
|
||||
p1_is_real: !!m.p1_team_id,
|
||||
p2_is_real: !!m.p2_team_id,
|
||||
isReady,
|
||||
@@ -109,8 +111,8 @@ export default function Tournament() {
|
||||
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">
|
||||
<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 z-20">
|
||||
<div className="h-full flex flex-col print:h-auto print:block">
|
||||
<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 z-20 print:hidden">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleViewChange('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
|
||||
@@ -119,10 +121,10 @@ export default function Tournament() {
|
||||
<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>}
|
||||
{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"><SlidersHorizontal size={20} /></button>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<div className="flex-1 overflow-hidden relative print:overflow-visible print:h-auto print:block">
|
||||
{view === 'bracket'
|
||||
? <BracketView matches={matches} onMatchClick={setScoreMatch} />
|
||||
: <ScheduleView schedule={matches} onMatchClick={setScoreMatch} />
|
||||
@@ -137,11 +139,7 @@ export default function Tournament() {
|
||||
/>
|
||||
)}
|
||||
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
|
||||
<TournamentForm
|
||||
tournamentId={id}
|
||||
onSuccess={() => { setShowSettings(false); fetchData(); }}
|
||||
onDelete={handleDeleteTournament}
|
||||
/>
|
||||
<TournamentForm tournamentId={id} onSuccess={() => { setShowSettings(false); fetchData(); }} onDelete={handleDeleteTournament} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,4 +9,11 @@ export const stringToColor = (str) => {
|
||||
const combined = normalized + salt;
|
||||
for (let i = 0; i < combined.length; i++) hash = combined.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COURT_COLORS[Math.abs(hash) % COURT_COLORS.length];
|
||||
};
|
||||
|
||||
export const printName = (name) => {
|
||||
if (!name) return '';
|
||||
if (name.startsWith('Winner of #')) return name.replace('Winner of #', 'W') + ': _______________';
|
||||
if (name.startsWith('Loser of #')) return name.replace('Loser of #', 'L') + ': _______________';
|
||||
return name;
|
||||
};
|
||||
Reference in New Issue
Block a user