mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat: add world cup 2026 hub spa with bracket simulation and i18n
This commit is contained in:
parent
c7088bc31b
commit
39f5881d33
13 changed files with 3702 additions and 0 deletions
126
assets/js/modal.js
Normal file
126
assets/js/modal.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// modal.js — match detail modal built on the native <dialog> element
|
||||
// (focus trap, Esc-to-close and ::backdrop for free). Shows teams, time,
|
||||
// stadium, city, capacity, result (+penalties) and a placeholder section for
|
||||
// future stats (possession, shots, cards). "Add to calendar" lands in step 12.
|
||||
|
||||
import { getData, formatMatchTime, flagSrc } from './app.js';
|
||||
import { t, getLocale, translatePhase } from './i18n.js';
|
||||
import { resolveBracketTeams } from './bracket.js';
|
||||
import { getFavorites } from './storage.js';
|
||||
import { exportMatchToICS } from './calendar.js';
|
||||
|
||||
let dialog = null;
|
||||
let currentMatchId = null;
|
||||
let lastFocused = null;
|
||||
|
||||
export function initModal() {
|
||||
document.getElementById('modal-root').innerHTML =
|
||||
'<dialog class="match-modal" id="match-dialog"></dialog>';
|
||||
dialog = document.getElementById('match-dialog');
|
||||
|
||||
// a click on the dialog element itself (not its padded content) = backdrop
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
dialog.addEventListener('close', () => {
|
||||
currentMatchId = null;
|
||||
lastFocused?.focus?.();
|
||||
});
|
||||
const rerenderIfOpen = () => {
|
||||
if (dialog.open && currentMatchId) renderContent(currentMatchId);
|
||||
};
|
||||
document.addEventListener('langchange', rerenderIfOpen);
|
||||
document.addEventListener('favchange', rerenderIfOpen);
|
||||
document.addEventListener('timemodechange', rerenderIfOpen);
|
||||
}
|
||||
|
||||
export function openMatchModal(matchId) {
|
||||
lastFocused = document.activeElement;
|
||||
currentMatchId = matchId;
|
||||
renderContent(matchId);
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- render
|
||||
|
||||
function renderContent(matchId) {
|
||||
const { matches, resultByMatchId, stadiumByName } = getData();
|
||||
const match = matches.find((m) => m.id === matchId);
|
||||
if (!match) return;
|
||||
const result = resultByMatchId.get(matchId);
|
||||
const status = result?.status ?? 'scheduled';
|
||||
const stadium = stadiumByName.get(match.stadium);
|
||||
const slots = resolveBracketTeams(match);
|
||||
const numberFmt = new Intl.NumberFormat(getLocale());
|
||||
|
||||
const statusChip =
|
||||
status === 'live' ? `<span class="match-status live pulse">● ${t('hero.live')}</span>`
|
||||
: status === 'finished' ? `<span class="match-status finished">${t('status.finished')}</span>`
|
||||
: `<span class="match-status">${t('status.scheduled')}</span>`;
|
||||
|
||||
let center;
|
||||
if (status === 'finished' || status === 'live') {
|
||||
const pens = result.penalties
|
||||
? `<span class="match-pens">(${result.penalties.home}–${result.penalties.away} ${t('status.pens')})</span>`
|
||||
: '';
|
||||
center = `<div class="modal-score">${result.homeScore}<span class="match-score-sep">–</span>${result.awayScore}${pens}</div>`;
|
||||
} else {
|
||||
center = `<span class="hero-vs">${t('hero.vs')}</span>`;
|
||||
}
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-content slide-up" role="document">
|
||||
<header class="modal-top">
|
||||
<p class="modal-phase">${translatePhase(match.phase)} ${statusChip}</p>
|
||||
<button class="modal-close" data-close aria-label="${t('modal.close')}">✕</button>
|
||||
</header>
|
||||
<div class="modal-matchup">
|
||||
${teamHTML(slots.home)}
|
||||
${center}
|
||||
${teamHTML(slots.away)}
|
||||
</div>
|
||||
<dl class="modal-info">
|
||||
<div><dt>${t('modal.date')}</dt><dd>${formatMatchTime(match, stadium)}</dd></div>
|
||||
<div><dt>${t('modal.stadium')}</dt><dd>${match.stadium}</dd></div>
|
||||
<div><dt>${t('modal.city')}</dt><dd>${match.city}</dd></div>
|
||||
<div><dt>${t('stadiums.capacity')}</dt><dd>${stadium ? numberFmt.format(stadium.capacity) : '—'}</dd></div>
|
||||
</dl>
|
||||
<section class="modal-stats" aria-label="${t('modal.stats')}">
|
||||
<h3>${t('modal.stats')}</h3>
|
||||
${statRow(t('modal.possession'))}
|
||||
${statRow(t('modal.shots'))}
|
||||
${statRow(t('modal.cards'))}
|
||||
<p class="modal-stats-note">${t('modal.statsSoon')}</p>
|
||||
</section>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" id="modal-ics">${t('modal.addCalendar')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
dialog.setAttribute('aria-label', `${slots.home.label} ${t('hero.vs')} ${slots.away.label} — ${translatePhase(match.phase)}`);
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.querySelector('#modal-ics').addEventListener('click', () => exportMatchToICS(match, stadium));
|
||||
}
|
||||
|
||||
function teamHTML(slot) {
|
||||
if (!slot.team) return `<div class="hero-team"><span class="match-team-name tbd">${slot.label}</span></div>`;
|
||||
const fav = getFavorites().includes(slot.team.id);
|
||||
return `
|
||||
<div class="hero-team">
|
||||
<img class="flag" src="${flagSrc(slot.team)}" alt="" width="48" height="32">
|
||||
<span class="modal-team-name">${slot.team.name}
|
||||
<button class="fav-btn ${fav ? 'active' : ''}" data-fav="${slot.team.id}"
|
||||
aria-pressed="${fav}" aria-label="${t('fav.toggle')} ${slot.team.name}">${fav ? '★' : '☆'}</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// placeholder row — real values will replace the dashes when stats data exists
|
||||
function statRow(label) {
|
||||
return `
|
||||
<div class="modal-stat-row">
|
||||
<span>—</span>
|
||||
<span class="modal-stat-label">${label}</span>
|
||||
<span>—</span>
|
||||
</div>`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue