diff --git a/app.py b/app.py index cdd0c66..240eaa5 100644 --- a/app.py +++ b/app.py @@ -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(), diff --git a/static/index.html b/static/index.html index 7f0d1ed..a244d70 100644 --- a/static/index.html +++ b/static/index.html @@ -172,6 +172,28 @@ + +
+ + +
+
+ + +
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 = ``; const CHEVRON_RIGHT = ``; + 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 = + ` +
` + + codes.map(code => { + const isActive = App.activeCountries.has(code); + return ``; + }).join('') + + (count > 0 + ? `
+ ` + : ''); + } + 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} + data-current-status="${escAttr(status)}">${emoji} `; + }).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 = '';