seleccionar-idioma

This commit is contained in:
2026-03-10 13:33:01 +01:00
parent 218983660c
commit e7e124527c
2 changed files with 173 additions and 32 deletions

9
app.py
View File

@@ -469,14 +469,17 @@ def api_export_m3u():
def api_mirror_status():
data = request.get_json()
channel_id = data.get('channel_id')
mirror_idx = data.get('mirror_idx')
acestream_hash = data.get('acestream_hash')
status = data.get('status', 'unknown')
if status not in ('ok', 'issues', 'broken', 'unknown'):
return jsonify({'error': 'invalid status'}), 400
ch = next((c for c in state['channels'] if c['id'] == channel_id), None)
if not ch or not isinstance(mirror_idx, int) or mirror_idx >= len(ch['mirrors']):
if not ch:
return jsonify({'error': 'not found'}), 404
ch['mirrors'][mirror_idx]['status'] = status
m = next((m for m in ch['mirrors'] if m['acestream_hash'] == acestream_hash), None)
if not m:
return jsonify({'error': 'not found'}), 404
m['status'] = status
export = {
'version': '1.0',
'exported_at': datetime.now(timezone.utc).isoformat(),

View File

@@ -172,6 +172,28 @@
<!-- Source label -->
<span id="sourceLabel" class="text-xs text-gray-600 hidden lg:block flex-shrink-0 truncate max-w-32"></span>
<!-- Country filter dropdown -->
<div class="relative flex-shrink-0" id="countryWrap">
<button id="countryBtn" onclick="toggleDropdown('country')"
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);">
<span>🌐</span>
<span class="hidden sm:inline">Idiomas</span>
<span id="countryBtnCount"
class="hidden text-[10px] font-bold px-1.5 py-0.5 rounded-full"
style="background:rgba(124,58,237,0.4);color:#c4b5fd"></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="countryMenu"
class="dropdown-menu hidden absolute right-0 top-full mt-1.5 rounded-xl overflow-hidden z-50"
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
box-shadow:0 16px 48px rgba(0,0,0,0.6);min-width:160px;max-height:320px;overflow-y:auto">
</div>
</div>
<!-- Import dropdown -->
<div class="relative flex-shrink-0" id="importWrap">
<button id="importBtn" onclick="toggleDropdown('import')"
@@ -367,6 +389,13 @@
</div>
</div>
<!-- Status popover (fixed, shared across all mirror rows) -->
<div id="statusPopover"
class="hidden fixed z-[300] rounded-xl overflow-hidden"
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
box-shadow:0 16px 48px rgba(0,0,0,0.6);min-width:160px">
</div>
<!-- ═══════════════════════════════════════════════════════ TOAST ════════ -->
<div id="toast"
@@ -403,6 +432,7 @@
activeGroup: null,
activeSubcategory: null,
activeTags: new Set(),
activeCountries: new Set(),
expandedGroups: new Set(),
search: '',
@@ -410,7 +440,8 @@
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)));
if (this.activeTags.size) ch = ch.filter(c => c.tags && c.tags.some(t => this.activeTags.has(t)));
if (this.activeCountries.size) ch = ch.filter(c => this.activeCountries.has(c.country_code));
const q = this.search.toLowerCase().trim();
if (q) ch = ch.filter(c =>
c.name.toLowerCase().includes(q) ||
@@ -501,11 +532,58 @@
renderSidebar();
renderGrid();
renderTopTags();
renderTopCountries();
}
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 renderTopCountries() {
const codes = [...new Set(App.channels.map(c => c.country_code).filter(Boolean))].sort();
const wrap = $('countryWrap');
if (!wrap) return;
if (codes.length === 0) { wrap.classList.add('hidden'); return; }
wrap.classList.remove('hidden');
const count = App.activeCountries.size;
const badge = $('countryBtnCount');
if (count > 0) { badge.textContent = count; badge.classList.remove('hidden'); }
else { badge.classList.add('hidden'); }
const allActive = count === 0;
$('countryMenu').innerHTML =
`<button class="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm
text-gray-300 hover:text-white hover:bg-white/5 text-left transition-colors"
style="${allActive ? 'background:rgba(124,58,237,0.15)' : ''}"
data-country-all>
<span class="w-5 text-center">🌐</span>
<span class="flex-1">Todos</span>
${allActive ? '<span class="text-purple-400 text-xs">✓</span>' : ''}
</button>
<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>`
+ codes.map(code => {
const isActive = App.activeCountries.has(code);
return `<button class="flex items-center gap-2.5 w-full px-4 py-2 text-sm
text-gray-300 hover:text-white hover:bg-white/5 transition-colors"
style="${isActive ? 'background:rgba(124,58,237,0.1)' : ''}"
data-country="${escAttr(code)}">
<img src="flags/${escAttr(code)}.svg"
class="w-5 h-3.5 rounded-sm object-cover flex-shrink-0"
onerror="this.style.display='none'">
<span class="flex-1 text-left font-mono text-xs uppercase">${escHTML(code)}</span>
${isActive ? '<span class="text-purple-400 text-xs">✓</span>' : ''}
</button>`;
}).join('')
+ (count > 0
? `<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>
<button class="flex items-center gap-2 w-full px-4 py-2.5 text-sm
text-red-400 hover:text-red-300 hover:bg-white/5 transition-colors"
data-country-clear>
<span>✕</span><span class="ml-1">Borrar selección</span>
</button>`
: '');
}
function renderTopTags() {
const bar = $('topTagsBar');
const allTags = [...new Set(App.channels.flatMap(c => c.tags || []))].sort();
@@ -704,7 +782,7 @@
const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
const hashFull = escHTML(m.acestream_hash);
const hashShort = m.acestream_hash.slice(0, 16) + '…';
const status = getMirrorStatus(m.acestream_hash);
const status = m.status || getMirrorStatus(m.acestream_hash);
const { emoji, label, bg } = STATUS_CONFIG[status] || STATUS_CONFIG.unknown;
const safeHash = escAttr(m.acestream_hash);
const safeChId = escAttr(ch.id);
@@ -724,7 +802,7 @@
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08)"
data-status-hash="${safeHash}"
data-channel-id="${safeChId}"
data-mirror-idx="${i}">${emoji}</button>
data-current-status="${escAttr(status)}">${emoji}</button>
<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);
@@ -752,6 +830,57 @@
document.body.style.overflow = '';
}
// ── Status popover ───────────────────────────────────────────────────────────
let _activeStatusBtn = null;
function openStatusPopover(btn) {
_activeStatusBtn = btn;
const cur = btn.dataset.currentStatus || 'unknown';
$('statusPopover').innerHTML = STATUS_CYCLE.map(s => {
const { emoji, label } = STATUS_CONFIG[s];
const isActive = s === cur;
return `<button class="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm
text-gray-300 hover:text-white hover:bg-white/5 text-left transition-colors"
style="${isActive ? 'background:rgba(124,58,237,0.15)' : ''}"
data-set-status="${escAttr(s)}">
<span class="w-5 text-center">${emoji}</span>
<span>${escHTML(label)}</span>
${isActive ? '<span class="ml-auto text-purple-400 text-xs">✓</span>' : ''}
</button>`;
}).join('');
const pop = $('statusPopover');
pop.classList.remove('hidden');
const rect = btn.getBoundingClientRect();
const popH = pop.offsetHeight || 160;
const top = (rect.bottom + 4 + popH > window.innerHeight)
? rect.top - popH - 4
: rect.bottom + 4;
pop.style.top = `${top}px`;
pop.style.left = `${rect.left}px`;
}
$('statusPopover').addEventListener('click', e => {
const opt = e.target.closest('[data-set-status]');
if (!opt || !_activeStatusBtn) return;
const next = opt.dataset.setStatus;
const btn = _activeStatusBtn;
const hash = btn.dataset.statusHash;
setMirrorStatus(hash, next);
btn.textContent = STATUS_CONFIG[next].emoji;
btn.title = STATUS_CONFIG[next].label;
btn.dataset.currentStatus = next;
btn.closest('.mirror-row').style.background = STATUS_CONFIG[next].bg;
const ch = App.channels.find(c => c.id === btn.dataset.channelId);
if (ch) { const m = ch.mirrors.find(m => m.acestream_hash === hash); if (m) m.status = next; }
fetch('/api/mirror/status', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: btn.dataset.channelId, acestream_hash: hash, status: next }),
}).catch(() => {});
$('statusPopover').classList.add('hidden');
_activeStatusBtn = null;
});
// ── Event delegation ─────────────────────────────────────────────────────────
// Channel grid click → open modal
@@ -769,6 +898,7 @@
App.activeGroup = null;
App.activeSubcategory = null;
App.activeTags.clear();
App.activeCountries.clear();
} else if (btn.dataset.expandGroup !== undefined) {
const g = btn.dataset.expandGroup;
if (App.expandedGroups.has(g)) App.expandedGroups.delete(g);
@@ -786,6 +916,27 @@
renderAll();
});
// Country dropdown menu
$('countryMenu').addEventListener('click', e => {
if (e.target.closest('[data-country-all]')) {
App.activeCountries.clear();
$('countryMenu').classList.add('hidden');
renderAll();
return;
}
if (e.target.closest('[data-country-clear]')) {
App.activeCountries.clear();
renderAll();
return;
}
const btn = e.target.closest('[data-country]');
if (!btn) return;
const code = btn.dataset.country;
if (App.activeCountries.has(code)) App.activeCountries.delete(code);
else App.activeCountries.add(code);
renderAll();
});
// Top bar tag chips
$('topTagsBar').addEventListener('click', e => {
const btn = e.target.closest('[data-top-tag]');
@@ -800,21 +951,7 @@
$('modalMirrors').addEventListener('click', e => {
const statusBtn = e.target.closest('[data-status-hash]');
if (statusBtn) {
const hash = statusBtn.dataset.statusHash;
const cur = getMirrorStatus(hash);
const next = STATUS_CYCLE[(STATUS_CYCLE.indexOf(cur) + 1) % STATUS_CYCLE.length];
setMirrorStatus(hash, next);
statusBtn.textContent = STATUS_CONFIG[next].emoji;
statusBtn.title = STATUS_CONFIG[next].label;
fetch('/api/mirror/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: statusBtn.dataset.channelId,
mirror_idx: parseInt(statusBtn.dataset.mirrorIdx),
status: next,
}),
}).catch(() => {});
openStatusPopover(statusBtn);
return;
}
const btn = e.target.closest('[data-url]');
@@ -838,21 +975,21 @@
// ── Dropdown menus ───────────────────────────────────────────────────────────
function toggleDropdown(which) {
const menus = { import: $('importMenu'), export: $('exportMenu') };
const other = which === 'import' ? 'export' : 'import';
menus[other].classList.add('hidden');
const menus = { import: $('importMenu'), export: $('exportMenu'), country: $('countryMenu') };
Object.keys(menus).forEach(k => { if (k !== which) menus[k].classList.add('hidden'); });
const menu = menus[which];
const isHidden = menu.classList.contains('hidden');
if (isHidden) {
menu.classList.remove('hidden');
} else {
menu.classList.add('hidden');
}
if (menu.classList.contains('hidden')) 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');
if (!$('importWrap').contains(e.target)) $('importMenu').classList.add('hidden');
if (!$('exportWrap').contains(e.target)) $('exportMenu').classList.add('hidden');
if (!$('countryWrap').contains(e.target)) $('countryMenu').classList.add('hidden');
if (_activeStatusBtn && !$('statusPopover').contains(e.target) && e.target !== _activeStatusBtn) {
$('statusPopover').classList.add('hidden');
_activeStatusBtn = null;
}
});
// ── Import ───────────────────────────────────────────────────────────────────
@@ -883,6 +1020,7 @@
App.activeGroup = null;
App.activeSubcategory = null;
App.activeTags.clear();
App.activeCountries.clear();
App.expandedGroups.clear();
App.search = '';
$('searchInput').value = '';