1import { getCreatorLinks } from './data.js?v=18'; 2import { getExpansionEmoji, getManaCSSColors } from './utils.js?v=18'; 3 4const MANA_COLORS = getManaCSSColors(); 5 6function escapeHtml(value = '') { 7 return String(value) 8 .replaceAll('&', '&') 9 .replaceAll('<', '<') 10 .replaceAll('>', '>') 11 .replaceAll('"', '"') 12 .replaceAll("'", '''); 13} 14 15function icon(name) { 16 const icons = { 17 youtube: '<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 32 32 0 0 0 0 12a32 32 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A32 32 0 0 0 24 12a32 32 0 0 0-.5-5.8ZM9.5 15.6V8.4l6.3 3.6-6.3 3.6Z"/>', 18 external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="m10 14 11-11"/>', 19 search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>', 20 refresh: '<path d="M21 12a9 9 0 0 1-15.2 6.5"/><path d="M3 12A9 9 0 0 1 18.2 5.5"/><path d="M18 2v4h-4"/><path d="M6 22v-4h4"/>', 21 x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 22 web: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.2 2.5 3.3 5.5 3.3 9S14.2 18.5 12 21c-2.2-2.5-3.3-5.5-3.3-9S9.8 5.5 12 3Z"/>', 23 twitch: '<path d="M4 4h16v10l-4 4h-4l-3 3v-3H4V4Z"/><path d="M10 8v5"/><path d="M15 8v5"/>', 24 kick: '<path d="M5 4h5v6l5-6h5l-6 7 6 9h-6l-4-6v6H5V4Z"/>', 25 untapped: '<path d="M5 19V5"/><path d="M10 19V9"/><path d="M15 19v-7"/><path d="M20 19V7"/>', 26 heart: '<path d="M20.8 5.6a5.1 5.1 0 0 0-7.2 0L12 7.2l-1.6-1.6a5.1 5.1 0 0 0-7.2 7.2L12 21.5l8.8-8.7a5.1 5.1 0 0 0 0-7.2Z"/>', 27 }; 28 29 const fill = name === 'youtube' ? 'fill="currentColor"' : 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; 30 return `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" ${fill}>${icons[name] || icons.external}</svg>`; 31} 32
33function renderSocialGrid(links) { 34 return ['youtube', 'twitch', 'kick', 'web', 'untapped', 'donate'] 35 .filter(key => links[key]?.url) 36 .map(key => ` 37 <a class="social-tile social-tile--${key}" href="${escapeHtml(links[key].url)}" target="_blank" rel="noopener" title="${escapeHtml(links[key].label)}"> 38 ${icon(links[key].icon)} 39 <span>${escapeHtml(links[key].label)}</span> 40 </a> 41 `).join(''); 42} 43 44function renderManaDots(colors = []) { 45 return colors.map(color => { 46 const label = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green', C: 'Colorless' }[color] || color; 47 return `<span class="mana-dot" style="--mana:${MANA_COLORS[color] || MANA_COLORS.C}" title="${label}" aria-label="${label}"></span>`; 48 }).join(''); 49} 50 51function renderFormatBadge(format) { 52 const cls = format.toLowerCase().replace(/\s+/g, '-'); 53 return `<span class="badge badge--${cls}">${escapeHtml(format)}</span>`; 54} 55 56export function renderDeckCard(deck, index) { 57 const delay = Math.min(index * 24, 420); 58 const expansion = deck.expansion || deck.sourceSheet || 'Sin seccion'; 59 const deckAction = deck.hasDeck 60 ? `<a class="deck-card__btn deck-card__btn--primary" href="${escapeHtml(deck.untappedUrl)}" target="_blank" rel="noopener">${icon('external')} Ver mazo</a>` 61 : `<span class="deck-card__btn deck-card__btn--disabled" aria-disabled="true">${icon('external')} Sin lista</span>`; 62 const videoAction = deck.hasVideo 63 ? `<a class="deck-card__btn deck-card__btn--video" href="${escapeHtml(deck.youtubeUrl)}" target="_blank" rel="noopener">${icon('youtube')} Video</a>` 64 : `<span class="deck-card__btn deck-card__btn--disabled" aria-disabled="true">${icon('youtube')} Sin video</span>`; 65 66 return ` 67 <article class="deck-card" style="animation-delay:${delay}ms" id="deck-${escapeHtml(deck.id)}"> 68 <div class="deck-card__top"> 69 <div class="deck-card__colors">${renderManaDots(deck.colors)}</div> 70 <div class="deck-card__top-actions"> 71 <button class="deck-card__detail" type="button" data-deck-detail="${escapeHtml(deck.id)}">Detalle</button> 72 ${renderFormatBadge(deck.format)} 73 </div> 74 </div> 75 <h3 class="deck-card__name">${escapeHtml(deck.name)}</h3> 76 <div class="deck-card__meta"> 77 <span>${escapeHtml(deck.dateFormatted || 'Sin fecha')}</span> 78 <span>${escapeHtml(getExpansionEmoji(expansion))} ${escapeHtml(expansion)}</span> 79 </div> 80 <div class="deck-card__source"> 81 <span>${deck.hasDeck ? 'Lista Untapped vinculada' : 'Lista pendiente'}</span> 82 <span>${deck.hasVideo ? 'Video directo' : 'Video pendiente'}</span> 83 </div> 84 <div class="deck-card__actions"> 85 ${deckAction} 86 ${videoAction} 87 </div> 88 </article>`; 89} 90 91export function renderDeckDetailModal(deck) { 92 const expansion = deck.expansion || deck.sourceSheet || 'Sin seccion'; 93 const deckAction = deck.hasDeck 94 ? `<a class="deck-card__btn deck-card__btn--primary" href="${escapeHtml(deck.untappedUrl)}" target="_blank" rel="noopener">${icon('external')} Abrir Untapped</a>` 95 : `<span class="deck-card__btn deck-card__btn--disabled" aria-disabled="true">${icon('external')} Sin lista Untapped</span>`; 96 const videoAction = deck.hasVideo 97 ? `<a class="deck-card__btn deck-card__btn--video" href="${escapeHtml(deck.youtubeUrl)}" target="_blank" rel="noopener">${icon('youtube')} Ver video</a>` 98 : `<span class="deck-card__btn deck-card__btn--disabled" aria-disabled="true">${icon('youtube')} Sin video</span>`; 99 100 return ` 101 <div class="modal-backdrop" data-modal-close> 102 <section class="deck-modal" role="dialog" aria-modal="true" aria-labelledby="deck-modal-title"> 103 <button class="modal-close" type="button" data-modal-close aria-label="Cerrar">${icon('x')}</button> 104 <div class="deck-modal__top"> 105 <div class="deck-card__colors">${renderManaDots(deck.colors)}</div> 106 ${renderFormatBadge(deck.format)} 107 </div> 108 <h2 id="deck-modal-title">${escapeHtml(deck.name)}</h2> 109 <dl class="deck-modal__meta"> 110 <div><dt>Fecha</dt><dd>${escapeHtml(deck.dateFormatted || 'Sin fecha')}</dd></div> 111 <div><dt>Seccion</dt><dd>${escapeHtml(expansion)}</dd></div> 112 <div><dt>Formato</dt><dd>${escapeHtml(deck.format)}</dd></div> 113 <div><dt>Estado</dt><dd>${deck.hasVideo && deck.hasDeck ? 'Video + mazo' : deck.hasVideo ? 'Solo video' : deck.hasDeck ? 'Solo lista' : 'Pendiente'}</dd></div> 114 </dl> 115 <div class="deck-modal__actions"> 116 ${deckAction} 117 ${videoAction} 118 </div> 119 </section> 120 </div>`; 121} 122 123export function renderExpansionHeader(expansion, count) { 124 return ` 125 <div class="expansion-header" id="exp-${escapeHtml(expansion.replace(/\s+/g, '-').toLowerCase())}"> 126 <div>
127 <span class="expansion-header__kicker">${escapeHtml(getExpansionEmoji(expansion))} Seccion</span> 128 <h2>${escapeHtml(expansion)}</h2> 129 </div> 130 <span class="expansion-count">${count} mazos</span> 131 </div>`; 132} 133 134export function renderStatsBar(stats) { 135 return ` 136 <section class="stats-bar" aria-label="Resumen de la coleccion"> 137 <div class="stat-item"><strong>${stats.total}</strong><span>Mazos</span></div> 138 <div class="stat-item"><strong>${stats.withDeck}</strong><span>con Untapped</span></div> 139 <div class="stat-item"><strong>${stats.withVideo}</strong><span>con video</span></div> 140 <div class="stat-item"><strong>${escapeHtml(stats.latestDate)}</strong><span>Ultimo mazo</span></div> 141 </section>`; 142} 143 144export function renderFilterBar(expansions, formats) { 145 const manaButtons = [ 146 { code: 'W', label: 'White' }, 147 { code: 'U', label: 'Blue' }, 148 { code: 'B', label: 'Black' }, 149 { code: 'R', label: 'Red' }, 150 { code: 'G', label: 'Green' }, 151 ]; 152 153 return ` 154 <section class="filter-bar" id="filter-bar" aria-label="Filtros"> 155 <div class="search-wrapper"> 156 ${icon('search')} 157 <input type="search" class="search-input" id="search-input" placeholder="Buscar por mazo, color o formato" autocomplete="off"> 158 </div> 159 <select class="filter-select" id="filter-expansion" aria-label="Filtrar por seccion"> 160 <option value="">Todas las secciones</option> 161 ${expansions.map(expansion => `<option value="${escapeHtml(expansion)}">${escapeHtml(expansion)}</option>`).join('')} 162 </select> 163 <select class="filter-select" id="filter-format" aria-label="Filtrar por formato"> 164 <option value="">Todos los formatos</option> 165 ${formats.map(format => `<option value="${escapeHtml(format)}">${escapeHtml(format)}</option>`).join('')} 166 </select> 167 <select class="filter-select" id="filter-publish-status" aria-label="Filtrar por estado de publicacion"> 168 <option value="published">Publicados</option> 169 <option value="scheduled">Programados</option> 170 <option value="all">Todos</option> 171 </select> 172 <select class="filter-select" id="filter-availability" aria-label="Filtrar por enlaces disponibles"> 173 <option value="">Todos los enlaces</option> 174 <option value="video-deck">Video + mazo</option> 175 <option value="video">Con video</option> 176 <option value="deck">Con Untapped</option> 177 <option value="missing-deck">Sin lista</option> 178 </select> 179 <div class="filter-colors" aria-label="Filtrar por colores"> 180 ${manaButtons.map(mana => ` 181 <button class="filter-btn filter-btn--mana" data-color="${mana.code}" title="${mana.label}" aria-label="${mana.label}"> 182 <span class="mana-dot mana-dot--lg" style="--mana:${MANA_COLORS[mana.code]}"></span> 183 </button> 184 `).join('')} 185 </div> 186 <button class="filter-btn filter-btn--clear" id="clear-filters">${icon('x')} Limpiar</button> 187 <button class="filter-btn filter-btn--refresh" id="refresh-data">${icon('refresh')} Actualizar</button> 188 <div class="filter-tags" id="active-tags"></div> 189 </section>`; 190} 191 192export function renderHeader() { 193 return ` 194 <header class="header" id="header"> 195 <a class="header__brand" href="#hero" aria-label="MagicBlogTK inicio"> 196 <img class="brand-logo" src="/icons/creator-logo.png?v=18" alt=""> 197 <span>MagicBlog<span>TK</span></span> 198 </a> 199 <div class="header__right"> 200 <a class="live-badge" id="live-badge" href="https://www.twitch.tv/magicblogtk_" target="_blank" rel="noopener" hidden> 201 <span class="live-badge__dot" aria-hidden="true"></span> 202 <span class="live-badge__text">EN DIRECTO</span> 203 </a> 204 <span class="header__tagline">Mazos, videos y listas del creador</span> 205 </div> 206 </header>`; 207} 208 209export function renderHero(creatorLinks = getCreatorLinks()) { 210 const links = { ...getCreatorLinks(), ...creatorLinks }; 211 return ` 212 <section class="hero" id="hero"> 213 <div class="hero__copy"> 214 <p class="hero__label">Magic: The Gathering Arena</p> 215 <h1>Mazos MTG Arena</h1> 216 <p>Videos, listas de Untapped y redes oficiales de MagicBlogTK.</p> 217 </div> 218 <div class="hero__panel hero__panel--socials" aria-label="Redes oficiales de MagicBlogTK"> 219 <div class="hero-social-grid" aria-label="Redes oficiales de MagicBlogTK"> 220 ${renderSocialGrid(links)} 221 </div> 222 </div> 223 </section>`; 224} 225 226export function renderFooter(creatorLinks = getCreatorLinks()) { 227 const links = { ...getCreatorLinks(), ...creatorLinks }; 228 return ` 229 <footer class="footer"> 230 <div> 231 <h2>MagicBlogTK</h2> 232 <p>Coleccion viva de mazos MTG Arena conectada a la hoja del creador.</p> 233 </div> 234 <div class="social-links"> 235 ${Object.entries(links).map(([key, link]) => ` 236 <a class="social-link social-link--${key}" href="${escapeHtml(link.url)}" target="_blank" rel="noopener"> 237 ${icon(link.icon)}
238 <span>${escapeHtml(link.label)}</span> 239 </a> 240 `).join('')} 241 </div> 242 </footer>`; 243} 244 245export function renderViewTabs(active = 'decks') { 246 const tabs = [ 247 { id: 'decks', label: 'Mazos' }, 248 { id: 'rotation', label: 'Rotación' }, 249 { id: 'banned', label: 'Baneadas' }, 250 ]; 251 return ` 252 <div class="view-tabs" role="tablist" aria-label="Secciones"> 253 ${tabs.map(tab => ` 254 <button class="view-tab ${tab.id === active ? 'active' : ''}" type="button" 255 role="tab" aria-selected="${tab.id === active}" data-view="${tab.id}">${tab.label}</button> 256 `).join('')} 257 </div>`; 258} 259 260function renderRotationTable(items, showTheme) { 261 return ` 262 <div class="info-table-wrap"><table class="info-table"> 263 <thead><tr><th>Colección</th><th>Rota en</th>${showTheme ? '<th>Tema</th>' : ''}</tr></thead> 264 <tbody> 265 ${items.map(item => ` 266 <tr> 267 <td>${escapeHtml(item.name)}</td> 268 <td><span class="year-pill">${escapeHtml(item.year || 'â')}</span></td> 269 ${showTheme ? `<td class="info-dim">${escapeHtml(item.theme || 'â')}</td>` : ''} 270 </tr> 271 `).join('')} 272 </tbody> 273 </table></div>`; 274} 275 276export function renderRotation({ legal, rotated }) { 277 return ` 278 <div class="expansion-header" style="border-top:0;margin-top:6px"> 279 <div> 280 <span class="expansion-header__kicker">Standard</span> 281 <h2>Rotación de formatos</h2> 282 </div> 283 <span class="expansion-count">${legal.length} legales</span> 284 </div> 285 <p class="info-lead">Cuándo rota cada colección fuera de Standard y su tema principal.</p> 286 ${renderRotationTable(legal, true)} 287 <div class="expansion-header"> 288 <div> 289 <span class="expansion-header__kicker">Historial</span> 290 <h2>Ya rotados</h2> 291 </div> 292 <span class="expansion-count">${rotated.length} colecciones</span> 293 </div> 294 ${renderRotationTable(rotated, false)}`; 295} 296 297export function renderBanned(formats) { 298 const total = formats.reduce((sum, group) => sum + group.cards.length, 0); 299 const chips = [{ id: 'ALL', label: 'Todos' }, ...formats.map(group => ({ id: group.format, label: group.format }))]; 300 301 return ` 302 <div class="expansion-header" style="border-top:0;margin-top:6px"> 303 <div> 304 <span class="expansion-header__kicker">Listas oficiales</span> 305 <h2>Cartas baneadas y restringidas</h2> 306 </div> 307 <span class="expansion-count" id="banned-results">${total} cartas</span> 308 </div> 309 <p class="info-lead">Busca una carta o elige un formato para ver dónde está prohibida.</p> 310 <div class="banned-controls"> 311 <div class="search-wrapper banned-search-wrap"> 312 ${icon('search')} 313 <input type="search" id="banned-search" class="search-input" placeholder="Buscar carta baneada" autocomplete="off"> 314 </div> 315 <div class="banned-chips" id="banned-chips" role="tablist"> 316 ${chips.map(chip => ` 317 <button class="banned-chip ${chip.id === 'ALL' ? 'active' : ''}" type="button" data-format="${escapeHtml(chip.id)}">${escapeHtml(chip.label)}</button> 318 `).join('')} 319 </div> 320 </div> 321 <div class="banned-grid" id="banned-grid"> 322 ${formats.map(group => ` 323 <section class="banned-card" data-format="${escapeHtml(group.format)}"> 324 <header class="banned-card__head"> 325 <h3>${escapeHtml(group.format)}</h3> 326 <span class="banned-count">${group.cards.length}</span> 327 </header> 328 <ul class="banned-list"> 329 ${group.cards.slice().sort((a, b) => a.localeCompare(b)).map(card => `<li>${escapeHtml(card)}</li>`).join('')} 330 </ul> 331 </section> 332 `).join('')} 333 </div> 334 <div class="empty-state banned-empty" id="banned-empty" hidden> 335 <h3>Sin resultados</h3> 336 <p>Ninguna carta coincide con la búsqueda.</p> 337 </div>`; 338} 339 340export function renderInfoState(message) { 341 return `<div class="empty-state"><h3>${escapeHtml(message)}</h3></div>`; 342} 343 344export function renderLoadingSkeletons(count = 9) { 345 return Array.from({ length: count }, () => ` 346 <div class="deck-card deck-card--skeleton">
347 <div class="skeleton skeleton--short"></div> 348 <div class="skeleton skeleton--title"></div> 349 <div class="skeleton"></div> 350 <div class="skeleton skeleton--actions"></div> 351 </div> 352 `).join(''); 353} 354 355export function renderEmptyState(query) { 356 return ` 357 <div class="empty-state"> 358 <h3>No hay resultados</h3> 359 <p>No encontramos mazos para "${escapeHtml(query)}". Prueba quitando algun filtro.</p> 360 </div>`; 361} 362 363let toastTimeout = null; 364export function showToast(message, type = 'success') { 365 let container = document.getElementById('toast-container'); 366 if (!container) { 367 container = document.createElement('div'); 368 container.id = 'toast-container'; 369 container.className = 'toast-container'; 370 document.body.appendChild(container); 371 } 372 373 container.innerHTML = `<div class="toast toast--${type}">${escapeHtml(message)}</div>`; 374 clearTimeout(toastTimeout); 375 toastTimeout = setTimeout(() => { 376 container.innerHTML = ''; 377 }, 2600); 378}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.