seleccionar-idioma
This commit is contained in:
9
app.py
9
app.py
@@ -469,14 +469,17 @@ def api_export_m3u():
|
|||||||
def api_mirror_status():
|
def api_mirror_status():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
channel_id = data.get('channel_id')
|
channel_id = data.get('channel_id')
|
||||||
mirror_idx = data.get('mirror_idx')
|
acestream_hash = data.get('acestream_hash')
|
||||||
status = data.get('status', 'unknown')
|
status = data.get('status', 'unknown')
|
||||||
if status not in ('ok', 'issues', 'broken', 'unknown'):
|
if status not in ('ok', 'issues', 'broken', 'unknown'):
|
||||||
return jsonify({'error': 'invalid status'}), 400
|
return jsonify({'error': 'invalid status'}), 400
|
||||||
ch = next((c for c in state['channels'] if c['id'] == channel_id), None)
|
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
|
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 = {
|
export = {
|
||||||
'version': '1.0',
|
'version': '1.0',
|
||||||
'exported_at': datetime.now(timezone.utc).isoformat(),
|
'exported_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -172,6 +172,28 @@
|
|||||||
<!-- Source label -->
|
<!-- Source label -->
|
||||||
<span id="sourceLabel" class="text-xs text-gray-600 hidden lg:block flex-shrink-0 truncate max-w-32"></span>
|
<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 -->
|
<!-- Import dropdown -->
|
||||||
<div class="relative flex-shrink-0" id="importWrap">
|
<div class="relative flex-shrink-0" id="importWrap">
|
||||||
<button id="importBtn" onclick="toggleDropdown('import')"
|
<button id="importBtn" onclick="toggleDropdown('import')"
|
||||||
@@ -367,6 +389,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ════════ -->
|
<!-- ═══════════════════════════════════════════════════════ TOAST ════════ -->
|
||||||
<div id="toast"
|
<div id="toast"
|
||||||
@@ -403,6 +432,7 @@
|
|||||||
activeGroup: null,
|
activeGroup: null,
|
||||||
activeSubcategory: null,
|
activeSubcategory: null,
|
||||||
activeTags: new Set(),
|
activeTags: new Set(),
|
||||||
|
activeCountries: new Set(),
|
||||||
expandedGroups: new Set(),
|
expandedGroups: new Set(),
|
||||||
search: '',
|
search: '',
|
||||||
|
|
||||||
@@ -411,6 +441,7 @@
|
|||||||
if (this.activeGroup) ch = ch.filter(c => c.group === this.activeGroup);
|
if (this.activeGroup) ch = ch.filter(c => c.group === this.activeGroup);
|
||||||
if (this.activeSubcategory) ch = ch.filter(c => c.subcategory === this.activeSubcategory);
|
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();
|
const q = this.search.toLowerCase().trim();
|
||||||
if (q) ch = ch.filter(c =>
|
if (q) ch = ch.filter(c =>
|
||||||
c.name.toLowerCase().includes(q) ||
|
c.name.toLowerCase().includes(q) ||
|
||||||
@@ -501,11 +532,58 @@
|
|||||||
renderSidebar();
|
renderSidebar();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
renderTopTags();
|
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_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>`;
|
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() {
|
function renderTopTags() {
|
||||||
const bar = $('topTagsBar');
|
const bar = $('topTagsBar');
|
||||||
const allTags = [...new Set(App.channels.flatMap(c => c.tags || []))].sort();
|
const allTags = [...new Set(App.channels.flatMap(c => c.tags || []))].sort();
|
||||||
@@ -704,7 +782,7 @@
|
|||||||
const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
|
const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
|
||||||
const hashFull = escHTML(m.acestream_hash);
|
const hashFull = escHTML(m.acestream_hash);
|
||||||
const hashShort = m.acestream_hash.slice(0, 16) + '…';
|
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 { emoji, label, bg } = STATUS_CONFIG[status] || STATUS_CONFIG.unknown;
|
||||||
const safeHash = escAttr(m.acestream_hash);
|
const safeHash = escAttr(m.acestream_hash);
|
||||||
const safeChId = escAttr(ch.id);
|
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)"
|
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08)"
|
||||||
data-status-hash="${safeHash}"
|
data-status-hash="${safeHash}"
|
||||||
data-channel-id="${safeChId}"
|
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
|
<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"
|
text-xs font-semibold transition-all w-20 justify-center"
|
||||||
style="background:rgba(124,58,237,0.2);color:var(--accent-light);
|
style="background:rgba(124,58,237,0.2);color:var(--accent-light);
|
||||||
@@ -752,6 +830,57 @@
|
|||||||
document.body.style.overflow = '';
|
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 ─────────────────────────────────────────────────────────
|
// ── Event delegation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Channel grid click → open modal
|
// Channel grid click → open modal
|
||||||
@@ -769,6 +898,7 @@
|
|||||||
App.activeGroup = null;
|
App.activeGroup = null;
|
||||||
App.activeSubcategory = null;
|
App.activeSubcategory = null;
|
||||||
App.activeTags.clear();
|
App.activeTags.clear();
|
||||||
|
App.activeCountries.clear();
|
||||||
} else if (btn.dataset.expandGroup !== undefined) {
|
} else if (btn.dataset.expandGroup !== undefined) {
|
||||||
const g = btn.dataset.expandGroup;
|
const g = btn.dataset.expandGroup;
|
||||||
if (App.expandedGroups.has(g)) App.expandedGroups.delete(g);
|
if (App.expandedGroups.has(g)) App.expandedGroups.delete(g);
|
||||||
@@ -786,6 +916,27 @@
|
|||||||
renderAll();
|
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
|
// Top bar tag chips
|
||||||
$('topTagsBar').addEventListener('click', e => {
|
$('topTagsBar').addEventListener('click', e => {
|
||||||
const btn = e.target.closest('[data-top-tag]');
|
const btn = e.target.closest('[data-top-tag]');
|
||||||
@@ -800,21 +951,7 @@
|
|||||||
$('modalMirrors').addEventListener('click', e => {
|
$('modalMirrors').addEventListener('click', e => {
|
||||||
const statusBtn = e.target.closest('[data-status-hash]');
|
const statusBtn = e.target.closest('[data-status-hash]');
|
||||||
if (statusBtn) {
|
if (statusBtn) {
|
||||||
const hash = statusBtn.dataset.statusHash;
|
openStatusPopover(statusBtn);
|
||||||
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(() => {});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const btn = e.target.closest('[data-url]');
|
const btn = e.target.closest('[data-url]');
|
||||||
@@ -838,21 +975,21 @@
|
|||||||
|
|
||||||
// ── Dropdown menus ───────────────────────────────────────────────────────────
|
// ── Dropdown menus ───────────────────────────────────────────────────────────
|
||||||
function toggleDropdown(which) {
|
function toggleDropdown(which) {
|
||||||
const menus = { import: $('importMenu'), export: $('exportMenu') };
|
const menus = { import: $('importMenu'), export: $('exportMenu'), country: $('countryMenu') };
|
||||||
const other = which === 'import' ? 'export' : 'import';
|
Object.keys(menus).forEach(k => { if (k !== which) menus[k].classList.add('hidden'); });
|
||||||
menus[other].classList.add('hidden');
|
|
||||||
const menu = menus[which];
|
const menu = menus[which];
|
||||||
const isHidden = menu.classList.contains('hidden');
|
if (menu.classList.contains('hidden')) menu.classList.remove('hidden');
|
||||||
if (isHidden) {
|
else menu.classList.add('hidden');
|
||||||
menu.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
menu.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
if (!$('importWrap').contains(e.target)) $('importMenu').classList.add('hidden');
|
if (!$('importWrap').contains(e.target)) $('importMenu').classList.add('hidden');
|
||||||
if (!$('exportWrap').contains(e.target)) $('exportMenu').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 ───────────────────────────────────────────────────────────────────
|
// ── Import ───────────────────────────────────────────────────────────────────
|
||||||
@@ -883,6 +1020,7 @@
|
|||||||
App.activeGroup = null;
|
App.activeGroup = null;
|
||||||
App.activeSubcategory = null;
|
App.activeSubcategory = null;
|
||||||
App.activeTags.clear();
|
App.activeTags.clear();
|
||||||
|
App.activeCountries.clear();
|
||||||
App.expandedGroups.clear();
|
App.expandedGroups.clear();
|
||||||
App.search = '';
|
App.search = '';
|
||||||
$('searchInput').value = '';
|
$('searchInput').value = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user