limpieza-img
4
app.py
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
/var/volumes/php/html/project-a13tv/
|
||||
894
static.bak/index.html
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
// ── 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
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 171 B After Width: | Height: | Size: 171 B |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 621 B |
|
Before Width: | Height: | Size: 242 B After Width: | Height: | Size: 242 B |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 888 B After Width: | Height: | Size: 888 B |
|
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 175 B |
|
Before Width: | Height: | Size: 208 B After Width: | Height: | Size: 208 B |
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 265 B After Width: | Height: | Size: 265 B |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 741 B After Width: | Height: | Size: 741 B |
|
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 175 B |
|
Before Width: | Height: | Size: 178 B After Width: | Height: | Size: 178 B |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 175 B |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 741 B After Width: | Height: | Size: 741 B |
@@ -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: {
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |