Compare commits
Vendored
+2
-1
@@ -3,5 +3,6 @@
|
|||||||
"backend"
|
"backend"
|
||||||
],
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python-envs.defaultEnvManager": "ms-python.python:venv"
|
||||||
}
|
}
|
||||||
@@ -173,6 +173,106 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# --- DYNAMIC REFEREE ASSIGNMENT ---
|
||||||
|
# ==========================================
|
||||||
|
all_matches = sorted(list(matches), key=lambda x: (x.start_time, x.court_id))
|
||||||
|
duty_counts = defaultdict(int)
|
||||||
|
active_refs = []
|
||||||
|
|
||||||
|
def is_busy(outcome_tuple, source_m, start, end):
|
||||||
|
next_m_id = source_m.winner_next_match_id if outcome_tuple[0] == "W" else source_m.loser_next_match_id
|
||||||
|
if next_m_id:
|
||||||
|
next_m = match_map[next_m_id]
|
||||||
|
next_start = next_m.start_time
|
||||||
|
if next_start:
|
||||||
|
next_end = next_start + timedelta(minutes=t.duration)
|
||||||
|
if not (next_end <= start or next_start >= end):
|
||||||
|
return True
|
||||||
|
for r_outcome, r_start, r_end in active_refs:
|
||||||
|
if r_outcome == outcome_tuple:
|
||||||
|
if not (r_end <= start or r_start >= end):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for m in all_matches:
|
||||||
|
if not m.start_time:
|
||||||
|
continue
|
||||||
|
|
||||||
|
m_start = m.start_time
|
||||||
|
m_end = m_start + timedelta(minutes=t.duration)
|
||||||
|
|
||||||
|
if m.bracket_type == BracketTypes.FINALS:
|
||||||
|
prev_final = next((p for p in all_matches if p.bracket_type == BracketTypes.FINALS and p.winner_next_match_id == m.id), None)
|
||||||
|
|
||||||
|
if prev_final:
|
||||||
|
m.ref_team_id = prev_final.ref_team_id
|
||||||
|
m.ref_label = prev_final.ref_label
|
||||||
|
|
||||||
|
identifier = f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
|
||||||
|
active_refs.append((identifier, m_start, m_end))
|
||||||
|
continue
|
||||||
|
|
||||||
|
best_outcome = None
|
||||||
|
best_score = float('inf')
|
||||||
|
|
||||||
|
for prev_m in all_matches:
|
||||||
|
if not prev_m.start_time:
|
||||||
|
continue
|
||||||
|
prev_end = prev_m.start_time + timedelta(minutes=t.duration)
|
||||||
|
|
||||||
|
if prev_end <= m_start:
|
||||||
|
l_outcome = ("L", prev_m.id)
|
||||||
|
if not is_busy(l_outcome, prev_m, m_start, m_end):
|
||||||
|
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
||||||
|
score = (duty_counts[l_outcome] * 120) + wait_mins
|
||||||
|
if prev_m.court_id == m.court_id:
|
||||||
|
score -= 30
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_outcome = (l_outcome, prev_m)
|
||||||
|
|
||||||
|
w_outcome = ("W", prev_m.id)
|
||||||
|
if not is_busy(w_outcome, prev_m, m_start, m_end):
|
||||||
|
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
||||||
|
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
|
||||||
|
if prev_m.court_id == m.court_id:
|
||||||
|
score -= 30
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_outcome = (w_outcome, prev_m)
|
||||||
|
|
||||||
|
if best_outcome:
|
||||||
|
outcome_tuple, prev_m = best_outcome
|
||||||
|
duty_counts[outcome_tuple] += 1
|
||||||
|
active_refs.append((outcome_tuple, m_start, m_end))
|
||||||
|
role = "Winner" if outcome_tuple[0] == "W" else "Loser"
|
||||||
|
m.ref_label = f"{role} of #{prev_m.match_number}"
|
||||||
|
else:
|
||||||
|
m.ref_label = "Staff / Volunteers"
|
||||||
|
for future_m in all_matches:
|
||||||
|
if future_m.start_time and future_m.start_time >= m_end:
|
||||||
|
assigned = False
|
||||||
|
|
||||||
|
for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
|
||||||
|
if t_id:
|
||||||
|
team_identifier = f"TEAM_{t_id}"
|
||||||
|
|
||||||
|
is_team_busy = any(
|
||||||
|
r_outcome == team_identifier and not (r_end <= m_start or r_start >= m_end)
|
||||||
|
for r_outcome, r_start, r_end in active_refs
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_team_busy:
|
||||||
|
m.ref_team_id = t_id
|
||||||
|
m.ref_label = None
|
||||||
|
active_refs.append((team_identifier, m_start, m_end))
|
||||||
|
assigned = True
|
||||||
|
break
|
||||||
|
if assigned:
|
||||||
|
break
|
||||||
|
# ----------------------------------
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -182,6 +282,14 @@ 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
|
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
||||||
|
|
||||||
|
for m in match.tournament.matches:
|
||||||
|
if m.ref_label == f"Loser of #{match.match_number}":
|
||||||
|
m.ref_team_id = loser_id
|
||||||
|
db.add(m)
|
||||||
|
elif m.ref_label == f"Winner of #{match.match_number}":
|
||||||
|
m.ref_team_id = winner_id
|
||||||
|
db.add(m)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
match.bracket_type == BracketTypes.FINALS
|
match.bracket_type == BracketTypes.FINALS
|
||||||
and match.winner_next_match
|
and match.winner_next_match
|
||||||
@@ -246,6 +354,11 @@ def undo_advancement(db: Session, match: models.Match):
|
|||||||
if not match.winner_team_id:
|
if not match.winner_team_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
for m in match.tournament.matches:
|
||||||
|
if m.ref_label == f"Loser of #{match.match_number}" or m.ref_label == f"Winner of #{match.match_number}":
|
||||||
|
m.ref_team_id = None
|
||||||
|
db.add(m)
|
||||||
|
|
||||||
winner_id = match.winner_team_id
|
winner_id = match.winner_team_id
|
||||||
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
||||||
|
|
||||||
@@ -272,3 +385,5 @@ def undo_advancement(db: Session, match: models.Match):
|
|||||||
clear_from_next(match.winner_next_match, 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:
|
if match.loser_next_match and loser_id:
|
||||||
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
+17
-36
@@ -70,51 +70,32 @@ class Match(Base):
|
|||||||
|
|
||||||
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
|
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
|
||||||
|
|
||||||
court_id: Mapped[Optional[int]] = mapped_column(
|
court_id: Mapped[Optional[int]] = mapped_column(ForeignKey("courts.id"), nullable=True)
|
||||||
ForeignKey("courts.id"), nullable=True
|
|
||||||
)
|
|
||||||
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
status: Mapped[MatchStatus] = mapped_column(
|
status: Mapped[MatchStatus] = mapped_column(SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED)
|
||||||
SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
|
|
||||||
)
|
p1_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||||
|
p2_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||||
|
|
||||||
|
ref_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||||
|
ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
p1_team_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
ForeignKey("teams.id"), nullable=True
|
|
||||||
)
|
|
||||||
p2_team_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
ForeignKey("teams.id"), nullable=True
|
|
||||||
)
|
|
||||||
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||||
winner_team_id: Mapped[Optional[int]] = mapped_column(
|
winner_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||||
ForeignKey("teams.id"), nullable=True
|
|
||||||
)
|
|
||||||
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
ForeignKey("matches.id"), nullable=True
|
|
||||||
)
|
|
||||||
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
ForeignKey("matches.id"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
|
winner_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
|
||||||
ForeignKey("matches.id"), nullable=True
|
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
)
|
|
||||||
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(
|
|
||||||
Integer, nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
|
loser_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
|
||||||
ForeignKey("matches.id"), nullable=True
|
|
||||||
)
|
|
||||||
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
|
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
|
||||||
court: Mapped[Optional["Court"]] = relationship()
|
court: Mapped[Optional["Court"]] = relationship()
|
||||||
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
|
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
|
||||||
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
|
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
|
||||||
winner_next_match: Mapped["Match"] = relationship(
|
|
||||||
"Match", remote_side=[id], foreign_keys=[winner_next_match_id]
|
ref_team: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[ref_team_id])
|
||||||
)
|
|
||||||
loser_next_match: Mapped["Match"] = relationship(
|
winner_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[winner_next_match_id])
|
||||||
"Match", remote_side=[id], foreign_keys=[loser_next_match_id]
|
loser_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[loser_next_match_id])
|
||||||
)
|
|
||||||
@@ -50,6 +50,10 @@ class MatchOut(BaseModel):
|
|||||||
p2_team_id: int | None = None
|
p2_team_id: int | None = None
|
||||||
winner_team_id: int | None = None
|
winner_team_id: int | None = None
|
||||||
|
|
||||||
|
ref_team_id: int | None = None
|
||||||
|
ref_label: str | None = None
|
||||||
|
ref_team: TeamSchema | None = None
|
||||||
|
|
||||||
winner_next_match_id: str | None = None
|
winner_next_match_id: str | None = None
|
||||||
winner_next_match_slot: int | None = None
|
winner_next_match_slot: int | None = None
|
||||||
loser_next_match_id: str | None = None
|
loser_next_match_id: str | None = None
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=admin
|
ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
REF_USER=ref
|
||||||
|
REF_PASSWORD=ref
|
||||||
|
|
||||||
SECRET_KEY=PLEASE_REPLACE_ME_WITH_A_SECRET_KEY
|
SECRET_KEY=PLEASE_REPLACE_ME_WITH_A_SECRET_KEY
|
||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="src/assets/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
||||||
<title>VolleyManager</title>
|
<title>VolleyManager</title>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<body class="transition bg-zinc-50 dark:bg-zinc-950">
|
<body class="transition bg-zinc-50 dark:bg-zinc-950">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Generated
+5026
-542
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "tsc -b && eslint .",
|
"lint": "tsc -b && eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview --host",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -38,6 +38,8 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript-eslint": "^8.57.0",
|
"typescript-eslint": "^8.57.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vite-plugin-svgr": "^5.0.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 966 B After Width: | Height: | Size: 966 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor">
|
||||||
|
<path d="M400 64c8.8 0 16 7.2 16 16 0 17.7 14.3 32 32 32s32-14.3 32-32c0-8.8 7.2-16 16-16l112 0c17.7 0 32 14.3 32 32l0 70.3c0 15-10.4 28-25.1 31.2L413.8 242.2c1.4 9.7 2.2 19.6 2.2 29.8 0 114.9-93.1 208-208 208S0 386.9 0 272 93.1 64 208 64l192 0zM208 192a80 80 0 1 0 0 160 80 80 0 1 0 0-160z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 386 B |
@@ -1,6 +1,7 @@
|
|||||||
// frontend/src/components/Bracket/BracketView.tsx
|
// frontend/src/components/Bracket/BracketView.tsx
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
import { type MatchData } from '../../types';
|
import { type MatchData } from '../../types';
|
||||||
import Podium from "../Tournament/Podium";
|
import Podium from "../Tournament/Podium";
|
||||||
import MatchCard from "./MatchCard";
|
import MatchCard from "./MatchCard";
|
||||||
@@ -13,10 +14,19 @@ interface BracketViewProps {
|
|||||||
export default function BracketView({ matches, onMatchClick }: BracketViewProps) {
|
export default function BracketView({ matches, onMatchClick }: BracketViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [lines, setLines] = useState<React.ReactElement[]>([]);
|
const [lines, setLines] = useState<React.ReactElement[]>([]);
|
||||||
|
const [zoom, setZoom] = useState<number>(1);
|
||||||
|
const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// Measure the unscaled bracket size to fix scrollbars later
|
||||||
|
setContentSize({
|
||||||
|
width: containerRef.current.scrollWidth,
|
||||||
|
height: containerRef.current.scrollHeight
|
||||||
|
});
|
||||||
|
|
||||||
const container = containerRef.current.getBoundingClientRect();
|
const container = containerRef.current.getBoundingClientRect();
|
||||||
const newLines: React.ReactElement[] = [];
|
const newLines: React.ReactElement[] = [];
|
||||||
|
|
||||||
@@ -26,9 +36,13 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
|
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
|
||||||
if (sEl && eEl) {
|
if (sEl && eEl) {
|
||||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
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 sx = (r1.right - container.left) / zoom;
|
||||||
|
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
|
||||||
|
const ex = (r2.left - container.left) / zoom;
|
||||||
|
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
|
||||||
const c1 = sx + (ex - sx) / 2;
|
const c1 = sx + (ex - sx) / 2;
|
||||||
|
|
||||||
newLines.push(
|
newLines.push(
|
||||||
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:stroke-zinc-400! fill-none stroke-[2px]" />
|
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:stroke-zinc-400! fill-none stroke-[2px]" />
|
||||||
);
|
);
|
||||||
@@ -42,9 +56,13 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
const eEl = document.getElementById(`match-${targetMatch.id}`);
|
const eEl = document.getElementById(`match-${targetMatch.id}`);
|
||||||
if (sEl && eEl) {
|
if (sEl && eEl) {
|
||||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
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 sx = (r1.right - container.left) / zoom;
|
||||||
|
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
|
||||||
|
const ex = (r2.left - container.left) / zoom;
|
||||||
|
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
|
||||||
const c1 = sx + (ex - sx) / 2;
|
const c1 = sx + (ex - sx) / 2;
|
||||||
|
|
||||||
newLines.push(
|
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]" />
|
<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]" />
|
||||||
);
|
);
|
||||||
@@ -55,7 +73,7 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
setLines(newLines);
|
setLines(newLines);
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = setTimeout(draw, 100);
|
const t = setTimeout(draw, 50);
|
||||||
window.addEventListener('resize', draw);
|
window.addEventListener('resize', draw);
|
||||||
|
|
||||||
const handlePrint = () => { draw(); setTimeout(draw, 100); };
|
const handlePrint = () => { draw(); setTimeout(draw, 100); };
|
||||||
@@ -71,7 +89,7 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
window.removeEventListener('beforeprint', handlePrint);
|
window.removeEventListener('beforeprint', handlePrint);
|
||||||
window.removeEventListener('afterprint', draw);
|
window.removeEventListener('afterprint', draw);
|
||||||
};
|
};
|
||||||
}, [matches]);
|
}, [matches, zoom]);
|
||||||
|
|
||||||
const renderRound = (list: MatchData[]) => {
|
const renderRound = (list: MatchData[]) => {
|
||||||
const rounds: Record<number, MatchData[]> = {};
|
const rounds: Record<number, MatchData[]> = {};
|
||||||
@@ -109,44 +127,81 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="transition-colors w-full h-full overflow-auto print:overflow-visible print:h-auto print:w-auto bg-zinc-50 dark:bg-zinc-950 bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-white! print:bg-none!">
|
<div className="relative w-full h-full flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-950 print:bg-white!">
|
||||||
<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">
|
{/* FLOATING ZOOM CONTROLS (Moved to top-6 to completely avoid theme button) */}
|
||||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0 print:overflow-visible">
|
<div className="absolute top-6 right-6 flex flex-col gap-3 z-50 print:hidden">
|
||||||
{lines}
|
<button
|
||||||
</svg>
|
onClick={() => setZoom(z => Math.min(1, z + 0.1))}
|
||||||
|
disabled={zoom >= 1}
|
||||||
|
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
<ZoomIn size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}
|
||||||
|
disabled={zoom <= 0.3}
|
||||||
|
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
<ZoomOut size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-24">
|
<div className="flex-1 overflow-auto bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-none!">
|
||||||
<div className="relative">
|
<style>
|
||||||
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Winners Bracket</div>
|
{`@media print {
|
||||||
<div className="flex gap-20">{renderRound(displayWb)}</div>
|
@page { size: landscape; margin: 0.5cm; }
|
||||||
</div>
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: white !important; }
|
||||||
{isDoubleElim && (
|
}`}
|
||||||
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:border-zinc-400!">
|
</style>
|
||||||
<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">
|
{/* SIZER WRAPPER (Dynamically scales width/height to fix the scrollbar ghost space) */}
|
||||||
{displayFinals.length > 0 && (
|
<div style={{
|
||||||
<div className="relative flex flex-col gap-6">
|
width: contentSize.width ? `${contentSize.width * zoom}px` : 'max-content',
|
||||||
<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">
|
height: contentSize.height ? `${contentSize.height * zoom}px` : 'max-content'
|
||||||
Championship
|
}}>
|
||||||
|
{/* SCALING WRAPPER */}
|
||||||
|
<div
|
||||||
|
className="origin-top-left print:transform-none! w-max h-max"
|
||||||
|
style={{ transform: `scale(${zoom})` }}
|
||||||
|
>
|
||||||
|
{/* Added pt-16 md:pt-20 to ensure absolute titles aren't clipped
|
||||||
|
*/}
|
||||||
|
<div ref={containerRef} className="relative min-w-max min-h-full pt-16 md:pt-20 px-6 md:px-12 pb-40 md:pb-32 flex gap-12 md:gap-20 items-center">
|
||||||
|
<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 print:text-black!">Winners Bracket</div>
|
||||||
|
<div className="flex gap-20">{renderRound(displayWb)}</div>
|
||||||
|
</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">
|
||||||
|
{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>
|
||||||
{displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-center gap-6 z-10">
|
|
||||||
<Podium matches={matches} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import { type MatchData } from '../../types';
|
import { type MatchData } from '../../types';
|
||||||
import { printName, stringToColor } from '../../utils/helpers';
|
import { printName, stringToColor } from '../../utils/helpers';
|
||||||
|
import WhistleIcon from "../../assets/whistle.svg?react"
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: MatchData;
|
match: MatchData;
|
||||||
@@ -21,6 +22,8 @@ export default function MatchCard({ match, onClick }: MatchCardProps) {
|
|||||||
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
|
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
|
||||||
: 'cursor-default opacity-100';
|
: 'cursor-default opacity-100';
|
||||||
|
|
||||||
|
const refName = match.ref_team?.name || match.ref_label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`match-${match.id}`}
|
id={`match-${match.id}`}
|
||||||
@@ -43,7 +46,7 @@ export default function MatchCard({ match, onClick }: MatchCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2 space-y-1.5">
|
<div className="p-2 space-y-1.5 flex-1 flex flex-col justify-center">
|
||||||
{[
|
{[
|
||||||
{ 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.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 }
|
{ 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 }
|
||||||
@@ -66,6 +69,14 @@ export default function MatchCard({ match, onClick }: MatchCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* --- FLOATING REF BADGE --- */}
|
||||||
|
{refName && !isFinished && (
|
||||||
|
<div className="absolute -bottom-2.5 left-1/2 -translate-x-1/2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 text-[8px] font-black uppercase tracking-widest px-2.5 py-0.5 rounded-full shadow-sm whitespace-nowrap z-20 flex items-center gap-1.5 print:hidden max-w-[90%]">
|
||||||
|
<WhistleIcon className="text-orange-500 shrink-0" width={12} height={12} />
|
||||||
|
<span className="truncate">{refName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { CheckCircle, Pencil, Plus, Trophy } from 'lucide-react';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { type MatchData } from '../../types';
|
import { type MatchData } from '../../types';
|
||||||
import { printName, stringToColor } from '../../utils/helpers';
|
import { printName, stringToColor } from '../../utils/helpers';
|
||||||
|
import WhistleIcon from "../../assets/whistle.svg?react"
|
||||||
|
|
||||||
interface ScheduleRowProps {
|
interface ScheduleRowProps {
|
||||||
match: MatchData;
|
match: MatchData;
|
||||||
@@ -14,9 +15,10 @@ interface ScheduleRowProps {
|
|||||||
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: ScheduleRowProps) {
|
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: ScheduleRowProps) {
|
||||||
const courtColor = stringToColor(m.court);
|
const courtColor = stringToColor(m.court);
|
||||||
const isFinished = m.isFinished;
|
const isFinished = m.isFinished;
|
||||||
|
const refName = m.ref_team?.name || m.ref_label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="transition-colors 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 hover:border-orange-500/30">
|
<div className="relative mb-5 transition-colors 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 hover:border-orange-500/30">
|
||||||
<div className="flex gap-4 md:gap-6 print:gap-6 flex-1 min-w-0">
|
<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="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-lg md:text-xl print:text-xl font-black font-mono text-zinc-900 dark:text-white print:text-black!">{m.time}</div>
|
||||||
@@ -42,7 +44,10 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: Sche
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="transition-colors 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 className="flex flex-col md:flex-row md:items-center gap-0.5 md:gap-2 mt-0.5 md:mt-1">
|
||||||
|
<div className="transition-colors 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,19 +59,14 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: Sche
|
|||||||
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-8 md:min-w-20 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
|
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-8 md:min-w-20 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
|
||||||
title="Edit Score"
|
title="Edit Score"
|
||||||
>
|
>
|
||||||
{/* --- MOBILE VERTICAL STACK --- */}
|
|
||||||
<div className={`${m.winnerName === m.p1 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
|
<div className={`${m.winnerName === m.p1 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
|
||||||
{m.p1_sets}
|
{m.p1_sets}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clean minimal vertical line for mobile */}
|
|
||||||
<div className="w-0.5 h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
|
<div className="w-0.5 h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
|
||||||
|
|
||||||
<div className={`${m.winnerName === m.p2 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
|
<div className={`${m.winnerName === m.p2 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
|
||||||
{m.p2_sets}
|
{m.p2_sets}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* --- DESKTOP VIEW (Default + Hover Edit) --- */}
|
|
||||||
<div className="hidden md:flex group-hover/btn:hidden flex-col items-end">
|
<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">
|
<div className="text-orange-500 font-black text-[10px] uppercase flex items-center gap-1">
|
||||||
<CheckCircle size={12} strokeWidth={3} /> Finished
|
<CheckCircle size={12} strokeWidth={3} /> Finished
|
||||||
@@ -102,6 +102,13 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: Sche
|
|||||||
{isFinished ? m.p2_sets : ''}
|
{isFinished ? m.p2_sets : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{refName && !isFinished && (
|
||||||
|
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-300 text-[9px] md:text-[10px] font-black uppercase tracking-widest px-3 md:px-4 py-1 rounded-full shadow-md whitespace-nowrap z-20 flex items-center gap-1.5 print:hidden max-w-[90%]">
|
||||||
|
<WhistleIcon className="text-orange-500 shrink-0" width={12} height={12} />
|
||||||
|
<span className="truncate">{refName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function ScheduleView({ schedule, onMatchClick }: ScheduleViewPro
|
|||||||
const badgeWidth = Math.max(80, longestCourt.length * 10);
|
const badgeWidth = Math.max(80, longestCourt.length * 10);
|
||||||
|
|
||||||
const filteredAndSorted = schedule
|
const filteredAndSorted = schedule
|
||||||
.filter(m => (m.p1 + m.p2 + m.number + `Match #${m.number}`).toLowerCase().includes(filter.toLowerCase()))
|
.filter(m => (m.p1 + m.p2 + m.number + `Match #${m.number}` + m.ref_label).toLowerCase().includes(filter.toLowerCase()))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const timeA = a.start_time || a.timestamp || a.time || "";
|
const timeA = a.start_time || a.timestamp || a.time || "";
|
||||||
const timeB = b.start_time || b.timestamp || b.time || "";
|
const timeB = b.start_time || b.timestamp || b.time || "";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { type SetData } from '../../types';
|
|||||||
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
|
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Modal from '../UI/Modal';
|
import Modal from '../UI/Modal';
|
||||||
|
import WhistleIcon from "../../assets/whistle.svg?react"
|
||||||
|
|
||||||
interface MatchData {
|
interface MatchData {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -15,6 +15,8 @@ interface MatchData {
|
|||||||
p1_label?: string;
|
p1_label?: string;
|
||||||
p2?: string;
|
p2?: string;
|
||||||
p2_label?: string;
|
p2_label?: string;
|
||||||
|
ref_label?: string;
|
||||||
|
ref_team?: { name: string };
|
||||||
isFinished: boolean;
|
isFinished: boolean;
|
||||||
sets?: SetData[];
|
sets?: SetData[];
|
||||||
}
|
}
|
||||||
@@ -30,6 +32,7 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
|||||||
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
|
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const refName = match.ref_team?.name || match.ref_label;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -52,17 +55,29 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Top Info Bar */}
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors">
|
{/* Time & Court Row */}
|
||||||
<div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
|
<div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors">
|
||||||
<Clock className="text-orange-500" size={18} />
|
<div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
|
||||||
<span>{match.time || "10:00"}</span>
|
<Clock className="text-orange-500" size={16} />
|
||||||
</div>
|
<span>{match.time || "10:00"}</span>
|
||||||
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
|
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
|
||||||
<MapPin className="text-orange-500" size={18} />
|
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
|
||||||
<span>{match.court || "TBD"}</span>
|
<MapPin className="text-orange-500" size={16} />
|
||||||
|
<span>{match.court || "TBD"}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dedicated Ref Row (Always visible) */}
|
||||||
|
{refName && (
|
||||||
|
<div className="flex justify-center items-center gap-2 bg-orange-50 dark:bg-orange-900/10 p-2.5 rounded-lg border border-orange-100 dark:border-orange-900/30">
|
||||||
|
<WhistleIcon className="text-orange-500 shrink-0" width={14} height={14} />
|
||||||
|
<span className="text-xs font-bold text-orange-700 dark:text-orange-500 uppercase tracking-wide truncate">
|
||||||
|
{refName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface MatchData {
|
|||||||
loser_next_match_id?: string | number | null;
|
loser_next_match_id?: string | number | null;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
start_time?: string;
|
start_time?: string;
|
||||||
|
ref_label?: string;
|
||||||
|
ref_team?: { id: string | number; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetData {
|
export interface SetData {
|
||||||
|
|||||||
Vendored
+1
@@ -1,3 +1,4 @@
|
|||||||
// frontend/src/vite-env.d.ts
|
// frontend/src/vite-env.d.ts
|
||||||
|
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-svgr/client" />
|
||||||
+35
-1
@@ -3,11 +3,45 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import svgr from 'vite-plugin-svgr';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss()
|
tailwindcss(),
|
||||||
|
svgr(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'VolleyManager',
|
||||||
|
short_name: 'VolleyManager',
|
||||||
|
description: 'Tournament Operations Manager',
|
||||||
|
theme_color: '#09090b',
|
||||||
|
background_color: '#fafafa',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user