limpieza-img

This commit is contained in:
2026-03-05 18:31:00 +01:00
parent e6dfdcd734
commit 8d8cc97bef
11109 changed files with 992 additions and 16413 deletions

4
app.py
View File

@@ -6,8 +6,8 @@ from datetime import datetime, timezone
from flask import Flask, jsonify, request, send_from_directory, Response
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOGOS_ROOT = os.path.join(BASE_DIR, 'tv-logos-main')
FLAGS_ROOT = os.path.join(BASE_DIR, 'svg')
LOGOS_ROOT = os.path.join(BASE_DIR, 'static', 'logos')
FLAGS_ROOT = os.path.join(BASE_DIR, 'static', 'flags')
DEFAULT_JSON = os.path.join(BASE_DIR, 'channels.json')
DEFAULT_M3U = os.path.join(BASE_DIR, 'example.m3u')
STATIC_DIR = os.path.join(BASE_DIR, 'static')

11
deploy.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Despliega static/ en release/project-a13tv/
SRC="$(dirname "$(realpath "$0")")/static"
DST="$(dirname "$(realpath "$0")")/release/project-a13tv"
echo "Desplegando en $DST ..."
# --no-group --no-owner: no intenta cambiar propietario/grupo en destino
rsync -rlt --no-group --no-owner --chmod=ug+rw "$SRC/" "$DST/"
echo "Despliegue completado."

1
release/project-a13tv Symbolic link
View File

@@ -0,0 +1 @@
/var/volumes/php/html/project-a13tv/

894
static.bak/index.html Normal file
View File

@@ -0,0 +1,894 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A13 TV</title>
<script src="tailwind.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
surface: {
950: '#07070f',
900: '#0d0d1a',
800: '#141428',
700: '#1c1c35',
}
}
}
}
}
</script>
<style>
:root {
--accent: #7c3aed;
--accent-light: #a78bfa;
--accent-glow: rgba(124, 58, 237, 0.25);
}
* { box-sizing: border-box; }
body {
background: #07070f;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2d2d4e; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3d3d6e; }
/* Channel card */
.channel-card {
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.channel-card:hover {
transform: translateY(-3px) scale(1.025);
border-color: rgba(124, 58, 237, 0.45) !important;
box-shadow: 0 12px 40px rgba(124, 58, 237, 0.18);
}
.channel-card:active {
transform: translateY(-1px) scale(1.01);
}
/* Logo fallback (no text, just icon) */
.logo-fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
flex-shrink: 0;
}
/* Sidebar group items */
.group-btn {
transition: background 0.12s ease, color 0.12s ease;
}
.group-btn:not(.active):hover {
background: rgba(255,255,255,0.05);
}
.group-btn.active {
background: rgba(124, 58, 237, 0.18);
border-left: 2px solid var(--accent);
color: var(--accent-light);
}
/* Mirror rows */
.mirror-row {
transition: background 0.1s ease;
}
.mirror-row:hover {
background: rgba(255,255,255,0.04);
}
/* Modal */
#modalOverlay {
animation: fadeOverlay 0.15s ease;
}
#modalCard {
animation: slideUp 0.2s cubic-bezier(0.34, 1.4, 0.64, 1);
}
@keyframes fadeOverlay {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(24px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Toast */
#toast {
transition: opacity 0.25s ease, transform 0.25s ease;
}
/* Dropdown menus */
.dropdown-menu {
animation: dropIn 0.12s ease;
}
@keyframes dropIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Spin loader */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 0.75s linear infinite; }
/* Search input focus ring */
#searchInput:focus {
border-color: rgba(124, 58, 237, 0.6) !important;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
}
</style>
</head>
<body class="text-gray-100 min-h-screen">
<!-- ═══════════════════════════════════════════════════════ TOP BAR ══════ -->
<header id="topbar"
class="fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-5 py-3"
style="background:rgba(7,7,15,0.9);backdrop-filter:blur(16px);
border-bottom:1px solid rgba(255,255,255,0.07);">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<svg class="w-6 h-6" style="color:var(--accent-light)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"
d="M15 10l4.553-2.069A1 1 0 0121 8.882v6.235a1 1 0 01-1.447.894L15 14
M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span class="font-bold text-base tracking-tight hidden sm:inline">
A13<span style="color:var(--accent-light)">TV</span>
</span>
</div>
<!-- Search -->
<div class="relative flex-1 max-w-sm">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input id="searchInput" type="text" placeholder="Buscar canales…"
class="w-full pl-8 pr-3 py-1.5 rounded-lg text-sm text-gray-200
placeholder-gray-600 outline-none transition-all"
style="background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);">
</div>
<!-- Count badge -->
<span id="countBadge" class="text-xs text-gray-600 whitespace-nowrap hidden md:block flex-shrink-0"></span>
<!-- Tag filter chips (populated dynamically) -->
<div id="topTagsBar" class="hidden items-center gap-1 flex-shrink-0 overflow-x-auto"></div>
<!-- Spacer -->
<div class="flex-1 hidden md:block"></div>
<!-- Source label -->
<span id="sourceLabel" class="text-xs text-gray-600 hidden lg:block flex-shrink-0 truncate max-w-32"></span>
<!-- Import dropdown -->
<div class="relative flex-shrink-0" id="importWrap">
<button id="importBtn" onclick="toggleDropdown('import')"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
text-gray-300 transition-colors hover:text-white"
style="background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
<span class="hidden sm:inline">Importar</span>
<svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="importMenu" class="dropdown-menu hidden absolute right-0 top-full mt-1.5 rounded-xl
overflow-hidden z-50 min-w-40"
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
box-shadow:0 16px 48px rgba(0,0,0,0.6);">
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
hover:text-white hover:bg-white/5 text-left transition-colors"
onclick="triggerFileInput('m3u')">
<svg class="w-4 h-4 flex-shrink-0" style="color:var(--accent-light)"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586
a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 8.414V19a2 2 0 01-2 2z"/>
</svg>
Importar M3U
</button>
<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
hover:text-white hover:bg-white/5 text-left transition-colors"
onclick="triggerFileInput('json')">
<svg class="w-4 h-4 flex-shrink-0 text-amber-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
Importar JSON
</button>
</div>
</div>
<!-- Export dropdown -->
<div class="relative flex-shrink-0" id="exportWrap">
<button id="exportBtn" onclick="toggleDropdown('export')"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
text-gray-300 transition-colors hover:text-white"
style="background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span class="hidden sm:inline">Exportar</span>
<svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="exportMenu" class="dropdown-menu hidden absolute right-0 top-full mt-1.5 rounded-xl
overflow-hidden z-50 min-w-40"
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
box-shadow:0 16px 48px rgba(0,0,0,0.6);">
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
hover:text-white hover:bg-white/5 text-left transition-colors"
onclick="doExport('json')">
<svg class="w-4 h-4 flex-shrink-0 text-amber-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
Exportar JSON
</button>
<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
hover:text-white hover:bg-white/5 text-left transition-colors"
onclick="doExport('m3u')">
<svg class="w-4 h-4 flex-shrink-0" style="color:var(--accent-light)"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586
a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 8.414V19a2 2 0 01-2 2z"/>
</svg>
Exportar M3U
</button>
</div>
</div>
<!-- Hidden file inputs -->
<input type="file" id="m3uFileInput" accept=".m3u,.m3u8" class="hidden"
onchange="handleImport('m3u',this)">
<input type="file" id="jsonFileInput" accept=".json" class="hidden"
onchange="handleImport('json',this)">
</header>
<!-- ═══════════════════════════════════════════════ MAIN LAYOUT ══════════ -->
<div class="flex min-h-screen" style="padding-top:52px">
<!-- Sidebar -->
<aside class="fixed left-0 bottom-0 w-52 overflow-y-auto z-20 hidden md:flex flex-col"
style="top:52px;background:rgba(7,7,15,0.97);border-right:1px solid rgba(255,255,255,0.06);">
<div class="p-3 pt-4 flex-1">
<nav id="groupNav"></nav>
</div>
</aside>
<!-- Content -->
<main class="flex-1 md:ml-52 p-5">
<!-- Loading -->
<div id="stateLoading" class="flex flex-col items-center justify-center gap-4 py-32">
<div class="w-9 h-9 rounded-full border-2 border-t-transparent spinner"
style="border-color:var(--accent);border-top-color:transparent"></div>
<p class="text-sm text-gray-600">Cargando canales…</p>
</div>
<!-- Empty -->
<div id="stateEmpty" class="hidden flex-col items-center justify-center gap-3 py-32">
<svg class="w-14 h-14 text-gray-800" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01
M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-gray-600 text-sm">No se encontraron canales</p>
</div>
<!-- Grid -->
<div id="channelGrid"
class="hidden grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
</div>
</main>
</div>
<!-- ═══════════════════════════════════════════════════════ MODAL ════════ -->
<div id="modal" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4">
<div id="modalOverlay" class="absolute inset-0"
style="background:rgba(0,0,0,0.78);backdrop-filter:blur(6px)"
onclick="closeModal()"></div>
<div id="modalCard" class="relative w-full max-w-2xl rounded-2xl overflow-hidden"
style="background:#10101f;border:1px solid rgba(255,255,255,0.1);
box-shadow:0 32px 80px rgba(0,0,0,0.85);">
<!-- Modal header -->
<div class="flex items-start gap-4 p-6 pb-5"
style="border-bottom:1px solid rgba(255,255,255,0.07)">
<div id="modalLogoWrap"
class="w-20 h-20 rounded-xl overflow-hidden flex-shrink-0 logo-placeholder text-xl"
style="font-size:1.25rem"></div>
<div class="flex-1 min-w-0 pt-0.5">
<h2 id="modalTitle" class="text-xl font-bold text-white leading-tight"></h2>
<div class="flex flex-wrap items-center gap-2 mt-2">
<span id="modalFlag" class="flex items-center"></span>
<span id="modalGroupBadge"
class="text-xs px-2.5 py-1 rounded-full font-medium"
style="background:rgba(124,58,237,0.2);color:var(--accent-light);
border:1px solid rgba(124,58,237,0.3)"></span>
<span id="modalSubcatBadge"
class="hidden text-xs px-2.5 py-1 rounded-full font-medium"
style="background:rgba(251,191,36,0.12);color:#fbbf24;
border:1px solid rgba(251,191,36,0.25)"></span>
</div>
<div id="modalTags" class="hidden flex gap-2 flex-wrap mt-1.5"></div>
<p id="modalMirrorCount" class="text-xs text-gray-600 mt-2"></p>
</div>
<button onclick="closeModal()"
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg
text-gray-600 hover:text-white hover:bg-white/8 transition-colors">
<svg class="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Mirror list header -->
<div class="flex items-center gap-4 px-6 py-2 text-[10px] uppercase tracking-widest text-gray-700 font-semibold"
style="border-bottom:1px solid rgba(255,255,255,0.04)">
<span class="w-5">#</span>
<span class="w-32">Calidad</span>
<span class="flex-1 hidden sm:block">Hash</span>
<span class="w-20 text-right">Acción</span>
</div>
<!-- Mirrors -->
<div id="modalMirrors" class="py-2 max-h-72 overflow-y-auto"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ TOAST ════════ -->
<div id="toast"
class="fixed bottom-6 right-6 z-50 opacity-0 translate-y-2 pointer-events-none
flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm font-medium"
style="background:#14142a;border:1px solid rgba(255,255,255,0.12);
box-shadow:0 8px 32px rgba(0,0,0,0.5);">
<span id="toastIcon"></span>
<span id="toastMsg"></span>
</div>
<!-- ═══════════════════════════════════════════════════ JAVASCRIPT ════════ -->
<script>
// ── Helpers ─────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
function escHTML(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escAttr(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;');
}
// ── App state ────────────────────────────────────────────────────────────────
const App = {
channels: [],
groups: [],
groupMeta: {},
activeGroup: null,
activeSubcategory: null,
activeTags: new Set(),
expandedGroups: new Set(),
search: '',
filtered() {
let ch = this.channels;
if (this.activeGroup) ch = ch.filter(c => c.group === this.activeGroup);
if (this.activeSubcategory) ch = ch.filter(c => c.subcategory === this.activeSubcategory);
if (this.activeTags.size) ch = ch.filter(c => c.tags && c.tags.some(t => this.activeTags.has(t)));
const q = this.search.toLowerCase().trim();
if (q) ch = ch.filter(c =>
c.name.toLowerCase().includes(q) ||
c.group.toLowerCase().includes(q)
);
return ch;
}
};
// ── Quality tier system ──────────────────────────────────────────────────────
const TIER_CONFIG = {
bronze: { style: 'background:rgba(100,55,10,0.5);color:#cd9a5a;border:1px solid rgba(180,110,40,0.5)' },
silver: { style: 'background:rgba(80,96,110,0.4);color:#e2e8f0;border:1px solid rgba(148,163,184,0.45)' },
gold: { style: 'background:rgba(120,88,5,0.45);color:#fcd34d;border:1px solid rgba(234,179,8,0.5)' },
diamond: { style: 'background:rgba(6,110,140,0.35);color:#a5f3fc;border:1px solid rgba(34,211,238,0.45)' },
};
const RES_TO_TIER = {
'240p': 'bronze', '360p': 'bronze',
'480p': 'silver', '720p': 'silver',
'1080p': 'gold', '1440p': 'gold',
'2160p': 'diamond', '4K': 'diamond',
};
function getQualityTier(res) { return RES_TO_TIER[res] || null; }
function tierBadge(res) {
const tier = getQualityTier(res);
if (!tier) return '';
const { style } = TIER_CONFIG[tier];
return `<span class="text-[10px] px-2 py-0.5 rounded font-mono font-semibold tracking-wide" style="${style}">${escHTML(res)}</span>`;
}
function bestMirrorTier(mirrors) {
const order = ['diamond', 'gold', 'silver', 'bronze'];
for (const t of order) {
if (mirrors.some(m => getQualityTier(m.resolution) === t)) return t;
}
return null;
}
// ── Flag helper ──────────────────────────────────────────────────────────────
const TV_ICON = `<svg class="w-7 h-7 opacity-30" fill="none" viewBox="0 0 24 24" stroke="white" stroke-width="1.2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 10l4.553-2.069A1 1 0 0121 8.882v6.235a1 1 0 01-1.447.894L15 14
M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>`;
function flagHTML(code) {
if (!code) return '';
return `<img src="/flags/${escAttr(code)}.svg" alt="${escAttr(code)}"
class="h-3.5 w-auto rounded-sm opacity-90"
onerror="this.style.display='none'" loading="lazy">`;
}
// ── Logo HTML helpers ────────────────────────────────────────────────────────
function logoImgHTML(url, cls) {
const safeSrc = escAttr(url);
return `<img src="${safeSrc}" alt="" class="${cls}"
style="object-fit:contain;padding:6px"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
loading="lazy">
<div class="${cls} logo-fallback rounded-xl" style="display:none">${TV_ICON}</div>`;
}
function logoFallback(cls) {
return `<div class="${cls} logo-fallback rounded-xl">${TV_ICON}</div>`;
}
// ── Render ───────────────────────────────────────────────────────────────────
function renderAll() {
renderSidebar();
renderGrid();
renderTopTags();
}
const CHEVRON_DOWN = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/></svg>`;
const CHEVRON_RIGHT = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>`;
function renderTopTags() {
const bar = $('topTagsBar');
const allTags = [...new Set(App.channels.flatMap(c => c.tags || []))].sort();
if (allTags.length === 0) {
bar.classList.add('hidden');
bar.classList.remove('flex');
return;
}
bar.classList.remove('hidden');
bar.classList.add('flex');
bar.innerHTML = allTags.map(tag => {
const isActive = App.activeTags.has(tag);
return `<button class="flex-shrink-0 px-2 py-1 rounded-lg text-sm leading-none transition-all"
style="${isActive
? 'background:rgba(124,58,237,0.3);border:1px solid rgba(124,58,237,0.6)'
: 'background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);opacity:0.6'}"
data-top-tag="${escAttr(tag)}">${escHTML(tag)}</button>`;
}).join('');
}
function renderSidebar() {
const total = App.channels.length;
const isAll = App.activeGroup === null && App.activeSubcategory === null && App.activeTags.size === 0;
// "Todos" button
let html = `<button class="group-btn flex items-center justify-between w-full px-3 py-2
rounded-lg text-sm mb-2 ${isAll ? 'active' : ''}"
data-action="all">
<span class="font-medium ${isAll ? '' : 'text-gray-500'}">Todos</span>
<span class="text-xs font-mono ${isAll ? '' : 'text-gray-700'}">${total}</span>
</button>`;
// Groups section
html += `<p class="text-[10px] uppercase tracking-widest font-semibold text-gray-600 px-2 mb-1">Grupos</p>`;
for (const g of App.groups) {
const count = App.channels.filter(c => c.group === g).length;
const isGroupActive = App.activeGroup === g;
const subcats = [...new Set(
App.channels.filter(c => c.group === g && c.subcategory).map(c => c.subcategory)
)].sort();
const hasSubcats = subcats.length > 0;
const isExpanded = App.expandedGroups.has(g) || isGroupActive;
const safeG = escHTML(g);
html += `<div class="flex items-center mb-0.5">
<button class="group-btn flex items-center justify-between flex-1 min-w-0 px-3 py-2
rounded-lg text-sm ${isGroupActive && !App.activeSubcategory ? 'active' : ''}"
data-group="${escAttr(g)}">
<span class="font-medium truncate ${isGroupActive ? '' : 'text-gray-500'}">${safeG}</span>
<span class="text-xs font-mono ml-2 flex-shrink-0 ${isGroupActive ? '' : 'text-gray-700'}">${count}</span>
</button>
${hasSubcats
? `<button class="flex-shrink-0 w-6 h-8 flex items-center justify-center ml-0.5
rounded text-gray-600 hover:text-gray-300 transition-colors"
data-expand-group="${escAttr(g)}">
${isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT}
</button>`
: ''}
</div>`;
// Subcategories — shown when group is expanded or active
if (hasSubcats && isExpanded) {
for (const sub of subcats) {
const subCount = App.channels.filter(c => c.group === g && c.subcategory === sub).length;
const isSubActive = App.activeSubcategory === sub;
html += `<button class="group-btn flex items-center justify-between w-full pl-7 pr-3 py-1.5
rounded-lg text-xs mb-0.5 ${isSubActive ? 'active' : ''}"
data-group="${escAttr(g)}" data-subcat="${escAttr(sub)}">
<span class="truncate ${isSubActive ? '' : 'text-gray-500'}">· ${escHTML(sub)}</span>
<span class="font-mono ml-2 flex-shrink-0 ${isSubActive ? '' : 'text-gray-700'}">${subCount}</span>
</button>`;
}
}
}
$('groupNav').innerHTML = html;
}
function renderGrid() {
const channels = App.filtered();
const grid = $('channelGrid');
const empty = $('stateEmpty');
const loading = $('stateLoading');
const badge = $('countBadge');
loading.classList.add('hidden');
if (channels.length === 0) {
grid.classList.add('hidden');
empty.classList.remove('hidden');
empty.classList.add('flex');
badge.textContent = '';
} else {
empty.classList.add('hidden');
empty.classList.remove('flex');
grid.classList.remove('hidden');
const total = App.channels.length;
badge.textContent = channels.length < total
? `${channels.length} / ${total} canales`
: `${total} canal${total !== 1 ? 'es' : ''}`;
grid.innerHTML = channels.map(channelCardHTML).join('');
}
}
function channelCardHTML(ch) {
const safeId = escAttr(ch.id);
const safeName = escHTML(ch.name);
const safeGrp = escHTML(ch.group);
const logoSection = ch.logo_url
? logoImgHTML(ch.logo_url, 'w-24 h-24 rounded-xl')
: logoFallback('w-24 h-24');
const best = bestMirrorTier(ch.mirrors);
const flagEl = flagHTML(ch.country_code || '');
const tagsEl = (ch.tags && ch.tags.length > 0)
? ch.tags.map(t => `<span class="text-sm leading-none" title="${escAttr(t)}">${escHTML(t)}</span>`).join('')
: '';
const qualityEl = best ? tierBadge(ch.mirrors.find(m => getQualityTier(m.resolution) === best)?.resolution) : '';
const infoRow = (flagEl || tagsEl || qualityEl)
? `<div class="flex items-center justify-between w-full px-1">
<div class="flex items-center gap-1">
${flagEl}
<div class="flex gap-0.5 flex-wrap">${tagsEl}</div>
</div>
<div class="flex-shrink-0">${qualityEl}</div>
</div>`
: '';
return `
<div class="channel-card rounded-xl p-4 flex flex-col items-center gap-2 select-none"
style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08)"
data-channel-id="${safeId}">
<div class="flex items-center justify-center">${logoSection}</div>
<div class="w-full text-center min-w-0">
<p class="text-sm font-semibold text-white leading-tight truncate" title="${escAttr(ch.name)}">${safeName}</p>
<p class="text-xs mt-0.5 truncate font-medium" style="color:var(--accent-light);opacity:0.75">${safeGrp}</p>
</div>
${infoRow}
</div>`;
}
// ── Modal ────────────────────────────────────────────────────────────────────
function openModal(channelId) {
const ch = App.channels.find(c => c.id === channelId);
if (!ch) return;
$('modalTitle').textContent = ch.name;
$('modalGroupBadge').textContent = ch.group;
const subEl = $('modalSubcatBadge');
if (ch.subcategory) {
subEl.textContent = ch.subcategory;
subEl.classList.remove('hidden');
} else {
subEl.classList.add('hidden');
}
// Tags
const tagsEl = $('modalTags');
if (ch.tags && ch.tags.length) {
tagsEl.innerHTML = ch.tags.map(t => `<span class="text-base">${escHTML(t)}</span>`).join('');
tagsEl.classList.remove('hidden');
} else {
tagsEl.classList.add('hidden');
}
const n = ch.mirrors.length;
$('modalMirrorCount').textContent = `${n} ${n === 1 ? 'mirror disponible' : 'mirrors disponibles'}`;
// Logo
const logoWrap = $('modalLogoWrap');
logoWrap.className = 'w-20 h-20 rounded-xl overflow-hidden flex-shrink-0';
if (ch.logo_url) {
logoWrap.innerHTML = logoImgHTML(ch.logo_url, 'w-full h-full');
} else {
logoWrap.innerHTML = `<div class="w-full h-full logo-fallback rounded-xl">${TV_ICON}</div>`;
}
// Flag in modal header (next to group badge)
const flagEl = $('modalFlag');
if (flagEl) flagEl.innerHTML = flagHTML(ch.country_code || '');
// Sort mirrors best → worst quality
const TIER_ORDER = ['diamond', 'gold', 'silver', 'bronze'];
const sorted = [...ch.mirrors].sort((a, b) => {
const ia = TIER_ORDER.indexOf(getQualityTier(a.resolution));
const ib = TIER_ORDER.indexOf(getQualityTier(b.resolution));
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
// Mirrors
$('modalMirrors').innerHTML = sorted.map((m, i) => {
const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
const hashFull = escHTML(m.acestream_hash);
const hashShort = m.acestream_hash.slice(0, 16) + '…';
return `
<div class="mirror-row flex items-center gap-3 px-4 py-2.5 rounded-lg mx-2 mb-0.5">
<span class="text-xs text-gray-700 font-mono w-5 flex-shrink-0 text-right">${i + 1}</span>
<div class="flex-shrink-0 w-20">
${tierBadge(m.resolution) || '<span class="text-xs text-gray-700">—</span>'}
</div>
<code class="flex-1 text-xs font-mono text-gray-600 truncate hidden sm:block"
title="${hashFull}">${hashShort}</code>
<button class="flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg
text-xs font-semibold transition-all w-20 justify-center"
style="background:rgba(124,58,237,0.2);color:var(--accent-light);
border:1px solid rgba(124,58,237,0.3)"
onmouseover="this.style.background='rgba(124,58,237,0.4)'"
onmouseout="this.style.background='rgba(124,58,237,0.2)'"
data-url="${safeUrl}">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Abrir
</button>
</div>`;
}).join('');
$('modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeModal() {
$('modal').classList.add('hidden');
document.body.style.overflow = '';
}
// ── Event delegation ─────────────────────────────────────────────────────────
// Channel grid click → open modal
$('channelGrid').addEventListener('click', e => {
const card = e.target.closest('[data-channel-id]');
if (card) openModal(card.dataset.channelId);
});
// Group nav click → filter / expand
$('groupNav').addEventListener('click', e => {
const btn = e.target.closest('[data-action], [data-expand-group], [data-group]');
if (!btn) return;
if (btn.dataset.action === 'all') {
App.activeGroup = null;
App.activeSubcategory = null;
App.activeTags.clear();
} else if (btn.dataset.expandGroup !== undefined) {
const g = btn.dataset.expandGroup;
if (App.expandedGroups.has(g)) App.expandedGroups.delete(g);
else App.expandedGroups.add(g);
renderSidebar();
return;
} else {
const g = btn.dataset.group;
const sub = btn.dataset.subcat;
if (App.activeGroup !== g) App.activeSubcategory = null;
App.activeGroup = g;
App.activeSubcategory = sub || null;
App.expandedGroups.add(g);
}
renderAll();
});
// Top bar tag chips
$('topTagsBar').addEventListener('click', e => {
const btn = e.target.closest('[data-top-tag]');
if (!btn) return;
const tag = btn.dataset.topTag;
if (App.activeTags.has(tag)) App.activeTags.delete(tag);
else App.activeTags.add(tag);
renderAll();
});
// Mirror open button
$('modalMirrors').addEventListener('click', e => {
const btn = e.target.closest('[data-url]');
if (btn) window.location.href = btn.dataset.url;
});
// Keyboard
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
// ── Search ───────────────────────────────────────────────────────────────────
let _searchTimer;
$('searchInput').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
App.search = e.target.value;
renderGrid();
}, 150);
});
// ── Dropdown menus ───────────────────────────────────────────────────────────
function toggleDropdown(which) {
const menus = { import: $('importMenu'), export: $('exportMenu') };
const other = which === 'import' ? 'export' : 'import';
menus[other].classList.add('hidden');
const menu = menus[which];
const isHidden = menu.classList.contains('hidden');
if (isHidden) {
menu.classList.remove('hidden');
} else {
menu.classList.add('hidden');
}
}
document.addEventListener('click', e => {
if (!$('importWrap').contains(e.target)) $('importMenu').classList.add('hidden');
if (!$('exportWrap').contains(e.target)) $('exportMenu').classList.add('hidden');
});
// ── Import ───────────────────────────────────────────────────────────────────
function triggerFileInput(type) {
$('importMenu').classList.add('hidden');
$(`${type}FileInput`).click();
}
async function handleImport(type, input) {
const file = input.files[0];
if (!file) return;
input.value = '';
$('importMenu').classList.add('hidden');
const fd = new FormData();
fd.append('file', file);
showToast(`Importando ${file.name}`);
try {
const res = await fetch(`/api/import/${type}`, { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error desconocido');
App.channels = data.channels;
App.groups = data.groups;
App.groupMeta = data.group_meta || {};
App.activeGroup = null;
App.activeSubcategory = null;
App.activeTags.clear();
App.expandedGroups.clear();
App.search = '';
$('searchInput').value = '';
$('sourceLabel').textContent = data.source || '';
renderAll();
showToast(`${data.total} canales cargados`, false);
} catch (err) {
showToast(`${err.message}`, true);
}
}
// ── Export ───────────────────────────────────────────────────────────────────
function doExport(type) {
$('exportMenu').classList.add('hidden');
window.location.href = `/api/export/${type}`;
}
// ── Toast ────────────────────────────────────────────────────────────────────
let _toastTimer;
function showToast(msg, isError = false) {
const toast = $('toast');
$('toastMsg').textContent = msg;
toast.style.borderColor = isError
? 'rgba(239,68,68,0.4)'
: 'rgba(124,58,237,0.35)';
toast.style.color = isError ? '#fca5a5' : '#c4b5fd';
toast.classList.remove('opacity-0', 'translate-y-2');
toast.classList.add('opacity-100', 'translate-y-0');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => {
toast.classList.remove('opacity-100', 'translate-y-0');
toast.classList.add('opacity-0', 'translate-y-2');
}, 3500);
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function init() {
try {
const res = await fetch('/api/channels');
const data = await res.json();
App.channels = data.channels;
App.groups = data.groups;
App.groupMeta = data.group_meta || {};
$('sourceLabel').textContent = data.source || '';
renderAll();
} catch (err) {
$('stateLoading').classList.add('hidden');
showToast('Error al cargar canales', true);
}
}
init();
</script>
</body>
</html>

83
static.bak/tailwind.js Normal file

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 171 B

View File

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 621 B

View File

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

Before

Width:  |  Height:  |  Size: 888 B

After

Width:  |  Height:  |  Size: 888 B

View File

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

View File

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 208 B

View File

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 527 B

View File

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 265 B

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 527 B

View File

Before

Width:  |  Height:  |  Size: 741 B

After

Width:  |  Height:  |  Size: 741 B

View File

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

View File

Before

Width:  |  Height:  |  Size: 178 B

After

Width:  |  Height:  |  Size: 178 B

View File

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

View File

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 741 B

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A13 TV</title>
<script src="/static/tailwind.js"></script>
<script src="tailwind.js"></script>
<script>
tailwind.config = {
theme: {

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Some files were not shown because too many files have changed in this diff Show More