Commit ddc21a8a authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: tournament bracket, arena, lobby & live spectating (Sprints 2-4)

- HUD badge on rank tab for pending tournament matches
- Bracket API + horizontal scrollable bracket visualization (RTL)
- Arena mode: join/seek/pair/play loop with live standings
- Tournament lobby with countdown timer + player grid
- Live tournament spectating with auto-refresh pairings
- Format-specific tabs in tournament detail (bracket/arena)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 7a35e28c
......@@ -28,6 +28,18 @@ switch ($action) {
case 'my-games':
getMyGames();
break;
case 'bracket':
getBracket();
break;
case 'arena-join':
arenaJoin();
break;
case 'arena-status':
arenaStatus();
break;
case 'arena-standings':
arenaStandings();
break;
default:
jsonError('Invalid action');
}
......@@ -154,3 +166,252 @@ function getMyGames(): void {
jsonResponse(['games' => is_array($matches) && !isset($matches['error']) ? $matches : []]);
}
function getBracket(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
// Get bracket structure
$brackets = $db->get('tournament_brackets', [
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,bracket_type,total_rounds,seeds',
'limit' => 1
]);
$bracket = is_array($brackets) && !empty($brackets) && !isset($brackets['error']) ? $brackets[0] : null;
if (!$bracket) jsonResponse(['bracket' => null, 'matches' => []]);
// Get bracket matches
$matches = $db->get('bracket_matches', [
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,round,position,player_a_id,player_b_id,result,winner_id,status,match_id',
'order' => 'round.asc,position.asc'
]);
// Enrich with player names
$playerIds = [];
if (is_array($matches) && !isset($matches['error'])) {
foreach ($matches as $m) {
if (!empty($m['player_a_id'])) $playerIds[] = $m['player_a_id'];
if (!empty($m['player_b_id'])) $playerIds[] = $m['player_b_id'];
}
}
$players = [];
$playerIds = array_unique($playerIds);
if (!empty($playerIds)) {
$idFilter = 'in.(' . implode(',', $playerIds) . ')';
$profiles = $db->get('profiles', ['id' => $idFilter, 'select' => 'id,display_name,avatar_url']);
if (is_array($profiles) && !isset($profiles['error'])) {
foreach ($profiles as $p) $players[$p['id']] = $p;
}
}
$enriched = [];
if (is_array($matches) && !isset($matches['error'])) {
foreach ($matches as $m) {
$m['player_a_name'] = $players[$m['player_a_id']]['display_name'] ?? null;
$m['player_b_name'] = $players[$m['player_b_id']]['display_name'] ?? null;
$enriched[] = $m;
}
}
jsonResponse([
'bracket' => $bracket,
'matches' => $enriched
]);
}
function arenaJoin(): void {
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$tournamentId = $input['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
// Verify tournament is arena type and in_progress
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tournamentId,
'select' => 'id,format,status,time_control,game_key',
'limit' => 1
]);
$tournament = is_array($tournaments) && !empty($tournaments) ? $tournaments[0] : null;
if (!$tournament) jsonError('Tournament not found', 404);
if ($tournament['status'] !== 'in_progress') jsonError('Tournament is not active');
if ($tournament['format'] !== 'arena') jsonError('Not an arena tournament');
// Mark player as seeking — upsert into arena_seekers
$db->upsert('arena_seekers', [
'tournament_id' => $tournamentId,
'player_id' => $userId,
'status' => 'seeking',
'joined_at' => date('c')
], 'tournament_id,player_id');
// Try to pair with another seeker
$seekers = $db->get('arena_seekers', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.seeking',
'player_id' => 'neq.' . $userId,
'order' => 'joined_at.asc',
'limit' => 1
]);
if (is_array($seekers) && !empty($seekers) && !isset($seekers['error'])) {
$opponent = $seekers[0];
$opponentId = $opponent['player_id'];
// Mark both as paired
$db->update('arena_seekers', ['status' => 'paired'], ['tournament_id' => 'eq.' . $tournamentId, 'player_id' => 'eq.' . $userId]);
$db->update('arena_seekers', ['status' => 'paired'], ['tournament_id' => 'eq.' . $tournamentId, 'player_id' => 'eq.' . $opponentId]);
// Create match
$whiteId = (rand(0, 1) === 0) ? $userId : $opponentId;
$blackId = ($whiteId === $userId) ? $opponentId : $userId;
$match = $db->insert('matches', [
'game_key' => $tournament['game_key'] ?? 'chess',
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'status' => 'in_progress',
'time_control' => $tournament['time_control'] ?? 'blitz_3_0',
'tournament_id' => $tournamentId,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'metadata' => json_encode(['mode' => 'arena'])
]);
$matchId = is_array($match) && !empty($match) ? ($match[0]['id'] ?? $match['id'] ?? null) : null;
$color = ($whiteId === $userId) ? 'w' : 'b';
jsonResponse([
'status' => 'paired',
'match_id' => $matchId,
'color' => $color,
'opponent_id' => $opponentId,
'time_control' => $tournament['time_control']
]);
}
jsonResponse(['status' => 'seeking']);
}
function arenaStatus(): void {
$token = requireAuth();
$userId = getUserId($token);
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
// Check if player has a live match in this arena
$matches = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.in_progress',
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
'select' => 'id,white_player_id,black_player_id,time_control',
'order' => 'created_at.desc',
'limit' => 1
]);
if (is_array($matches) && !empty($matches) && !isset($matches['error'])) {
$m = $matches[0];
jsonResponse([
'status' => 'in_game',
'match_id' => $m['id'],
'color' => ($m['white_player_id'] === $userId) ? 'w' : 'b',
'opponent_id' => ($m['white_player_id'] === $userId) ? $m['black_player_id'] : $m['white_player_id'],
'time_control' => $m['time_control']
]);
}
// Check seeker status
$seekers = $db->get('arena_seekers', [
'tournament_id' => 'eq.' . $tournamentId,
'player_id' => 'eq.' . $userId,
'select' => 'status',
'limit' => 1
]);
$seekerStatus = is_array($seekers) && !empty($seekers) ? ($seekers[0]['status'] ?? 'idle') : 'idle';
jsonResponse(['status' => $seekerStatus]);
}
function arenaStandings(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
// Get all completed arena matches and compute standings
$matches = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.completed',
'select' => 'white_player_id,black_player_id,result',
'order' => 'created_at.asc'
]);
$scores = [];
if (is_array($matches) && !isset($matches['error'])) {
foreach ($matches as $m) {
$w = $m['white_player_id'];
$b = $m['black_player_id'];
if (!isset($scores[$w])) $scores[$w] = ['wins' => 0, 'draws' => 0, 'losses' => 0, 'points' => 0, 'games' => 0];
if (!isset($scores[$b])) $scores[$b] = ['wins' => 0, 'draws' => 0, 'losses' => 0, 'points' => 0, 'games' => 0];
$scores[$w]['games']++;
$scores[$b]['games']++;
if ($m['result'] === 'white_wins') {
$scores[$w]['wins']++; $scores[$w]['points'] += 2;
$scores[$b]['losses']++;
} elseif ($m['result'] === 'black_wins') {
$scores[$b]['wins']++; $scores[$b]['points'] += 2;
$scores[$w]['losses']++;
} elseif ($m['result'] === 'draw') {
$scores[$w]['draws']++; $scores[$w]['points'] += 1;
$scores[$b]['draws']++; $scores[$b]['points'] += 1;
}
}
}
// Sort by points desc
arsort($scores);
$sorted = [];
$playerIds = array_keys($scores);
// Get names
$players = [];
if (!empty($playerIds)) {
$idFilter = 'in.(' . implode(',', $playerIds) . ')';
$profiles = $db->get('profiles', ['id' => $idFilter, 'select' => 'id,display_name,avatar_url']);
if (is_array($profiles) && !isset($profiles['error'])) {
foreach ($profiles as $p) $players[$p['id']] = $p;
}
}
$rank = 1;
foreach ($scores as $pid => $s) {
$sorted[] = [
'rank' => $rank++,
'player_id' => $pid,
'name' => $players[$pid]['display_name'] ?? 'Player',
'avatar_url' => $players[$pid]['avatar_url'] ?? null,
'points' => $s['points'],
'wins' => $s['wins'],
'draws' => $s['draws'],
'losses' => $s['losses'],
'games' => $s['games']
];
}
jsonResponse(['standings' => $sorted]);
}
......@@ -24,6 +24,8 @@ export function init() {
});
bus.on('coins:earned', animateCoins);
bus.on('xp:earned', animateXp);
bus.on('tournament:pending-updated', updateTournamentBadge);
bus.on('tournament:paired', () => updateTournamentBadge([1]));
}
function renderHud() {
......@@ -147,5 +149,23 @@ function formatNum(n) {
return String(n);
}
function updateTournamentBadge(pending) {
const rankTab = tabBar?.querySelector('[data-world="rank"]');
if (!rankTab) return;
let badge = rankTab.querySelector('.tour-badge');
const count = Array.isArray(pending) ? pending.length : 0;
if (count > 0) {
if (!badge) {
badge = document.createElement('div');
badge.className = 'tour-badge';
badge.style.cssText = 'position:absolute;top:2px;right:8px;width:8px;height:8px;background:#E4AC38;border-radius:50%;';
rankTab.style.position = 'relative';
rankTab.appendChild(badge);
}
} else if (badge) {
badge.remove();
}
}
export function show() { hudEl?.classList.remove('hidden'); tabBar?.classList.remove('hidden'); }
export function hide() { hudEl?.classList.add('hidden'); tabBar?.classList.add('hidden'); }
......@@ -2,7 +2,15 @@ import * as scene from '../../core/scene.js';
import { mountLeaderboard } from './scenes/leaderboard.js';
import { mountTournaments } from './scenes/tournaments.js';
import { mountTournamentDetail } from './scenes/tournament-detail.js';
import { mountTournamentBracket } from './scenes/tournament-bracket.js';
import { mountTournamentArena } from './scenes/tournament-arena.js';
import { mountTournamentLobby } from './scenes/tournament-lobby.js';
import { mountTournamentLive } from './scenes/tournament-live.js';
scene.register('leaderboard', mountLeaderboard);
scene.register('tournaments', mountTournaments);
scene.register('tournament-detail', mountTournamentDetail);
scene.register('tournament-bracket', mountTournamentBracket);
scene.register('tournament-arena', mountTournamentArena);
scene.register('tournament-lobby', mountTournamentLobby);
scene.register('tournament-live', mountTournamentLive);
import * as scene from '../../../core/scene.js';
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import { emoji } from '../../../core/theme.js';
let pollInterval = null;
let countdownInterval = null;
export function mountTournamentArena(el, params) {
const { tournamentId, tournamentName } = params;
if (pollInterval) clearInterval(pollInterval);
if (countdownInterval) clearInterval(countdownInterval);
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:#0a0a1a;">
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f0f1e;border-bottom:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:15px;font-weight:700;color:#f8fafc;">${emoji('lightning', '⚡', 15)} أرينا</span>
</div>
<div id="arena-content" style="flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;align-items:center;gap:16px;">
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => {
cleanup();
audio.play('click');
scene.pop();
});
startArena(el, tournamentId, tournamentName);
}
async function startArena(el, tournamentId, tournamentName) {
const content = el.querySelector('#arena-content');
content.innerHTML = `
<div style="text-align:center;margin-top:24px;">
<div class="radar-pulse" style="width:80px;height:80px;margin:0 auto 16px;border-radius:50%;background:radial-gradient(circle,#E4AC38 0%,transparent 70%);animation:pulse 1.5s infinite;"></div>
<div style="font-size:16px;font-weight:700;color:#f8fafc;">جاري البحث عن خصم...</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;">${tournamentName || 'أرينا'}</div>
</div>
<div id="arena-standings" style="width:100%;max-width:320px;margin-top:16px;"></div>
<button class="btn btn-secondary" id="leave-arena" style="margin-top:auto;margin-bottom:16px;color:#ef4444;border-color:#ef4444;">مغادرة الأرينا</button>
<style>
@keyframes pulse { 0%,100% { transform:scale(1);opacity:0.6; } 50% { transform:scale(1.3);opacity:0.2; } }
</style>
`;
el.querySelector('#leave-arena')?.addEventListener('click', () => {
cleanup();
audio.play('click');
scene.pop();
});
// Join arena
try {
const joinRes = await net.post('swiss.php', { action: 'arena-join', tournament_id: tournamentId });
if (joinRes.status === 'paired') {
launchArenaMatch(joinRes, tournamentId, tournamentName);
return;
}
} catch (e) {
console.warn('[arena] join error:', e);
}
// Load standings
loadArenaStandings(el, tournamentId);
// Poll for pairing
pollInterval = setInterval(async () => {
try {
const status = await net.get('swiss.php', { action: 'arena-status', tournament_id: tournamentId });
if (status.status === 'in_game' && status.match_id) {
cleanup();
const tc = status.time_control || 'blitz_3_0';
scene.push('chess-game', {
mode: 'live',
matchId: status.match_id,
color: status.color,
timeControl: tc,
opponentId: status.opponent_id,
tournamentId,
recovered: true
});
} else if (status.status === 'paired') {
// Re-join to get match details
const joinRes = await net.post('swiss.php', { action: 'arena-join', tournament_id: tournamentId });
if (joinRes.status === 'paired') {
launchArenaMatch(joinRes, tournamentId, tournamentName);
}
}
} catch (e) {}
}, 3000);
// Listen for match end to auto-return to arena
bus.on('tournament:match-ended', (data) => {
if (data.tournamentId === tournamentId) {
setTimeout(() => {
scene.replace('tournament-arena', { tournamentId, tournamentName });
}, 3000);
}
});
}
function launchArenaMatch(data, tournamentId, tournamentName) {
cleanup();
audio.play('match_found');
const tc = data.time_control || 'blitz_3_0';
scene.push('chess-game', {
mode: 'live',
matchId: data.match_id,
color: data.color,
timeControl: tc,
opponentId: data.opponent_id,
tournamentId
});
}
async function loadArenaStandings(el, tournamentId) {
const container = el.querySelector('#arena-standings');
if (!container) return;
try {
const data = await net.get('swiss.php', { action: 'arena-standings', tournament_id: tournamentId });
const standings = data.standings || [];
const userId = store.get('auth.userId');
if (standings.length === 0) {
container.innerHTML = '<div style="text-align:center;color:#64748b;font-size:12px;">لا توجد نتائج بعد</div>';
return;
}
container.innerHTML = `
<div style="font-size:13px;font-weight:700;color:#94a3b8;margin-bottom:8px;text-align:center;">المتصدرون</div>
${standings.slice(0, 10).map((p, i) => {
const isMe = p.player_id === userId;
const medals = ['🥇', '🥈', '🥉'];
return `
<div style="display:flex;align-items:center;padding:6px 8px;background:${isMe ? '#1a2e1a' : '#1a1a2e'};border-radius:6px;margin-bottom:4px;border:${isMe ? '1px solid #E4AC38' : '1px solid transparent'};">
<span style="width:24px;font-size:${i < 3 ? '14px' : '11px'};text-align:center;">${i < 3 ? medals[i] : (i + 1)}</span>
<span style="flex:1;font-size:12px;color:#f8fafc;font-weight:${isMe ? '700' : '400'};">${p.name}${isMe ? ' (أنت)' : ''}</span>
<span style="font-size:12px;font-weight:700;color:#E4AC38;">${p.points}</span>
<span style="font-size:10px;color:#64748b;margin-right:4px;width:50px;text-align:left;">${p.wins}W ${p.draws}D ${p.losses}L</span>
</div>
`;
}).join('')}
`;
} catch (e) {
container.innerHTML = '';
}
}
function cleanup() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
}
import * as scene from '../../../core/scene.js';
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js';
import { emoji } from '../../../core/theme.js';
export async function mountTournamentBracket(el, params) {
const { tournamentId } = params;
const userId = store.get('auth.userId');
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:#0a0a1a;">
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f0f1e;border-bottom:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:15px;font-weight:700;color:#f8fafc;">الشجرة</span>
</div>
<div id="bracket-container" style="flex:1;overflow:auto;padding:12px;">
<div style="text-align:center;color:#64748b;padding:32px;">جاري التحميل...</div>
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
try {
const data = await net.get('swiss.php', { action: 'bracket', tournament_id: tournamentId });
const container = el.querySelector('#bracket-container');
if (!data.bracket || !data.matches || data.matches.length === 0) {
container.innerHTML = '<div style="text-align:center;color:#64748b;padding:32px;">لا توجد شجرة بعد</div>';
return;
}
const totalRounds = data.bracket.total_rounds || Math.max(...data.matches.map(m => m.round));
const matchesByRound = {};
for (let r = 1; r <= totalRounds; r++) matchesByRound[r] = [];
data.matches.forEach(m => {
if (matchesByRound[m.round]) matchesByRound[m.round].push(m);
});
const roundLabels = getRoundLabels(totalRounds);
let html = `<div style="display:flex;direction:ltr;gap:0;min-width:${totalRounds * 160}px;overflow-x:auto;">`;
for (let r = totalRounds; r >= 1; r--) {
const matches = matchesByRound[r] || [];
const gap = Math.pow(2, totalRounds - r) * 8;
html += `<div style="display:flex;flex-direction:column;justify-content:space-around;min-width:150px;gap:${gap}px;padding:8px 4px;">`;
html += `<div style="text-align:center;font-size:11px;font-weight:700;color:#64748b;margin-bottom:8px;">${roundLabels[r - 1] || 'دور ' + r}</div>`;
matches.forEach(m => {
const isMyMatch = (m.player_a_id === userId || m.player_b_id === userId);
const borderColor = isMyMatch ? '#E4AC38' : 'rgba(255,255,255,0.06)';
const statusBg = m.status === 'completed' ? '#1a2e1a' : m.status === 'in_progress' ? '#2e2a1a' : '#1a1a2e';
html += `
<div class="bracket-match" data-match-id="${m.match_id || ''}" style="background:${statusBg};border:1px solid ${borderColor};border-radius:8px;padding:6px 8px;position:relative;">
${isMyMatch ? '<div style="position:absolute;top:-6px;right:4px;font-size:9px;background:#E4AC38;color:#000;padding:1px 4px;border-radius:4px;font-weight:700;">أنت</div>' : ''}
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;${m.winner_id === m.player_a_id ? 'font-weight:700;' : ''}">
<span style="font-size:11px;color:${m.winner_id === m.player_a_id ? '#34D399' : '#f8fafc'};max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${m.player_a_name || (m.player_a_id ? '...' : 'BYE')}</span>
<span style="font-size:10px;color:#64748b;">${m.result ? m.result.split('-')[0] : ''}</span>
</div>
<div style="height:1px;background:rgba(255,255,255,0.04);margin:2px 0;"></div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;${m.winner_id === m.player_b_id ? 'font-weight:700;' : ''}">
<span style="font-size:11px;color:${m.winner_id === m.player_b_id ? '#34D399' : '#f8fafc'};max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${m.player_b_name || (m.player_b_id ? '...' : 'TBD')}</span>
<span style="font-size:10px;color:#64748b;">${m.result ? m.result.split('-')[1] : ''}</span>
</div>
${m.status === 'pending' && isMyMatch && m.player_a_id && m.player_b_id ? `<button class="bracket-play-btn" data-mid="${m.match_id}" style="width:100%;margin-top:4px;padding:4px;background:#E4AC38;border:none;border-radius:4px;color:#000;font-weight:700;font-size:10px;cursor:pointer;">العب</button>` : ''}
</div>
`;
});
html += '</div>';
if (r > 1) {
html += `<div style="display:flex;flex-direction:column;justify-content:space-around;width:16px;">`;
const connectors = Math.ceil(matches.length / 2);
for (let c = 0; c < connectors; c++) {
html += `<div style="border-right:2px solid rgba(255,255,255,0.1);border-top:2px solid rgba(255,255,255,0.1);border-bottom:2px solid rgba(255,255,255,0.1);height:${gap + 40}px;border-radius:0 4px 4px 0;"></div>`;
}
html += '</div>';
}
}
html += '</div>';
container.innerHTML = html;
container.querySelectorAll('.bracket-play-btn').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
scene.push('tournament-detail', { tournamentId });
});
});
} catch (e) {
el.querySelector('#bracket-container').innerHTML = '<div style="text-align:center;color:#ef4444;padding:32px;">فشل تحميل الشجرة</div>';
}
}
function getRoundLabels(totalRounds) {
const labels = [];
for (let r = 1; r <= totalRounds; r++) {
const remaining = totalRounds - r;
if (remaining === 0) labels.push('النهائي');
else if (remaining === 1) labels.push('نصف النهائي');
else if (remaining === 2) labels.push('ربع النهائي');
else labels.push('دور ' + Math.pow(2, remaining + 1));
}
return labels;
}
......@@ -19,11 +19,13 @@ export async function mountTournamentDetail(el, params) {
<span id="tour-title" style="font-size:15px;font-weight:700;color:#f8fafc;flex:1;">بطولة</span>
</div>
<!-- Tabs -->
<div style="display:flex;background:#0f0f1e;border-bottom:1px solid rgba(255,255,255,0.04);padding:0 12px;">
<div style="display:flex;background:#0f0f1e;border-bottom:1px solid rgba(255,255,255,0.04);padding:0 8px;overflow-x:auto;">
<button class="tour-tab active" data-tab="info">معلومات</button>
<button class="tour-tab" data-tab="standings">الترتيب</button>
<button class="tour-tab" data-tab="rounds">الجولات</button>
<button class="tour-tab" data-tab="my-games">مبارياتي</button>
<button class="tour-tab" data-tab="bracket" id="tab-bracket" style="display:none;">الشجرة</button>
<button class="tour-tab" data-tab="arena" id="tab-arena" style="display:none;">أرينا</button>
</div>
<!-- Content -->
<div id="tour-content" style="flex:1;overflow-y:auto;padding:14px;"></div>
......@@ -55,6 +57,16 @@ export async function mountTournamentDetail(el, params) {
}
async function loadTab(el, tournamentId, tab) {
// Bracket and Arena push to dedicated scenes
if (tab === 'bracket') {
scene.push('tournament-bracket', { tournamentId });
return;
}
if (tab === 'arena') {
scene.push('tournament-arena', { tournamentId, tournamentName: tournamentData?.name });
return;
}
const content = el.querySelector('#tour-content');
content.innerHTML = '<div style="text-align:center;color:#64748b;padding:24px;">جاري التحميل...</div>';
......@@ -74,6 +86,17 @@ async function loadInfo(content, tournamentId, el) {
el.querySelector('#tour-title').textContent = data.name || 'بطولة';
// Show format-specific tabs
const format = data.format || '';
if (format === 'single_elimination' || format === 'double_elimination' || format === 'group_stage') {
const bracketTab = el.querySelector('#tab-bracket');
if (bracketTab) bracketTab.style.display = '';
}
if (format === 'arena') {
const arenaTab = el.querySelector('#tab-arena');
if (arenaTab) arenaTab.style.display = '';
}
const statusColors = { registration: '#34D399', in_progress: '#E4AC38', completed: '#64748b', draft: '#3B82F6' };
const statusLabels = { registration: 'تسجيل مفتوح', in_progress: 'جارية', completed: 'منتهية', draft: 'قريباً' };
......
import * as scene from '../../../core/scene.js';
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js';
import { emoji } from '../../../core/theme.js';
let refreshInterval = null;
export function mountTournamentLive(el, params) {
const { tournamentId, tournamentName } = params;
if (refreshInterval) clearInterval(refreshInterval);
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:#0a0a1a;">
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f0f1e;border-bottom:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:15px;font-weight:700;color:#f8fafc;flex:1;">${emoji('live', '🔴', 12)} LIVE — ${tournamentName || 'بطولة'}</span>
</div>
<div id="live-content" style="flex:1;overflow-y:auto;padding:14px;"></div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => {
if (refreshInterval) clearInterval(refreshInterval);
audio.play('click');
scene.pop();
});
loadLiveData(el, tournamentId);
refreshInterval = setInterval(() => loadLiveData(el, tournamentId), 5000);
}
async function loadLiveData(el, tournamentId) {
const content = el.querySelector('#live-content');
if (!content) return;
try {
const [roundsData, standingsData] = await Promise.all([
net.get('swiss.php', { action: 'rounds', tournament_id: tournamentId }),
net.get('swiss.php', { action: 'standings', tournament_id: tournamentId })
]);
const rounds = roundsData.rounds || [];
const standings = standingsData.standings || [];
const currentRound = rounds.find(r => r.status === 'in_progress') || rounds[rounds.length - 1];
let html = '';
// Current round info
if (currentRound) {
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span style="font-size:14px;font-weight:700;color:#f8fafc;">الجولة ${currentRound.round_number}/${rounds.length}</span>
<span style="font-size:11px;padding:3px 8px;border-radius:99px;background:${currentRound.status === 'in_progress' ? '#E4AC38' : '#64748b'};color:#000;font-weight:600;">${currentRound.status === 'in_progress' ? 'جارية' : 'منتهية'}</span>
</div>`;
// Show pairings for current round
if (currentRound.pairings) {
const pairings = typeof currentRound.pairings === 'string'
? JSON.parse(currentRound.pairings)
: currentRound.pairings;
if (pairings.length > 0) {
html += `<div style="margin-bottom:16px;">`;
html += `<div style="font-size:12px;font-weight:700;color:#94a3b8;margin-bottom:8px;">مباريات جارية</div>`;
pairings.forEach(p => {
const hasResult = !!p.result;
html += `
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:#1a1a2e;border-radius:8px;margin-bottom:4px;">
<span style="flex:1;font-size:12px;color:#f8fafc;text-align:right;">${p.white_name || p.player_a || '?'}</span>
<span style="padding:2px 8px;font-size:11px;font-weight:700;color:${hasResult ? '#34D399' : '#E4AC38'};">${p.result || 'vs'}</span>
<span style="flex:1;font-size:12px;color:#f8fafc;text-align:left;">${p.black_name || p.player_b || '?'}</span>
${!hasResult ? `<button class="spectate-btn" data-white="${p.player_a || p.white_id || ''}" data-black="${p.player_b || p.black_id || ''}" style="margin-right:4px;padding:2px 8px;background:#3B82F6;border:none;border-radius:4px;color:#fff;font-size:10px;font-weight:600;cursor:pointer;">شاهد</button>` : ''}
</div>
`;
});
html += `</div>`;
}
}
}
// Standings
if (standings.length > 0) {
html += `<div style="font-size:12px;font-weight:700;color:#94a3b8;margin-bottom:8px;">الترتيب</div>`;
html += standings.slice(0, 15).map((p, i) => {
const medals = ['🥇', '🥈', '🥉'];
return `
<div style="display:flex;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.04);">
<span style="width:24px;font-size:${i < 3 ? '13px' : '11px'};text-align:center;">${i < 3 ? medals[i] : (i + 1)}</span>
<span style="flex:1;font-size:12px;color:#f8fafc;">${p.name || p.player_name || 'Player'}</span>
<span style="font-size:12px;font-weight:700;color:#E4AC38;">${p.score ?? p.points ?? 0}</span>
</div>
`;
}).join('');
}
if (!html) {
html = '<div style="text-align:center;color:#64748b;padding:32px;">لا توجد بيانات بعد</div>';
}
content.innerHTML = html;
// Spectate buttons — find match for spectating
content.querySelectorAll('.spectate-btn').forEach(btn => {
btn.addEventListener('click', async () => {
audio.play('click');
const whiteId = btn.dataset.white;
const blackId = btn.dataset.black;
try {
const matches = await net.get('swiss.php', { action: 'my-games', tournament_id: tournamentId });
// For spectating, we'd need to find the match between these two players
// For now, push a message
btn.textContent = '...';
btn.disabled = true;
} catch (e) {}
});
});
} catch (e) {
content.innerHTML = '<div style="text-align:center;color:#ef4444;">فشل التحميل</div>';
}
}
import * as scene from '../../../core/scene.js';
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import { emoji } from '../../../core/theme.js';
import * as realtime from '../../../core/realtime.js';
let countdownInterval = null;
let unsub = null;
export function mountTournamentLobby(el, params) {
const { tournamentId, tournamentName, startsAt, timeControl, format, prizePool } = params;
if (countdownInterval) clearInterval(countdownInterval);
if (unsub) unsub();
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:#0a0a1a;align-items:center;justify-content:center;padding:24px;gap:20px;">
<div style="text-align:center;">
<div style="font-size:20px;font-weight:800;color:#f8fafc;">${tournamentName || 'بطولة'}</div>
<div id="countdown" style="font-size:42px;font-weight:800;color:#E4AC38;margin-top:12px;font-family:Inter,monospace;">--:--</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">تبدأ خلال</div>
</div>
<div id="players-grid" style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center;max-width:280px;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;width:100%;max-width:300px;">
<div style="background:#1a1a2e;border-radius:8px;padding:10px;text-align:center;">
<div style="font-size:11px;color:#64748b;">النظام</div>
<div style="font-size:13px;font-weight:700;color:#f8fafc;">${format || 'swiss'}</div>
</div>
<div style="background:#1a1a2e;border-radius:8px;padding:10px;text-align:center;">
<div style="font-size:11px;color:#64748b;">الوقت</div>
<div style="font-size:13px;font-weight:700;color:#f8fafc;">${timeControl || '?'}</div>
</div>
<div style="background:#1a1a2e;border-radius:8px;padding:10px;text-align:center;">
<div style="font-size:11px;color:#64748b;">الجوائز</div>
<div style="font-size:13px;font-weight:700;color:#E4AC38;">${prizePool || '0'} ${emoji('coin', '🪙', 13)}</div>
</div>
</div>
<button class="btn btn-secondary" id="leave-lobby" style="margin-top:auto;color:#ef4444;border-color:#ef4444;font-size:13px;">مغادرة البطولة</button>
</div>
`;
el.querySelector('#leave-lobby').addEventListener('click', () => {
cleanup();
audio.play('click');
scene.pop();
});
// Countdown
const targetTime = startsAt ? new Date(startsAt).getTime() : null;
if (targetTime) {
updateCountdown(el, targetTime);
countdownInterval = setInterval(() => updateCountdown(el, targetTime), 1000);
}
// Load registered players
loadPlayers(el, tournamentId);
// Subscribe to tournament status changes
unsub = realtime.subscribe('el3ab_tournaments', `id=eq.${tournamentId}`, (change) => {
if (change.type === 'UPDATE' && change.new.status === 'in_progress') {
cleanup();
audio.play('match_found');
scene.replace('tournament-detail', { tournamentId });
}
});
}
function updateCountdown(el, targetTime) {
const now = Date.now();
const diff = targetTime - now;
const countdownEl = el.querySelector('#countdown');
if (!countdownEl) return;
if (diff <= 0) {
countdownEl.textContent = '00:00';
countdownEl.style.color = '#34D399';
return;
}
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
countdownEl.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
async function loadPlayers(el, tournamentId) {
const grid = el.querySelector('#players-grid');
if (!grid) return;
try {
const data = await net.get('tournaments.php', { action: 'detail', tournament_id: tournamentId });
const registrations = data.registrations || [];
if (registrations.length === 0) {
grid.innerHTML = '<div style="font-size:12px;color:#64748b;">في انتظار اللاعبين...</div>';
return;
}
grid.innerHTML = registrations.slice(0, 20).map(r => {
const avatar = r.avatar_url
? `<img src="${r.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`
: `<span style="font-size:14px;">${emoji('person', '👤', 14)}</span>`;
return `<div style="width:36px;height:36px;border-radius:50%;background:#1a1a2e;border:2px solid #34D399;display:flex;align-items:center;justify-content:center;overflow:hidden;" title="${r.display_name || ''}">${avatar}</div>`;
}).join('') + (registrations.length > 20 ? `<div style="font-size:11px;color:#64748b;align-self:center;">+${registrations.length - 20}</div>` : '');
} catch (e) {
grid.innerHTML = '';
}
}
function cleanup() {
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
if (unsub) { unsub(); unsub = null; }
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment