mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat(stats): degradation engine + scrollspy sub-nav (Stage A)
Scaffold the post-Cup stats screen (.agents/stats-screen-plan.md) on the feature/stats-final-screen branch. - loadData(): fault-tolerant optional data layers (players, player-events, awards, keeper-stats, curiosities, all-time-baselines) via loadOptional() — an absent/404 file defaults to empty SILENTLY (graceful degradation, keeps the console clean), warning only on a present-but-malformed file. The 6 core files still throw on failure. - stats.js SECTIONS registry: a section and its sub-nav chip render only when available(model) holds, else they are omitted from the DOM entirely (no placeholder / no coming-soon). Overview/Teams live; the 4 future sections stay dark until later stages. - Sticky scrollspy sub-nav: hash-safe anchor chips (preventDefault + scrollIntoView, never touch location.hash so the tab router does not bounce to Home); position-based scrollspy with an explicit page-bottom -> last section rule (robust on short pages). --header-h kept live via a ResizeObserver so the nav sticks correctly under the variable-height header. - flagImg() monogram fallback: a broken flag SVG becomes a 3-letter code span, never a broken-image icon. - i18n stats.nav* keys (EN/PT); stats.css for sub-nav / section / fallback. No DATA_VERSION bump (no deployed data changed). No index.html change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
79e746e4d4
commit
387fab3c8b
4 changed files with 253 additions and 4 deletions
|
|
@ -381,6 +381,75 @@
|
||||||
background: var(--glass-bg-strong);
|
background: var(--glass-bg-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------- sub-nav (scrollspy) */
|
||||||
|
|
||||||
|
/* sticks just below the variable-height sticky header (--header-h is kept live
|
||||||
|
by app.js trackHeaderHeight). NOT a tablist — it's a <nav> of in-page anchors
|
||||||
|
whose clicks are intercepted, so the top tablist's arrow keys never conflict. */
|
||||||
|
.stats-subnav {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--header-h, 64px);
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 1.4rem 0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
background: rgba(8, 20, 33, 0.88);
|
||||||
|
backdrop-filter: blur(10px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-subnav::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.stats-subnav-chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.4rem 0.95rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-subnav-chip:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.stats-subnav-chip.active {
|
||||||
|
color: var(--bg-primary);
|
||||||
|
background: linear-gradient(135deg, var(--accent-gold), var(--accent-gold-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* offset scroll targets so the sticky header + sub-nav never cover a heading */
|
||||||
|
.stats-section {
|
||||||
|
scroll-margin-top: calc(var(--header-h, 64px) + 3.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section + .stats-section { margin-top: 2.5rem; }
|
||||||
|
.stats-section:focus { outline: none; }
|
||||||
|
/* section spacing is owned by the wrapper, not the first heading inside it */
|
||||||
|
.stats-section > .section-title:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* flag monogram fallback (graceful degradation §0.3) — never a broken-img icon */
|
||||||
|
.flag-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------- responsive */
|
/* ---------------------------------------------------------- responsive */
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,49 @@ let data = null;
|
||||||
|
|
||||||
const DATA_VERSION = '2026-06-16-rev2';
|
const DATA_VERSION = '2026-06-16-rev2';
|
||||||
|
|
||||||
|
// Optional data layers for the post-tournament stats screen (players, awards,
|
||||||
|
// editorial — see .agents/stats-screen-plan.md §0.2). They don't exist yet, so
|
||||||
|
// an absent/404 file is the NORMAL "this layer hasn't arrived" state: return the
|
||||||
|
// empty default silently (graceful degradation — never surface the gap, and keep
|
||||||
|
// the console clean). Warn only when a file is present but malformed (a real dev
|
||||||
|
// error). Never throws — the stats screen lights these up as the JSON lands.
|
||||||
|
async function loadOptional(name, fallback) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
|
||||||
|
if (!res.ok) return fallback; // not provided yet → empty, no noise
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`data/${name}.json present but unreadable — ignoring`, err);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadData() {
|
export async function loadData() {
|
||||||
if (data) return data;
|
if (data) return data;
|
||||||
const files = ['teams', 'groups', 'matches', 'results', 'stadiums', 'bracket-config'];
|
const files = ['teams', 'groups', 'matches', 'results', 'stadiums', 'bracket-config'];
|
||||||
const [teams, groups, matches, results, stadiums, bracketConfig] = await Promise.all(
|
// Core files are mandatory: a failure here is fatal (throws → showError()).
|
||||||
|
const corePromise = Promise.all(
|
||||||
files.map(async (name) => {
|
files.map(async (name) => {
|
||||||
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
|
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
|
||||||
if (!res.ok) throw new Error(`data/${name}.json — HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`data/${name}.json — HTTP ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Optional layers fetched concurrently; each defaults to empty, never fatal.
|
||||||
|
const optionalPromise = Promise.all([
|
||||||
|
loadOptional('players', []),
|
||||||
|
loadOptional('player-events', []),
|
||||||
|
loadOptional('awards', {}),
|
||||||
|
loadOptional('keeper-stats', []),
|
||||||
|
loadOptional('curiosities', []),
|
||||||
|
loadOptional('all-time-baselines', {}),
|
||||||
|
]);
|
||||||
|
const [teams, groups, matches, results, stadiums, bracketConfig] = await corePromise;
|
||||||
|
const [players, playerEvents, awards, keeperStats, curiosities, allTimeBaselines] =
|
||||||
|
await optionalPromise;
|
||||||
data = {
|
data = {
|
||||||
teams, groups, matches, results, stadiums, bracketConfig,
|
teams, groups, matches, results, stadiums, bracketConfig,
|
||||||
|
players, playerEvents, awards, keeperStats, curiosities, allTimeBaselines,
|
||||||
teamById: new Map(teams.map((team) => [team.id, team])),
|
teamById: new Map(teams.map((team) => [team.id, team])),
|
||||||
stadiumByName: new Map(stadiums.map((s) => [s.name, s])),
|
stadiumByName: new Map(stadiums.map((s) => [s.name, s])),
|
||||||
resultByMatchId: new Map(results.map((r) => [r.matchId, r])),
|
resultByMatchId: new Map(results.map((r) => [r.matchId, r])),
|
||||||
|
|
@ -515,6 +546,18 @@ function initLangSwitch() {
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The header is sticky with a VARIABLE height (one row ≥1100px, two bands below).
|
||||||
|
// Expose its live height as --header-h so the stats sub-nav can stick right
|
||||||
|
// beneath it and sections can offset their scroll target at every breakpoint.
|
||||||
|
function trackHeaderHeight() {
|
||||||
|
const header = document.querySelector('.site-header');
|
||||||
|
if (!header) return;
|
||||||
|
const set = () => document.documentElement.style.setProperty('--header-h', `${header.offsetHeight}px`);
|
||||||
|
set();
|
||||||
|
if ('ResizeObserver' in window) new ResizeObserver(set).observe(header);
|
||||||
|
else window.addEventListener('resize', set);
|
||||||
|
}
|
||||||
|
|
||||||
function renderHome() {
|
function renderHome() {
|
||||||
renderHero();
|
renderHero();
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
|
|
@ -530,6 +573,7 @@ function showError(error) {
|
||||||
async function init() {
|
async function init() {
|
||||||
initI18n();
|
initI18n();
|
||||||
initTabs();
|
initTabs();
|
||||||
|
trackHeaderHeight();
|
||||||
initLangSwitch();
|
initLangSwitch();
|
||||||
initTimeToggle();
|
initTimeToggle();
|
||||||
initFavorites();
|
initFavorites();
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,13 @@ const dicts = {
|
||||||
'stats.tileAvg': 'Goals / match',
|
'stats.tileAvg': 'Goals / match',
|
||||||
'stats.tileBiggestMargin': 'Biggest margin',
|
'stats.tileBiggestMargin': 'Biggest margin',
|
||||||
'stats.tileCleanSheets': 'Clean sheets',
|
'stats.tileCleanSheets': 'Clean sheets',
|
||||||
|
'stats.sectionsNav': 'Statistics sections',
|
||||||
|
'stats.navOverview': 'Overview',
|
||||||
|
'stats.navTeams': 'Teams',
|
||||||
|
'stats.navPlayers': 'Players',
|
||||||
|
'stats.navRecords': 'Records',
|
||||||
|
'stats.navComparator': 'Comparator',
|
||||||
|
'stats.navArchive': 'Archive',
|
||||||
'stats.overviewTitle': 'Overview',
|
'stats.overviewTitle': 'Overview',
|
||||||
'stats.played': 'Matches played',
|
'stats.played': 'Matches played',
|
||||||
'stats.decisive': 'Decisive',
|
'stats.decisive': 'Decisive',
|
||||||
|
|
@ -259,6 +266,13 @@ const dicts = {
|
||||||
'stats.tileAvg': 'Gols por jogo',
|
'stats.tileAvg': 'Gols por jogo',
|
||||||
'stats.tileBiggestMargin': 'Maior margem',
|
'stats.tileBiggestMargin': 'Maior margem',
|
||||||
'stats.tileCleanSheets': 'Sem sofrer gols',
|
'stats.tileCleanSheets': 'Sem sofrer gols',
|
||||||
|
'stats.sectionsNav': 'Seções de estatísticas',
|
||||||
|
'stats.navOverview': 'Visão geral',
|
||||||
|
'stats.navTeams': 'Seleções',
|
||||||
|
'stats.navPlayers': 'Jogadores',
|
||||||
|
'stats.navRecords': 'Recordes',
|
||||||
|
'stats.navComparator': 'Comparador',
|
||||||
|
'stats.navArchive': 'Arquivo',
|
||||||
'stats.overviewTitle': 'Visão geral',
|
'stats.overviewTitle': 'Visão geral',
|
||||||
'stats.played': 'Jogos disputados',
|
'stats.played': 'Jogos disputados',
|
||||||
'stats.decisive': 'Decididas',
|
'stats.decisive': 'Decididas',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,22 @@ const COLUMNS = [
|
||||||
{ key: 'cleanSheets', label: 'stats.colCS', tip: 'tip.cs' },
|
{ key: 'cleanSheets', label: 'stats.colCS', tip: 'tip.cs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Sub-nav sections (graceful-degradation contract, stats-screen-plan.md §0.1): a
|
||||||
|
// section renders — and its sub-nav chip appears — only when `available(model)`
|
||||||
|
// holds. Otherwise it is omitted from the DOM entirely (no placeholder, no "—",
|
||||||
|
// no "coming soon") and the nav never points at emptiness. Later stages flip
|
||||||
|
// `available` and supply `body` for players/records/comparator/archive; the same
|
||||||
|
// code base thus renders a coherent, "full" screen with only today's data and
|
||||||
|
// lights up sections as each data layer arrives.
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: 'overview', navKey: 'stats.navOverview', available: () => true, body: overviewHTML },
|
||||||
|
{ id: 'teams', navKey: 'stats.navTeams', available: () => true, body: teamsSectionHTML },
|
||||||
|
{ id: 'players', navKey: 'stats.navPlayers', available: () => false, body: () => '' },
|
||||||
|
{ id: 'records', navKey: 'stats.navRecords', available: () => false, body: () => '' },
|
||||||
|
{ id: 'comparator', navKey: 'stats.navComparator', available: () => false, body: () => '' },
|
||||||
|
{ id: 'archive', navKey: 'stats.navArchive', available: () => false, body: () => '' },
|
||||||
|
];
|
||||||
|
|
||||||
let model = null;
|
let model = null;
|
||||||
// table interaction state — survives langchange re-renders (default on load:
|
// table interaction state — survives langchange re-renders (default on load:
|
||||||
// most goals first, page 1), like the bracket keeps its zoom across re-renders.
|
// most goals first, page 1), like the bracket keeps its zoom across re-renders.
|
||||||
|
|
@ -165,6 +181,7 @@ function computeLeaders(teamStats) {
|
||||||
// ---------------------------------------------------------------- render
|
// ---------------------------------------------------------------- render
|
||||||
|
|
||||||
export function initStats() {
|
export function initStats() {
|
||||||
|
installImageFallback();
|
||||||
render();
|
render();
|
||||||
// labels re-render on language change; the derived model never changes at
|
// labels re-render on language change; the derived model never changes at
|
||||||
// runtime (data is static per page load) so it is reused.
|
// runtime (data is static per page load) so it is reused.
|
||||||
|
|
@ -176,7 +193,15 @@ export function initStats() {
|
||||||
function render() {
|
function render() {
|
||||||
if (!model) model = buildStatsModel();
|
if (!model) model = buildStatsModel();
|
||||||
const root = document.getElementById('stats-root');
|
const root = document.getElementById('stats-root');
|
||||||
root.innerHTML = heroHTML() + overviewHTML() + teamsSectionHTML() + footerHTML();
|
const sections = SECTIONS.filter((section) => section.available(model));
|
||||||
|
root.innerHTML =
|
||||||
|
heroHTML()
|
||||||
|
+ subNavHTML(sections)
|
||||||
|
+ sections.map((section) => `
|
||||||
|
<section id="stats-${section.id}" class="stats-section" tabindex="-1" aria-label="${t(section.navKey)}">
|
||||||
|
${section.body(model)}
|
||||||
|
</section>`).join('')
|
||||||
|
+ footerHTML();
|
||||||
root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches'));
|
root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches'));
|
||||||
const teamsHost = root.querySelector('#stats-teams-table');
|
const teamsHost = root.querySelector('#stats-teams-table');
|
||||||
if (teamsHost) {
|
if (teamsHost) {
|
||||||
|
|
@ -184,6 +209,103 @@ function render() {
|
||||||
renderTeamTable();
|
renderTeamTable();
|
||||||
}
|
}
|
||||||
setupCountUps(root);
|
setupCountUps(root);
|
||||||
|
setupSubNav(root, sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- sub-nav
|
||||||
|
|
||||||
|
function subNavHTML(sections) {
|
||||||
|
if (sections.length < 2) return ''; // a lone section needs no navigation
|
||||||
|
const chips = sections.map((section, i) => `
|
||||||
|
<a class="stats-subnav-chip${i === 0 ? ' active' : ''}" href="#stats-${section.id}"
|
||||||
|
data-section="${section.id}" aria-current="${i === 0 ? 'true' : 'false'}">${t(section.navKey)}</a>`).join('');
|
||||||
|
return `<nav class="stats-subnav" aria-label="${t('stats.sectionsNav')}">${chips}</nav>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spyScrollHandler = null;
|
||||||
|
function setupSubNav(root, sections) {
|
||||||
|
const nav = root.querySelector('.stats-subnav');
|
||||||
|
if (!nav) return;
|
||||||
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
// chip → smooth-scroll to the section WITHOUT touching location.hash: the tab
|
||||||
|
// router (app.js) listens on hashchange, so a real #fragment would route to
|
||||||
|
// an unknown tab and bounce the user to Home. preventDefault keeps us in-tab.
|
||||||
|
nav.addEventListener('click', (event) => {
|
||||||
|
const chip = event.target.closest('.stats-subnav-chip');
|
||||||
|
if (!chip) return;
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById(`stats-${chip.dataset.section}`)
|
||||||
|
?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
|
||||||
|
setActiveChip(nav, chip.dataset.section);
|
||||||
|
});
|
||||||
|
|
||||||
|
// scrollspy: active = the last section whose heading has scrolled under the
|
||||||
|
// sticky sub-nav line; at the page bottom the last section always wins (a short
|
||||||
|
// final section may never reach the line — the classic scrollspy edge case an
|
||||||
|
// IntersectionObserver band leaves unlit). Reading getBoundingClientRect on a
|
||||||
|
// handful of sections per frame is cheap and always correct on short pages.
|
||||||
|
const ids = sections.map((section) => section.id);
|
||||||
|
const updateSpy = () => {
|
||||||
|
const headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 64;
|
||||||
|
const line = headerH + 80; // just beneath the sticky sub-nav
|
||||||
|
let activeId = ids[0];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (document.getElementById(`stats-${id}`)?.getBoundingClientRect().top <= line) activeId = id;
|
||||||
|
}
|
||||||
|
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
|
||||||
|
activeId = ids[ids.length - 1]; // bottom reached → last section
|
||||||
|
}
|
||||||
|
setActiveChip(nav, activeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (spyScrollHandler) window.removeEventListener('scroll', spyScrollHandler);
|
||||||
|
let raf = 0;
|
||||||
|
spyScrollHandler = () => {
|
||||||
|
if (raf) return;
|
||||||
|
raf = requestAnimationFrame(() => { raf = 0; updateSpy(); });
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', spyScrollHandler, { passive: true });
|
||||||
|
updateSpy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveChip(nav, id) {
|
||||||
|
for (const chip of nav.querySelectorAll('.stats-subnav-chip')) {
|
||||||
|
const on = chip.dataset.section === id;
|
||||||
|
chip.classList.toggle('active', on);
|
||||||
|
chip.setAttribute('aria-current', on ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
// keep the active chip visible when the nav scrolls horizontally on mobile
|
||||||
|
// (only moves the nav's own scroll, never the page).
|
||||||
|
const active = nav.querySelector('.stats-subnav-chip.active');
|
||||||
|
if (active) nav.scrollLeft = active.offsetLeft - (nav.clientWidth - active.clientWidth) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- flags
|
||||||
|
|
||||||
|
// Flag <img> that degrades to a 3-letter monogram if the SVG is missing — never
|
||||||
|
// a broken-image icon (graceful degradation §0.3). Used everywhere the stats
|
||||||
|
// screen shows a flag so the fallback is uniform.
|
||||||
|
function flagImg(team, w, h, cls = 'flag') {
|
||||||
|
return `<img class="${cls}" src="${flagSrc(team)}" alt="" width="${w}" height="${h}" loading="lazy" data-monogram="${team.id}">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackInstalled = false;
|
||||||
|
function installImageFallback() {
|
||||||
|
if (fallbackInstalled) return;
|
||||||
|
fallbackInstalled = true;
|
||||||
|
// error events don't bubble → listen in the capture phase. Only opted-in
|
||||||
|
// images (data-monogram) are touched, so other views are unaffected.
|
||||||
|
document.addEventListener('error', (event) => {
|
||||||
|
const img = event.target;
|
||||||
|
if (!(img instanceof HTMLImageElement) || !img.dataset.monogram) return;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'flag-fallback';
|
||||||
|
span.style.width = `${img.getAttribute('width')}px`;
|
||||||
|
span.style.height = `${img.getAttribute('height')}px`;
|
||||||
|
span.textContent = img.dataset.monogram;
|
||||||
|
img.replaceWith(span);
|
||||||
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function heroHTML() {
|
function heroHTML() {
|
||||||
|
|
@ -292,7 +414,7 @@ function leaderCardHTML({ label, row, value }) {
|
||||||
<div class="leader-card glass">
|
<div class="leader-card glass">
|
||||||
<span class="leader-label">${label}</span>
|
<span class="leader-label">${label}</span>
|
||||||
<div class="leader-team">
|
<div class="leader-team">
|
||||||
<img class="flag" src="${flagSrc(team)}" alt="" width="30" height="20" loading="lazy">
|
${flagImg(team, 30, 20)}
|
||||||
<span class="leader-name">${team.name}</span>
|
<span class="leader-name">${team.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="leader-value">${value}</span>
|
<span class="leader-value">${value}</span>
|
||||||
|
|
@ -340,7 +462,7 @@ function tableHTML(rows, startIndex) {
|
||||||
<tr class="${row.played === 0 ? 'row-idle' : ''}">
|
<tr class="${row.played === 0 ? 'row-idle' : ''}">
|
||||||
<td class="col-rank">${startIndex + i + 1}</td>
|
<td class="col-rank">${startIndex + i + 1}</td>
|
||||||
<td class="col-team">
|
<td class="col-team">
|
||||||
<img class="flag" src="${flagSrc(team)}" alt="" width="22" height="15" loading="lazy">
|
${flagImg(team, 22, 15)}
|
||||||
<span>${team.name}</span>
|
<span>${team.name}</span>
|
||||||
</td>
|
</td>
|
||||||
${cells}
|
${cells}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue