// src/pages-main.jsx — Presentación, Equipo, Noticias
/* ── Diccionarios ES→EN para datos que viven en data.js ──
(clave = string español exacto → inglés; si falta, se muestra el español) */
const STATS_LABELS_EN = {
'Colegios miembros': 'Member schools',
'Regiones del Perú': 'Regions of Peru',
'Mundiales consecutivos': 'Consecutive world titles',
'Año de fundación': 'Year founded',
};
const MARQUEE_EN = {
'Tricampeones Mundiales': 'Three-Time World Champions',
'Lima 2018 — fundación': 'Founded in Lima · 2018',
'103 colegios miembros': '103 member schools',
'11 regiones': '11 regions',
'Selección Nacional': 'National Team',
'Educación Continua': 'Continuing education',
'Argumentar · Pensar · Convencer': 'Argue · Think · Persuade',
};
/* ═══════════════════════════════════════════════════════════
PRESENTACIÓN (Home)
═══════════════════════════════════════════════════════════ */
const PresentacionPage = ({ setPage }) => {
const D = window.LPDE_DATA;
const { t, lang } = useLang();
return (
{/* ─── HERO ──────────────────────────────────────── */}
{t('Desde 2018 · Lima · Perú', 'Since 2018 · Lima · Peru')}
{t('Argumentar', 'Argument')}
{t('da sentido al', 'gives meaning to')}
{t('mundo que ', 'the world we ')}{t('habitamos', 'inhabit')}
{t('Somos la ', 'We are the ')}{t('Liga Peruana de Debate Escolar', 'Peruvian School Debate League (LPDE)')}{t(' — la red más grande de debate académico del Perú. 103 colegios, 11 regiones, tres títulos mundiales consecutivos.', ' — Peru’s largest academic debate network. 103 schools, 11 regions, three consecutive world titles.')}
{/* ─── Marquee ──────────────────────────────────── */}
);
};
/* ═══════════════════════════════════════════════════════════
EQUIPO
═══════════════════════════════════════════════════════════ */
/* Traducciones EN de los datos del equipo (window.LPDE_DATA.team vive en data.js
y se mantiene en español; aquí solo se sobreescribe al renderizar en inglés). */
const TEAM_EN = {
'Evangelina Beierbach': {
role: 'Wellbeing Coordinator',
short: 'Looks after the wellbeing and conduct of the National Team’s students and designs non-academic debate training sessions.',
bio: 'Communication teacher at Colegio Trener since 2005. She entered the world of debate in 2012 and has accompanied the Trener Debate Society since 2014. Part of the LPDE since its early days, she helped launch the World Schools Debate Council and now serves on its General Secretariat. She supports the SNDE in the area of holistic development.',
},
'Felipe Zenteno Pacheco': {
role: 'Academic Director',
short: 'Leads the LPDE’s academic team. Designs and delivers training, coaching and consulting programs in school debate.',
bio: 'Holds a bachelor’s degree in Philosophy from U.N.M.S.M. Debate teacher at Colegio Aleph and argumentation teacher at Colegio San Clemente. Coached the 2023 MED champion team and the 2023 TNDE champion.',
},
'Dante León Saavedra': {
role: 'Operations Director',
short: 'Leads the LPDE’s operations. Manages the finances, staff and logistics that sustain our programs and tournaments.',
bio: 'Holds a degree in Pedagogy and is pursuing a master’s in Education at Tecnológico de Monterrey. Debate coach at St. George’s and teacher of Debate and Entrepreneurship at Colegio Salcantay. He also teaches at ESAN University (Extended Learning).',
cvLabel: 'View LinkedIn',
},
'Sofía Pacheco': {
role: 'Partnerships Director',
short: 'Leads the LPDE’s institutional partnerships. Builds relationships with schools, universities, and public and private organizations.',
bio: 'Lawyer from ESAN University and legal analyst at OEFA. Manager of school debate education projects. Served as Team Manager of the national team that won the 2024 and 2025 MED titles.',
},
'Nataly Vásquez Cabellos': {
role: 'SNDE Coordinator',
short: 'Coordinates the SNDE development team (students in grades 6 to 8).',
bio: 'Psychologist from Universidad Peruana Cayetano Heredia, with experience in community social work. Holds graduate diplomas in Sexuality, Human Rights and Health, and in Education Policy (UPCH, 2025) and in University Teaching (UPCH, 2026). Works on the planning and delivery of educational projects in human rights, gender equality and climate change. Directs the Cayetano Heredia Debate Society — national runner-up in debate and national champion in speech — and is a co-founder of the consultancy SAMI: Salud Mental Integrada.',
cvLabel: 'View LinkedIn',
},
'Mauricio Jarufe Caballero': {
role: 'SNDE Coordinator',
short: 'Coordinates the SNDE team (students in grades 9 to 11).',
bio: 'Anthropologist from PUCP and film critic. Teaches debate at Colegio Alpamayo and Markham College. Three-time world champion in Spanish-language debate and best EFL speaker at the English-language world championship. Coach of Team Peru at WSDC and of the top-ranked team at the 2025 MED.',
},
'Luis Valverde': {
role: 'Research Team',
short: 'Leads research at the League. Produces diagnostics and data visualizations on the school debate circuit.',
bio: 'Holds a degree in Political Science and Government from PUCP. Researcher at GIES PUCP with experience in academic research centers and consulting for public and private organizations. His research focuses on electoral behavior, institutional performance and education policy related to debate.',
},
};
/* Etiquetas EN de los grupos de filtro (la lógica sigue usando las claves en español) */
const TEAM_GROUPS_EN = {
'Todos': 'All',
'Dirección': 'Leadership',
'Académico': 'Academic',
'Operaciones': 'Operations',
'Comunicaciones': 'Communications',
};
const EquipoPage = () => {
const D = window.LPDE_DATA;
const { t, lang } = useLang();
const [selected, setSelected] = React.useState(null);
const [filter, setFilter] = React.useState('Todos');
const localize = (m) => (lang === 'en' && TEAM_EN[m.name]) ? { ...m, ...TEAM_EN[m.name] } : m;
const groupFor = (role) => {
if (role.includes('Director') || role.includes('Dir.')) return 'Dirección';
if (role.includes('Académic') || role.includes('Coach') || role.includes('Educación') || role.includes('Jueces')) return 'Académico';
if (role.includes('Coordinador') || role.includes('Torneos') || role.includes('Regional')) return 'Operaciones';
if (role.includes('Comunic')) return 'Comunicaciones';
return 'Académico';
};
// Solo mostramos filtros con al menos un miembro (evita pestañas vacías con tarjetas "Por anunciar")
const groups = ['Todos', ...['Dirección', 'Académico', 'Operaciones', 'Comunicaciones'].filter((g) => D.team.some((m) => groupFor(m.role) === g))];
const visible = filter === 'Todos' ? D.team : D.team.filter((m) => groupFor(m.role) === filter);
return (
{/* Hero */}
{t('Conoce al equipo', 'Meet the team')}
{t('Las personas detrás de la ', 'The people behind the ')}{t('liga', 'League')}
{t('Profesionales —debatientes, profesores, abogados, politólogos, filósofos, comunicadores— sostienen la operación diaria de la LPDE. Haz clic en cualquier nombre para ver bio, CV y datos de contacto.', 'Professionals — debaters, teachers, lawyers, political scientists, philosophers, communicators — keep the LPDE running every day. Click on any name to see their bio, CV and contact details.')}
{query ? t('No hay noticias para tu búsqueda.', 'No news matches your search.') : t('Aún no hay noticias publicadas.', 'No news published yet.')}
:
{/* Featured */}
{featured &&
setReading(featured)}>
{featured.cover
?
:
LPDE
}
{featured.tag}{featured.date}
{featured.title}
{featured.excerpt}
}
{/* Rest — mixed grid */}
{rest.length > 0 &&
{rest.map((n, i) =>
)}
}
}
setReading(null)} />
{/* Newsletter */}
{t('Newsletter mensual', 'Monthly newsletter')}
{t('Suscríbete al ', 'Subscribe to the ')}newsletter.
{t('Una vez al mes: torneos, becas, mociones recomendadas y la columna del director. Sin spam, sin marketing.', 'Once a month: tournaments, scholarships, recommended motions and the director’s column. No spam, no marketing.')}
{/* Publicaciones */}
{t('Publicaciones LPDE', 'LPDE Publications')}
{t('Materiales gratuitos para profesores y debatientes.', 'Free materials for teachers and debaters.')}
{t('Manuales, guías y nuestra revista ', 'Manuals, guides and our magazine ')}Dialógica{t('. Acceso abierto — sin pago, sin registro.', '. Open access — no fees, no sign-up.')}
{news.length} noticia{news.length !== 1 ? 's' : ''} en este navegador.Los cambios se guardan en localStorage. Para publicarlos al sitio público, exporta el JSON y reemplaza la semilla en data.js.
{/* Lista de noticias */}
{news.length === 0
?
Aún no hay noticias.
:
{/* Header */}
Portada
Título
Autoría
Fecha
Estado
Acciones
{news.map((n, i) => {
const by = newsByline(n);
return (
{n.cover
?
:
—
}
{n.tag || '—'}
{n.title || (sin título)}
{by
? <>
{by.group &&
{by.group}
}
{by.names.length > 0 &&
{by.names.join(' · ')}
}
>
: Sin autor}
{n.date || '—'}
{n.featured
? Destacada
: Publicada}
);
})}
}
{/* Modales */}
{editing && setEditing(null)} />}
setPreview(null)} />
);
};
/* ═══════════════════════════════════════════════════════════
CALENDARIO — snippet (en home) + página completa
Live fetch del Sheet con fallback al snapshot en data.js
═══════════════════════════════════════════════════════════ */
const MONTH_SHORT = ['ENE','FEB','MAR','ABR','MAY','JUN','JUL','AGO','SEP','OCT','NOV','DIC'];
const MONTH_LONG = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const MONTH_SHORT_EN = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
const MONTH_LONG_EN = ['January','February','March','April','May','June','July','August','September','October','November','December'];
/* Etiquetas EN del chrome del calendario (tags de evento + filtros; las claves
en español siguen mandando en la lógica y en los datos vivos del Sheet) */
const EVENT_TAG_EN = {
'SEMINARIO': 'SEMINAR',
'INTERNACIONAL': 'INTERNATIONAL',
'EXTERNO': 'EXTERNAL',
'PASADO': 'PAST',
};
const CAL_FILTERS_EN = {
'Todos': 'All',
'Externos': 'External',
'Virtual': 'Online',
'Presencial': 'In person',
'Internacional': 'International',
'Seminarios': 'Seminars',
};
const _CAL_MES_MAP = {
enero: 1, febrero: 2, marzo: 3, abril: 4, mayo: 5, junio: 6,
julio: 7, agosto: 8, septiembre: 9, octubre: 10, noviembre: 11, diciembre: 12,
};
const CALENDAR_CSV_URL = 'https://docs.google.com/spreadsheets/d/1zeV3ASYhwPq5nIEEpz8NX4nH6TOOaF9I6BuZOYQIA0E/export?format=csv';
function _parseCalCSVLine(line) {
const cols = []; let cur = '', inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQ = !inQ; }
else if (ch === ',' && !inQ) { cols.push(cur); cur = ''; }
else cur += ch;
}
cols.push(cur);
return cols.map(c => c ? c.replace(/^"|"$/g, '').trim() : '');
}
// Divide el CSV en filas respetando saltos de línea dentro de celdas entrecomilladas
// (el Sheet tiene \n embebidos en "contacto"/"dirigido"; un split('\n') simple rompe 7 eventos).
function _splitCalCSVRows(text) {
const rows = []; let cur = '', inQ = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '"') { inQ = !inQ; cur += ch; }
else if (ch === '\n' && !inQ) { rows.push(cur); cur = ''; }
else cur += ch;
}
if (cur.trim()) rows.push(cur);
return rows;
}
function parseCalendarCSV(text) {
const rows = _splitCalCSVRows(text.replace(/\r/g, ''));
if (rows.length < 2) return [];
const out = [];
for (let r = 1; r < rows.length; r++) {
if (!rows[r].trim()) continue;
const c = _parseCalCSVLine(rows[r]);
const id = parseInt(c[0], 10);
if (!id) continue;
const fecha = c[3] || '';
const mes = c[2] || '';
const fechaLow = fecha.toLowerCase();
const mesLow = mes.toLowerCase();
let mesNum = _CAL_MES_MAP[mesLow] || null;
if (!mesNum) {
for (const k of Object.keys(_CAL_MES_MAP)) {
if (fechaLow.includes(k)) { mesNum = _CAL_MES_MAP[k]; break; }
}
}
const isTba = fechaLow.includes('anuncio') || fechaLow.includes('tba') || !fechaLow.trim();
const nums = fecha.match(/\d+/g) || [];
const day = (!isTba && nums.length) ? parseInt(nums[0], 10) : null;
const isRange = nums.length > 1;
out.push({
id, name: c[1] || '', mes, mesNum: mesNum || 12,
fecha, day, isRange, tba: isTba,
horario: c[5] || '', ubicacion: c[6] || '', org: c[7] || '',
contacto: (c[8] || '').replace(/\n/g, ' ').trim(),
dirigido: (c[9] || '').replace(/\n/g, ' ').trim(),
cats: c[10] || '',
lpde: c[11] === 'TRUE',
ext: c[12] === 'TRUE',
virtual: c[13] === 'TRUE',
presencial: c[14] === 'TRUE',
intl: c[15] === 'TRUE',
seminario: c[16] === 'TRUE',
});
}
return out.sort((a, b) => (a.mesNum - b.mesNum) || ((a.day || 99) - (b.day || 99)));
}
function useCalendarEvents() {
const D = window.LPDE_DATA;
const [events, setEvents] = React.useState(D.calendarEvents);
React.useEffect(() => {
let cancelled = false;
fetch(`${CALENDAR_CSV_URL}&t=${Date.now()}`, { cache: 'no-store' })
.then(r => r.ok ? r.text() : Promise.reject('http ' + r.status))
.then(text => {
if (cancelled) return;
const parsed = parseCalendarCSV(text);
if (parsed.length) setEvents(parsed);
})
.catch(err => console.warn('[calendario] fetch failed, using snapshot:', err));
return () => { cancelled = true; };
}, []);
return events;
}
const isPastEvent = (ev) => {
if (ev.tba || !ev.day) return false;
const today = new Date();
const year = today.getFullYear();
if (year > 2026) return true;
if (year < 2026) return false;
const m = today.getMonth() + 1;
const d = today.getDate();
if (ev.mesNum < m) return true;
if (ev.mesNum === m && ev.day < d) return true;
return false;
};
const eventTag = (ev) => {
if (ev.id === 10) return 'TNDE';
if (ev.seminario) return 'SEMINARIO';
if (ev.intl) return 'INTERNACIONAL';
if (ev.lpde) return 'LPDE';
return 'EXTERNO';
};
const CalendarioSnippet = ({ setPage }) => {
const events = useCalendarEvents();
const { t, lang } = useLang();
const MS = lang === 'en' ? MONTH_SHORT_EN : MONTH_SHORT;
// Próximos 4 eventos (no pasados, con fecha definida)
const upcoming = events.filter(e => !isPastEvent(e) && e.day != null).slice(0, 4);
return (
{t('Calendario 2026', '2026 Calendar')}
{t('Lo que se viene en los ', 'What’s ahead in the ')}{t('próximos meses', 'coming months')}
{t('Torneos LPDE, encuentros amistosos, seminarios virtuales y campeonatos internacionales. Los eventos LPDE aparecen marcados en oscuro; el TNDE en rojo.', 'LPDE tournaments, friendly meets, online seminars and international championships. LPDE events are shown in dark gray; the TNDE in red.')}
{/* Year strip — 12 meses dinámicos */}
{t('Vista anual · 2026', 'Year at a glance · 2026')}
);
};
/* ─── Descarga de evento como JPG (canvas 1080×1350, estilo editorial) ───
El layout se MIDE antes de dibujar y baja de escala hasta caber:
garantiza que la imagen nunca salga cortada. */
const _wrapCanvasText = (ctx, text, maxWidth) => {
const words = [];
String(text || '').split(/\s+/).filter(Boolean).forEach((w) => {
// trocea palabras más anchas que la caja (URLs, correos largos)
while (ctx.measureText(w).width > maxWidth && w.length > 4) {
let cut = w.length - 1;
while (cut > 1 && ctx.measureText(w.slice(0, cut)).width > maxWidth) cut--;
words.push(w.slice(0, cut));
w = w.slice(cut);
}
words.push(w);
});
const lines = [];
let line = '';
words.forEach((w) => {
const probe = line ? line + ' ' + w : w;
if (ctx.measureText(probe).width > maxWidth && line) { lines.push(line); line = w; }
else line = probe;
});
if (line) lines.push(line);
return lines;
};
// Como _wrapCanvasText pero limita líneas y cierra con "…" si truncó
const _fitCanvasLines = (ctx, text, maxWidth, maxLines) => {
const lines = _wrapCanvasText(ctx, text, maxWidth);
if (lines.length <= maxLines) return lines;
const kept = lines.slice(0, maxLines);
let last = kept[maxLines - 1] + '…';
while (ctx.measureText(last).width > maxWidth && last.length > 2) last = last.slice(0, -2) + '…';
kept[maxLines - 1] = last;
return kept;
};
const downloadEventJPG = (ev, lang) => {
const css = getComputedStyle(document.body);
const RED = (css.getPropertyValue('--red') || '#E30613').trim();
const INK = (css.getPropertyValue('--ink') || '#0F0F12').trim();
const CREAM = (css.getPropertyValue('--bg-alt') || '#F7F5EF').trim();
const dark = ev.id === 10; // tarjeta TNDE en oscuro, como en la web
const W = 1080, H = 1350, M = 90, CW = W - M * 2;
const SERIF = '"Instrument Serif", Georgia, serif';
const SANS = '"Source Sans 3", Arial, sans-serif';
const MONO = '"JetBrains Mono", monospace';
const bg = dark ? INK : CREAM;
const fg = dark ? '#FFFFFF' : INK;
const sub = dark ? '#C9C9D0' : '#4B4B53';
const mute = dark ? '#9C9AA0' : '#6B7280';
const past = isPastEvent(ev);
const baseTag = eventTag(ev);
const eventTagLabel = lang === 'en' ? (EVENT_TAG_EN[baseTag] || baseTag) : baseTag;
const ML = lang === 'en' ? MONTH_LONG_EN : MONTH_LONG;
const draw = (logoImg) => {
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const CONTENT_TOP = 282;
const CONTENT_BOTTOM = H - 210; // el pie es fijo; nada puede invadirlo
const monthName = (ML[(ev.mesNum || 12) - 1] || '').toUpperCase();
const fechaTxt = ev.fecha || (lang === 'en' ? 'Date TBA' : 'Fecha por anunciar');
const L = (es, en) => (lang === 'en' ? en : es);
// Dibuja (o solo mide, con dry=true) el contenido a una escala S. Devuelve la y final.
const render = (S, dry) => {
const fecha = Math.round(60 * S);
const title = Math.round(74 * S), titleLH = Math.round(82 * S);
const val = Math.round(33 * S), valLH = Math.round(43 * S);
const gap = Math.round(34 * S);
const maxTitle = S >= 0.9 ? 3 : 4;
let y = CONTENT_TOP;
// Chips: categoría (+ PASADO si aplica, sin perder la categoría)
ctx.font = '500 23px ' + MONO;
let chipX = M;
const chips = past ? [eventTagLabel, L('PASADO', 'PAST')] : [eventTagLabel];
chips.forEach((c, ci) => {
const w = ctx.measureText(c).width + 44;
if (!dry) {
ctx.fillStyle = ci === 0 && !past ? RED : (ci === 0 ? RED : (dark ? '#2A2A30' : '#D4D2CC'));
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(chipX, y, w, 48, 6); ctx.fill(); }
else ctx.fillRect(chipX, y, w, 48);
ctx.fillStyle = ci === 0 ? '#FFFFFF' : (dark ? '#C9C9D0' : '#4B4B53');
ctx.fillText(c, chipX + 22, y + 33);
}
chipX += w + 14;
});
y += 48 + Math.round(44 * S);
// Mes (kicker) + fecha (serif itálica roja)
if (!dry) {
ctx.fillStyle = mute;
ctx.font = '500 24px ' + MONO;
ctx.fillText(monthName, M, y + 24);
}
y += 24 + Math.round(26 * S);
ctx.font = 'italic 400 ' + fecha + 'px ' + SERIF;
const fechaLines = _fitCanvasLines(ctx, fechaTxt, CW, 1);
if (!dry) {
ctx.fillStyle = RED;
ctx.fillText(fechaLines[0], M, y + fecha * 0.78);
}
y += fecha + gap;
// Título
ctx.font = '900 ' + title + 'px ' + SANS;
const titleLines = _fitCanvasLines(ctx, ev.name, CW, maxTitle);
titleLines.forEach((l) => {
if (!dry) { ctx.fillStyle = fg; ctx.fillText(l, M, y + title * 0.78); }
y += titleLH;
});
y += gap;
// Regla corta roja entre título y datos
if (!dry) { ctx.fillStyle = RED; ctx.fillRect(M, y, 56, 4); }
y += Math.round(40 * S);
// Detalles: Ubicación + Horario en dos columnas; el resto a lo ancho
const colW = Math.floor((CW - 48) / 2);
const block = (label, value, x, width, maxLines) => {
if (!value) return 0;
let by = 0;
if (!dry) {
ctx.fillStyle = mute;
ctx.font = '500 21px ' + MONO;
ctx.fillText(label.toUpperCase(), x, y + by + 21);
}
by += 21 + 16;
ctx.font = '400 ' + val + 'px ' + SANS;
const lines = _fitCanvasLines(ctx, value, width, maxLines);
lines.forEach((l) => {
if (!dry) { ctx.fillStyle = sub; ctx.fillText(l, x, y + by + val * 0.78); }
by += valLH;
});
return by;
};
const h1 = block(L('Ubicación', 'Location'), ev.ubicacion, M, colW, 2);
const h2 = block(L('Horario', 'Time'), ev.horario, M + colW + 48, colW, 2);
if (h1 || h2) y += Math.max(h1, h2) + gap;
const h3 = block(L('Organiza', 'Hosted by'), ev.org, M, CW, 1);
if (h3) y += h3 + gap;
const h4 = block(L('Dirigido a', 'For'), ev.dirigido, M, CW, S >= 0.85 ? 3 : 2);
if (h4) y += h4 + gap;
const h5 = block(L('Contacto', 'Contact'), ev.contacto, M, CW, S >= 0.85 ? 2 : 1);
if (h5) y += h5;
return y;
};
// 1) Pasada de medición: baja la escala hasta que el contenido cabe
const scales = [1, 0.92, 0.84, 0.76, 0.68];
let S = scales[scales.length - 1];
for (const s of scales) {
if (render(s, true) <= CONTENT_BOTTOM) { S = s; break; }
}
// 2) Pasada real
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
try { ctx.letterSpacing = '3px'; } catch (e) {}
// Cabecera fija: logo + kicker + línea
if (logoImg) {
const lh = 54, lw = lh * (logoImg.width / logoImg.height);
ctx.drawImage(logoImg, M, M - 6, lw, lh);
} else {
ctx.fillStyle = RED;
ctx.font = '900 44px ' + SANS;
ctx.fillText('LPDE', M, M + 38);
}
ctx.fillStyle = mute;
ctx.font = '500 22px ' + MONO;
ctx.textAlign = 'right';
ctx.fillText(lang === 'en' ? '2026 CALENDAR' : 'CALENDARIO 2026', W - M, M + 34);
ctx.textAlign = 'left';
ctx.strokeStyle = dark ? '#2A2A30' : '#D4D2CC';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(M, 206); ctx.lineTo(W - M, 206); ctx.stroke();
render(S, false);
// Pie fijo
ctx.strokeStyle = dark ? '#2A2A30' : '#D4D2CC';
ctx.beginPath(); ctx.moveTo(M, H - 150); ctx.lineTo(W - M, H - 150); ctx.stroke();
ctx.fillStyle = RED;
ctx.fillRect(M, H - 150, 64, 4);
ctx.fillStyle = mute;
ctx.font = '500 23px ' + MONO;
ctx.fillText('lpdebate.org', M, H - 88);
ctx.textAlign = 'right';
ctx.fillText(lang === 'en' ? 'Peruvian School Debate League' : 'Liga Peruana de Debate Escolar', W - M, H - 88);
ctx.textAlign = 'left';
canvas.toBlob((blob) => {
if (!blob) return;
const a = document.createElement('a');
const slug = String(ev.name || 'evento').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 60);
a.href = URL.createObjectURL(blob);
a.download = `lpde-${slug}.jpg`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
}, 'image/jpeg', 0.92);
};
const start = () => {
const logo = new Image();
logo.onload = () => draw(logo);
logo.onerror = () => draw(null);
logo.src = dark ? 'assets/logo-lpde-white.png' : 'assets/logo-lpde-red.png';
};
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start);
else start();
};
const CalendarioPage = ({ setPage }) => {
const events = useCalendarEvents();
const { t, lang } = useLang();
const ML = lang === 'en' ? MONTH_LONG_EN : MONTH_LONG;
const [filter, setFilter] = React.useState('Todos');
const [hidePast, setHidePast] = React.useState(false);
const filters = ['Todos', 'LPDE', 'Externos', 'Virtual', 'Presencial', 'Internacional', 'Seminarios'];
const matchFilter = (e) => {
if (filter === 'Todos') return true;
if (filter === 'LPDE') return e.lpde;
if (filter === 'Externos') return e.ext;
if (filter === 'Virtual') return e.virtual;
if (filter === 'Presencial') return e.presencial;
if (filter === 'Internacional') return e.intl;
if (filter === 'Seminarios') return e.seminario;
return true;
};
const visible = events.filter(matchFilter).filter((e) => !hidePast || !isPastEvent(e));
// Agrupar por mes
const byMonth = {};
visible.forEach(e => {
const k = e.mesNum;
if (!byMonth[k]) byMonth[k] = [];
byMonth[k].push(e);
});
return (
{/* Hero */}
{t('Calendario 2026', '2026 Calendar')}
{t('Todo lo que se viene en la ', 'Everything coming up at the ')}{t('Liga', 'League')}
{events.length}{t(' eventos planificados para 2026: torneos LPDE, encuentros amistosos, seminarios virtuales, campeonatos externos e internacionales. Usa los filtros para ver solo lo que te interesa.', ' events planned for 2026: LPDE tournaments, friendly meets, online seminars, and external and international championships. Use the filters to see only what interests you.')}