// Online Radio Explorer — Admin // // Views: // - Stations: paginated list, search, bulk select, edit dialog with tabs. // - Discover: search radio-browser, preview, bulk import. // - Leaderboard: top stations by plays/votes + moderation actions. // - Rooms: see who's connected to which named room, delete shared rooms. // - Users: existing CRUD. // - System: counters + cache stats. // // The station edit dialog has four tabs: Details / Streams / Image / Stats. // Image tab supports drag-drop upload, refetch-from-URL, and clear-cache. // A tiny per-station preview player lets the admin audition a stream // without leaving the table. import { api } from '../shared/api.js'; import { el, clear } from '../shared/dom.js'; import { Player } from '../player.js'; const app = document.getElementById('app'); const state = { user: null, view: 'stations', stations: [], users: [], rooms: [], leaderboard: [], system: null, search: '', sourceFilter: '', selected: new Set(), // Discover view scratch: discoverResults: [], discoverQuery: { q: '', country: '', tag: '' } }; // One shared preview player instance for the admin (so only one stream plays // at a time across the whole UI). const preview = new Player({ onState: (s) => { Object.assign(previewState, s); paintPreviewButtons(); } }); const previewState = { stationId: null, playing: false, loading: false }; // ---------- bootstrap ---------- async function bootstrap() { try { state.user = await api.get('/api/auth/me'); } catch { return showLogin(); } if (state.user.role !== 'admin') { app.innerHTML = `

Admin only

Signed in as ${state.user.username} (${state.user.role}).

`; return; } await refresh(); render(); } async function refresh() { const tasks = [api.get('/api/stations?all=1')]; if (state.view === 'users') tasks.push(api.get('/api/auth/users')); if (state.view === 'system') tasks.push(api.get('/api/admin/system')); if (state.view === 'rooms') tasks.push(api.get('/api/admin/rooms')); if (state.view === 'leaderboard') tasks.push(api.get('/api/admin/leaderboard')); const results = await Promise.all(tasks); state.stations = results[0]; if (state.view === 'users') state.users = results[1] || []; if (state.view === 'system') state.system = results[1] || null; if (state.view === 'rooms') state.rooms = results[1] || []; if (state.view === 'leaderboard') state.leaderboard = results[1] || []; } function showLogin() { clear(app); app.appendChild(el('div', { class: 'login' }, el('form', { onSubmit: async (e) => { e.preventDefault(); const fd = new FormData(e.target); try { state.user = await api.post('/api/auth/login', { username: fd.get('username'), password: fd.get('password') }); await bootstrap(); } catch (err) { e.target.querySelector('.err').textContent = err.message; } } }, el('h1', {}, 'Admin sign in'), el('input', { name: 'username', placeholder: 'Username', required: true }), el('input', { name: 'password', type: 'password', placeholder: 'Password', required: true }), el('div', { class: 'err' }), el('button', { class: 'btn primary', type: 'submit' }, 'Sign in') ))); } function render() { clear(app); const views = ['stations', 'discover', 'leaderboard', 'rooms', 'users', 'system']; const side = el('aside', { class: 'side' }, el('h1', {}, 'OnlineRadio · Admin'), ...views.map((v) => el('button', { class: `nav ${state.view === v ? 'active' : ''}`, onClick: async () => { state.view = v; state.selected.clear(); await refresh(); render(); } }, label(v))), el('div', { class: 'me' }, `Signed in as ${state.user.username}`, el('br'), el('a', { href: '/master', target: '_blank' }, 'Open master ↗'), el('br'), el('a', { href: '/', target: '_blank' }, 'Open kiosk ↗'), el('br'), el('a', { href: '#', onClick: async (e) => { e.preventDefault(); await api.post('/api/auth/logout'); location.reload(); } }, 'Sign out')) ); const main = el('main', { class: 'main' }); // Append BEFORE delegating to the per-view renderers — they rely on // document.getElementById() to find slots they just inserted into `main`, // which only works once `main` is actually in the DOM. app.appendChild(el('div', { class: 'shell' }, side, main)); if (state.view === 'stations') renderStations(main); else if (state.view === 'discover') renderDiscover(main); else if (state.view === 'leaderboard') renderLeaderboard(main); else if (state.view === 'rooms') renderRooms(main); else if (state.view === 'users') renderUsers(main); else if (state.view === 'system') renderSystem(main); } const label = (v) => ({ stations: 'Stations', discover: 'Discover', leaderboard: 'Leaderboard', rooms: 'Rooms', users: 'Users', system: 'System' })[v]; // ============================================================ // Stations view // ============================================================ function renderStations(root) { root.appendChild(el('div', { class: 'bar' }, el('input', { placeholder: 'Search name / country / genre…', value: state.search, onInput: (e) => { state.search = e.target.value; renderStationsTable(); } }), el('select', { onChange: (e) => { state.sourceFilter = e.target.value; renderStationsTable(); } }, el('option', { value: '' }, 'All sources'), ...uniqueSources(state.stations).map((s) => el('option', { value: s, selected: state.sourceFilter === s }, s)) ), el('button', { class: 'btn primary', onClick: () => openStationDialog() }, '+ Add station'), el('button', { class: 'btn', onClick: async () => { if (!confirm('Run health check on every stream?')) return; const r = await api.post('/api/admin/health-check'); alert(`Checked ${r.checked} streams.`); await refresh(); render(); } }, 'Health check'), )); root.appendChild(el('div', { id: 'bulkSlot' })); root.appendChild(el('div', { id: 'tableWrap' })); renderStationsTable(); } function uniqueSources(stations) { return [...new Set(stations.map((s) => s.source).filter(Boolean))].sort(); } function renderStationsTable() { const wrap = document.getElementById('tableWrap'); const bulkSlot = document.getElementById('bulkSlot'); if (!wrap || !bulkSlot) return; clear(wrap); clear(bulkSlot); const q = state.search.toLowerCase(); const src = state.sourceFilter; const filtered = state.stations.filter((s) => (!src || s.source === src) && (!q || s.name.toLowerCase().includes(q) || (s.country || '').toLowerCase().includes(q) || (s.genres || []).some((g) => g.toLowerCase().includes(q))) ); if (state.selected.size) bulkSlot.appendChild(renderBulkBar(filtered)); const allOnPageSelected = filtered.length && filtered.every((s) => state.selected.has(s.id)); const table = el('table', {}, el('thead', {}, el('tr', {}, el('th', { style: { width: '32px' } }, el('input', { type: 'checkbox', checked: allOnPageSelected, onChange: (e) => { if (e.target.checked) filtered.forEach((s) => state.selected.add(s.id)); else filtered.forEach((s) => state.selected.delete(s.id)); renderStationsTable(); } })), el('th', {}, 'Name'), el('th', {}, 'Source'), el('th', {}, 'Genres'), el('th', {}, 'Country'), el('th', {}, 'Streams'), el('th', {}, '▲/▼/▶'), el('th', {}, 'On'), el('th', {}, 'Actions'))), el('tbody', {}, ...filtered.map((s) => { const art = s.image_display_url || s.image_url; return el('tr', { 'data-id': s.id }, el('td', {}, el('input', { type: 'checkbox', checked: state.selected.has(s.id), onChange: (e) => { if (e.target.checked) state.selected.add(s.id); else state.selected.delete(s.id); renderStationsTable(); } })), el('td', {}, el('div', { class: 'station-cell' }, el('div', { class: 'station-art-thumb' + (art ? '' : ' empty') }, art ? el('img', { src: art, alt: '', loading: 'lazy', referrerpolicy: 'no-referrer', onError: (e) => { const parent = e.target.parentNode; e.target.remove(); if (parent) parent.classList.add('empty'); } }) : null ), el('div', { class: 'meta' }, el('strong', {}, s.name), el('small', {}, s.homepage || s.uuid)) )), el('td', {}, s.source || ''), el('td', {}, ...(s.genres || []).slice(0, 3).map((g) => el('span', { class: 'tag' }, g))), el('td', {}, s.country || ''), el('td', {}, String(s.stream_count ?? '—')), el('td', {}, `${s.up || 0}/${s.down || 0}/${s.plays || 0}`), el('td', {}, s.enabled ? '✓' : '✗'), el('td', {}, renderPreviewButton(s), ' ', el('button', { class: 'btn', onClick: () => openStationDialog(s.id) }, 'Edit'), ' ', el('button', { class: 'btn danger', onClick: async () => { if (!await confirmStationDelete({ stations: [s] })) return; await api.del(`/api/admin/stations/${s.id}`); await refresh(); render(); } }, '×') ) ); })) ); wrap.appendChild(table); } function renderBulkBar(filtered) { const ids = [...state.selected]; async function run(action, confirmMsg) { if (confirmMsg && !confirm(`${confirmMsg} (${ids.length} stations)`)) return; const r = await api.post('/api/admin/stations/bulk', { ids, action }); alert(`${action}: ${r.ok} ok / ${r.failed} failed`); state.selected.clear(); await refresh(); render(); } async function runDelete() { const targets = state.stations.filter((s) => state.selected.has(s.id)); if (!targets.length) return; if (!await confirmStationDelete({ stations: targets })) return; const r = await api.post('/api/admin/stations/bulk', { ids: targets.map((s) => s.id), action: 'delete' }); alert(`delete: ${r.ok} ok / ${r.failed} failed`); state.selected.clear(); await refresh(); render(); } return el('div', { class: 'bulkbar' }, el('span', { class: 'count' }, `${ids.length} selected`), el('button', { class: 'btn', onClick: () => run('enable') }, 'Enable'), el('button', { class: 'btn', onClick: () => run('disable') }, 'Disable'), el('button', { class: 'btn', onClick: () => run('scrape-icon', 'Scrape icons for') }, 'Scrape icons'), el('button', { class: 'btn', onClick: () => run('refetch-image', 'Refetch images for') }, 'Refetch images'), el('button', { class: 'btn danger', onClick: () => runDelete() }, 'Delete'), el('button', { class: 'btn', onClick: () => { state.selected.clear(); renderStationsTable(); } }, 'Clear'), ); } function renderPreviewButton(station) { const playing = previewState.stationId === station.id && previewState.playing; return el('span', { class: 'preview-player' + (playing ? ' playing' : ''), 'data-preview-station': station.id }, el('button', { title: 'Preview', onClick: (e) => { e.stopPropagation(); if (previewState.stationId === station.id && previewState.playing) { preview.stop(); previewState.stationId = null; previewState.playing = false; } else { previewState.stationId = station.id; preview.play(station); } paintPreviewButtons(); } }, playing ? '❚❚' : '▶') ); } function paintPreviewButtons() { document.querySelectorAll('.preview-player').forEach((nodeEl) => { const id = Number(nodeEl.getAttribute('data-preview-station')); const isPlaying = previewState.stationId === id && previewState.playing; nodeEl.classList.toggle('playing', isPlaying); const btn = nodeEl.querySelector('button'); if (btn) btn.textContent = isPlaying ? '❚❚' : (previewState.stationId === id && previewState.loading ? '…' : '▶'); }); } // ============================================================ // Station edit dialog (tabs) // ============================================================ async function openStationDialog(id) { const station = id ? await api.get(`/api/stations/${id}`) : { name: '', genres: [], streams: [], enabled: true, image_url: '', country: '', homepage: '' }; const dlg = el('dialog', { class: 'wide' }); let activeTab = 'details'; const body = el('div', { class: 'tab-body' }); const tabs = el('div', { class: 'tabs' }, ...[ ['details', 'Details'], ['streams', 'Streams'], ['image', 'Image'], ['stats', 'Stats'] ].map(([key, lbl]) => el('button', { type: 'button', class: activeTab === key ? 'active' : '', onClick: () => { activeTab = key; paintTabs(); } }, lbl)) ); function paintTabs() { clear(body); tabs.querySelectorAll('button').forEach((b, i) => { const key = ['details', 'streams', 'image', 'stats'][i]; b.classList.toggle('active', key === activeTab); }); if (activeTab === 'details') paintDetails(body, station); else if (activeTab === 'streams') paintStreams(body, station); else if (activeTab === 'image') paintImage(body, station, async () => { // After upload/refetch/delete: re-fetch station so previews refresh. if (id) Object.assign(station, await api.get(`/api/stations/${id}`)); paintTabs(); }); else if (activeTab === 'stats') paintStats(body, station, id); } paintTabs(); const footer = el('div', { class: 'actions' }, id ? el('button', { class: 'btn danger', type: 'button', onClick: async () => { if (!await confirmStationDelete({ stations: [station] })) return; await api.del(`/api/admin/stations/${id}`); dlg.close(); await refresh(); render(); } }, 'Delete') : null, el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'), el('button', { class: 'btn primary', type: 'button', onClick: async () => { const payload = { name: station.name, homepage: station.homepage, country: station.country, genres: station.genres, description: station.description, image_url: station.image_url, category: station.category, enabled: station.enabled }; if (id) { await api.patch(`/api/admin/stations/${id}`, payload); } else { payload.streams = (station.streams || []).filter((s) => s.url); const created = await api.post('/api/stations', payload); Object.assign(station, created); } dlg.close(); await refresh(); render(); } }, 'Save') ); dlg.appendChild(el('form', { method: 'dialog', onSubmit: (e) => e.preventDefault() }, el('h2', {}, id ? `Edit · ${station.name}` : 'Add station'), tabs, body, footer )); document.body.appendChild(dlg); dlg.showModal(); dlg.addEventListener('close', () => dlg.remove()); } function paintDetails(root, station) { root.appendChild(el('div', { class: 'row' }, el('label', {}, 'Name'), el('input', { value: station.name || '', onInput: (e) => station.name = e.target.value, required: true }))); root.appendChild(el('div', { class: 'row' }, el('label', {}, 'Homepage'), el('input', { value: station.homepage || '', onInput: (e) => station.homepage = e.target.value }))); root.appendChild(el('div', { class: 'row' }, el('label', {}, 'Country'), el('input', { value: station.country || '', maxlength: 4, onInput: (e) => station.country = e.target.value }))); root.appendChild(el('div', { class: 'row' }, el('label', {}, 'Genres (CSV)'), el('input', { value: (station.genres || []).join(', '), onInput: (e) => station.genres = e.target.value.split(',').map((s) => s.trim()).filter(Boolean) }))); root.appendChild(el('div', { class: 'row' }, el('label', {}, 'Category'), el('input', { value: station.category || '', onInput: (e) => station.category = e.target.value }))); root.appendChild(el('div', { class: 'row' }, el('label', {}, 'Description'), el('textarea', { rows: 4, placeholder: 'Short description shown to listeners', onInput: (e) => station.description = e.target.value }, station.description || ''))); root.appendChild(el('div', { class: 'row' }, el('label', {}, 'Enabled'), el('input', { type: 'checkbox', checked: station.enabled, onChange: (e) => station.enabled = e.target.checked }))); if (station.id) { const meta = (k, v) => el('div', { class: 'meta-row' }, el('span', { class: 'meta-k' }, k), el('span', { class: 'meta-v mono' }, v == null || v === '' ? '—' : String(v)) ); root.appendChild(el('div', { class: 'readonly-meta' }, el('div', { class: 'readonly-meta-head' }, 'Read-only metadata (used by the public API)'), meta('id', station.id), meta('uuid', station.uuid), meta('slug', station.slug), meta('source', station.source), meta('source_ref', station.source_ref), meta('image_source', station.image_source), meta('image_path', station.image_path), meta('created_at', station.created_at), meta('updated_at', station.updated_at), )); } } function paintStreams(root, station) { if (!station.id) { // Pre-create flow — work in-memory. const box = el('div', { class: 'streams' }); const repaint = () => { clear(box); box.appendChild(el('div', { style: { fontWeight: 700, marginBottom: '6px' } }, 'Streams')); for (const s of station.streams || []) box.appendChild(renderStreamRow(s, () => { station.streams = station.streams.filter((x) => x !== s); repaint(); })); box.appendChild(el('button', { class: 'btn', type: 'button', onClick: () => { station.streams = [...(station.streams || []), { url: '', format: 'mp3', priority: (station.streams?.length || 0) }]; repaint(); } }, '+ Add stream')); }; repaint(); root.appendChild(box); return; } // Live mode: every change hits the server immediately. const box = el('div', { class: 'streams' }); const refreshStreams = async () => { station.streams = await api.get(`/api/admin/stations/${station.id}/streams`); repaint(); }; const repaint = () => { clear(box); box.appendChild(el('div', { style: { fontWeight: 700, marginBottom: '6px' } }, 'Streams')); for (const s of station.streams || []) box.appendChild(renderStreamRow(s, async () => { if (!confirm('Delete stream?')) return; await api.del(`/api/admin/streams/${s.id}`); await refreshStreams(); }, async () => { await api.patch(`/api/admin/streams/${s.id}`, { url: s.url, format: s.format, bitrate: s.bitrate || null, label: s.label || null, priority: s.priority || 0 }); }, async () => { const r = await api.post(`/api/admin/streams/${s.id}/probe`); s.last_status = r.status; repaint(); })); box.appendChild(el('button', { class: 'btn', type: 'button', onClick: async () => { const newStream = await api.post(`/api/admin/stations/${station.id}/streams`, { url: '', format: 'mp3', priority: (station.streams?.length || 0) }); station.streams = [...(station.streams || []), newStream]; repaint(); } }, '+ Add stream')); }; repaint(); root.appendChild(box); } function renderStreamRow(s, onDelete, onSave, onProbe) { const update = (k, v) => { s[k] = v; }; return el('div', { class: 'stream-row' }, el('select', { onChange: (e) => update('format', e.target.value) }, ...['mp3', 'aac', 'hls', 'm3u', 'pls', 'ogg', 'unknown'].map((f) => el('option', { value: f, selected: s.format === f }, f))), el('input', { value: s.url || '', placeholder: 'https://…', onInput: (e) => update('url', e.target.value) }), el('input', { type: 'number', placeholder: 'kbps', value: s.bitrate || '', onInput: (e) => update('bitrate', Number(e.target.value) || null) }), el('input', { value: s.label || '', placeholder: 'Label', onInput: (e) => update('label', e.target.value) }), s.last_status ? el('span', { class: `pill ${s.last_status === 'up' ? 'up' : s.last_status === 'down' ? 'down' : 'unknown'}` }, s.last_status) : el('span'), el('span', { style: { display: 'flex', gap: '4px' } }, onProbe ? el('button', { class: 'btn', type: 'button', onClick: onProbe }, 'Test') : null, onSave ? el('button', { class: 'btn', type: 'button', onClick: onSave }, 'Save') : null, el('button', { class: 'btn danger', type: 'button', onClick: onDelete }, '×') ) ); } function paintImage(root, station, onChanged) { if (!station.id) { root.appendChild(el('p', {}, 'Save the station first, then come back to upload an image.')); return; } const art = station.image_display_url || station.image_url; const area = el('div', { class: 'image-area' }); const preview = el('div', { class: 'preview', style: art ? { backgroundImage: `url("${art}")` } : {} }, art ? '' : 'No image'); const dropzone = el('div', { class: 'dropzone' }, 'Drop image file here, or click to upload'); const fileInput = el('input', { type: 'file', accept: 'image/*', style: { display: 'none' } }); async function upload(file) { if (!file) return; if (file.size > 5 * 1024 * 1024) return alert('File is too large (5 MB max).'); const buf = await file.arrayBuffer(); const res = await fetch(`/api/admin/stations/${station.id}/image`, { method: 'PUT', headers: { 'Content-Type': file.type || 'application/octet-stream' }, credentials: 'same-origin', body: buf }); if (!res.ok) { alert('Upload failed: ' + res.status); return; } await onChanged(); } dropzone.addEventListener('click', () => fileInput.click()); dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('over'); }); dropzone.addEventListener('dragleave', () => dropzone.classList.remove('over')); dropzone.addEventListener('drop', (e) => { e.preventDefault(); dropzone.classList.remove('over'); upload(e.dataTransfer.files?.[0]); }); fileInput.addEventListener('change', () => upload(fileInput.files?.[0])); const actions = el('div', { class: 'actions-col' }, el('div', { class: 'row' }, el('label', {}, 'Image URL'), el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })), dropzone, fileInput, el('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } }, el('button', { class: 'btn', type: 'button', onClick: async () => { // Save image_url first, then refetch. await api.patch(`/api/admin/stations/${station.id}`, { image_url: station.image_url }); const r = await fetch(`/api/admin/stations/${station.id}/image/refetch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ url: station.image_url }) }); if (!r.ok) { alert('Refetch failed: ' + r.status); return; } await onChanged(); } }, 'Refetch from URL'), el('button', { class: 'btn', type: 'button', onClick: async () => { const r = await api.post(`/api/admin/stations/${station.id}/scrape-icon`).catch((e) => ({ error: e.message })); if (r.error) alert('Scrape failed: ' + r.error); await onChanged(); } }, 'Auto-scrape'), el('button', { class: 'btn danger', type: 'button', onClick: async () => { if (!confirm('Drop the cached image?')) return; await api.del(`/api/admin/stations/${station.id}/image`); await onChanged(); } }, 'Clear cache') ), el('div', { style: { fontSize: '11px', color: 'var(--muted)' } }, `Source: ${station.image_source || '—'} · Path: ${station.image_path || '—'}`) ); area.appendChild(preview); area.appendChild(actions); root.appendChild(area); } function paintStats(root, station, id) { if (!id) { root.appendChild(el('p', {}, 'Stats are available after the station is created.')); return; } root.appendChild(el('div', { class: 'system-grid' }, stat('Votes up', station.up || 0), stat('Votes down', station.down || 0), stat('Score', (station.score || 0).toFixed(2)), stat('Plays', station.plays || 0), stat('Streams', (station.streams || []).length) )); root.appendChild(el('div', { style: { marginTop: '12px', display: 'flex', gap: '8px', flexWrap: 'wrap' } }, el('button', { class: 'btn danger', onClick: async () => { if (!confirm('Delete all votes for this station?')) return; await api.del(`/api/admin/stations/${id}/votes`); Object.assign(station, await api.get(`/api/stations/${id}`)); paintStats(clearInline(root), station, id); } }, 'Reset votes'), el('button', { class: 'btn danger', onClick: async () => { if (!confirm('Delete all plays for this station?')) return; await api.del(`/api/admin/stations/${id}/plays`); Object.assign(station, await api.get(`/api/stations/${id}`)); paintStats(clearInline(root), station, id); } }, 'Reset plays'), )); } function clearInline(node) { clear(node); return node; } function stat(k, v) { return el('div', { class: 'stat' }, el('div', { class: 'v' }, String(v)), el('div', { class: 'k' }, k)); } // Strong confirmation for destructive station removal. Forces the admin to // type the station name (or the literal "DELETE" for bulk ops) and lays out // exactly what API-visible state will disappear so we don't accidentally // break clients that hard-code numeric ids or uuids. function confirmStationDelete({ stations }) { // `stations` is an array of {id, name, uuid?} so we can list them. const list = Array.isArray(stations) ? stations : [stations]; const single = list.length === 1 ? list[0] : null; const expected = single ? single.name : 'DELETE'; return new Promise((resolve) => { const dlg = el('dialog', { class: 'danger-confirm' }); let typed = ''; const input = el('input', { placeholder: `Type "${expected}" to confirm`, autocomplete: 'off', onInput: (e) => { typed = e.target.value; confirmBtn.disabled = typed.trim() !== expected; } }); const confirmBtn = el('button', { class: 'btn danger', type: 'button', disabled: true, onClick: () => { dlg.close(); resolve(true); } }, single ? 'Permanently delete' : `Permanently delete ${list.length} stations`); dlg.appendChild(el('form', { method: 'dialog', onSubmit: (e) => e.preventDefault() }, el('div', { class: 'danger-header' }, el('div', { class: 'danger-icon' }, '⚠'), el('h2', {}, single ? `Delete "${single.name}"?` : `Delete ${list.length} stations?`) ), el('div', { class: 'danger-body' }, el('p', { class: 'lede' }, 'This is irreversible and ', el('b', {}, 'will break the public API'), ' for any client (kiosk, master, third-party integration, bookmarks, scripts) ', 'that references these stations by id, uuid or slug.' ), el('ul', { class: 'impact' }, el('li', {}, el('code', {}, 'GET /api/v1/stations/{id}'), ' will return 404'), el('li', {}, el('code', {}, 'GET /api/v1/stations/{uuid}'), ' will return 404'), el('li', {}, 'All streams, votes, plays and favorites attached to ', single ? 'this station' : 'these stations', ' are dropped (cascade)'), el('li', {}, 'Active listeners playing ', single ? 'this station' : 'one of these', ' will receive a stop event'), el('li', {}, 'Cached image files are unlinked from disk') ), list.length > 1 || single ? el('div', { class: 'impact-list' }, el('div', { class: 'impact-list-head' }, 'Targets:'), el('ul', {}, ...list.slice(0, 12).map((s) => el('li', {}, el('b', {}, s.name), s.uuid ? el('span', { class: 'mono' }, ` · ${s.uuid}`) : null, el('span', { class: 'mono' }, ` · id=${s.id}`))), list.length > 12 ? el('li', { class: 'more' }, `…and ${list.length - 12} more`) : null ) ) : null, el('label', { class: 'type-to-confirm' }, `Type `, el('code', {}, expected), ` to confirm:`, input ) ), el('div', { class: 'actions' }, el('button', { class: 'btn', type: 'button', onClick: () => { dlg.close(); resolve(false); } }, 'Cancel'), confirmBtn ) )); document.body.appendChild(dlg); dlg.addEventListener('close', () => dlg.remove()); dlg.showModal(); setTimeout(() => input.focus(), 50); }); } // ============================================================ // Discover view (Radio-Browser) // ============================================================ // Loads an initial list of popular Radio-Browser stations on tab open so the // view doesn't feel empty. Same visual layout as the Stations table (with // thumbnails, source column, etc.) — the only difference is the action column // (Import) and the badge that flags entries already in our library. function renderDiscover(root) { root.appendChild(el('h2', {}, 'Discover · Radio-Browser')); const q = state.discoverQuery; async function runSearch() { const params = new URLSearchParams(); if (q.q) params.set('q', q.q); if (q.country) params.set('country', q.country); if (q.tag) params.set('tag', q.tag); params.set('limit', '50'); const wrap = document.getElementById('discoverWrap'); if (wrap) { clear(wrap); wrap.appendChild(el('p', { class: 'muted' }, 'Loading…')); } try { state.discoverResults = await api.get(`/api/stations/sources/radiobrowser/search?${params}`); } catch (err) { state.discoverResults = []; if (wrap) { clear(wrap); wrap.appendChild(el('p', { class: 'err' }, err.message || 'Search failed')); } return; } renderDiscoverTable(); } root.appendChild(el('div', { class: 'bar' }, el('input', { placeholder: 'Name…', value: q.q, onInput: (e) => q.q = e.target.value, onKeyDown: (e) => { if (e.key === 'Enter') runSearch(); } }), el('input', { placeholder: 'Country (e.g. NL)', value: q.country, onInput: (e) => q.country = e.target.value, onKeyDown: (e) => { if (e.key === 'Enter') runSearch(); }, style: { minWidth: '120px' } }), el('input', { placeholder: 'Tag/genre', value: q.tag, onInput: (e) => q.tag = e.target.value, onKeyDown: (e) => { if (e.key === 'Enter') runSearch(); } }), el('button', { class: 'btn primary', onClick: runSearch }, 'Search'), el('button', { class: 'btn', onClick: () => { q.q = ''; q.country = ''; q.tag = ''; renderDiscover(clearInline(root)); } }, 'Reset'), el('button', { class: 'btn', onClick: async () => { const picks = state.discoverResults.filter((r) => r.__import); if (!picks.length) return alert('Nothing selected.'); if (!confirm(`Import ${picks.length} stations?`)) return; for (const p of picks) { await api.post('/api/stations/sources/radiobrowser/import', p).catch(() => { }); } alert('Done.'); await refresh(); await runSearch(); } }, 'Import selected'), )); root.appendChild(el('div', { id: 'discoverWrap' })); // Initial load: top-voted stations from Radio-Browser. if (!state.discoverResults.length) runSearch(); else renderDiscoverTable(); } function renderDiscoverTable() { const wrap = document.getElementById('discoverWrap'); if (!wrap) return; clear(wrap); const rows = state.discoverResults; if (!rows.length) { wrap.appendChild(el('p', { class: 'muted' }, 'No results. Try a different query — leave fields blank for the top stations on Radio-Browser.')); return; } // Dedupe against the library so the admin sees which entries already exist. const existingUuids = new Set(state.stations.map((s) => s.uuid).filter(Boolean)); const allSelected = rows.length && rows.every((r) => r.__import); wrap.appendChild(el('table', {}, el('thead', {}, el('tr', {}, el('th', { style: { width: '32px' } }, el('input', { type: 'checkbox', checked: !!allSelected, onChange: (e) => { rows.forEach((r) => { r.__import = e.target.checked && !existingUuids.has(r.uuid); }); renderDiscoverTable(); } })), el('th', {}, 'Name'), el('th', {}, 'Country'), el('th', {}, 'Tags'), el('th', {}, 'Stream'), el('th', {}, 'Status'), el('th', {}, ''))), el('tbody', {}, ...rows.map((s) => { const exists = existingUuids.has(s.uuid); const art = s.image_url; return el('tr', { class: exists ? 'discover-existing' : '' }, el('td', {}, el('input', { type: 'checkbox', checked: !!s.__import, disabled: exists, onChange: (e) => { s.__import = e.target.checked; } })), el('td', {}, el('div', { class: 'station-cell' }, el('div', { class: 'station-art-thumb' + (art ? '' : ' empty') }, art ? el('img', { src: art, alt: '', loading: 'lazy', referrerpolicy: 'no-referrer', onError: (e) => { const parent = e.target.parentNode; e.target.remove(); if (parent) parent.classList.add('empty'); } }) : null ), el('div', { class: 'meta' }, el('strong', {}, s.name), el('small', {}, s.homepage || s.uuid)) )), el('td', {}, s.country || ''), el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))), el('td', {}, el('small', {}, (s.streams[0]?.format || '') + ' ' + (s.streams[0]?.bitrate ? `· ${s.streams[0].bitrate}kbps` : ''))), el('td', {}, exists ? el('span', { class: 'pill up' }, 'in library') : el('span', { class: 'pill unknown' }, 'new')), el('td', {}, exists ? el('button', { class: 'btn', disabled: true }, 'Imported') : el('button', { class: 'btn primary', onClick: async (e) => { e.target.disabled = true; try { await api.post('/api/stations/sources/radiobrowser/import', s); await refresh(); renderDiscoverTable(); } catch (err) { alert('Import failed: ' + err.message); } } }, 'Import')) ); })) )); } // ============================================================ // Leaderboard view (moderation) // ============================================================ function renderLeaderboard(root) { root.appendChild(el('h2', {}, 'Leaderboard')); root.appendChild(el('p', { style: { color: 'var(--muted)', marginTop: 0 } }, 'Top stations by total listen time. Use the reset buttons to moderate runaway counters.')); const list = el('div', { class: 'leaderboard' }); state.leaderboard.forEach((s, i) => { const art = s.image_display_url || (s.image_path ? `/media/${s.image_path}` : s.image_url); list.appendChild(el('div', { class: 'leader-row' }, el('div', { class: 'rank' }, String(i + 1)), el('div', { class: 'art' + (art ? '' : ' empty') }, art ? el('img', { src: art, alt: '', loading: 'lazy', referrerpolicy: 'no-referrer', onError: (e) => { const parent = e.target.parentNode; e.target.remove(); if (parent) parent.classList.add('empty'); } }) : null ), el('div', { class: 'name' }, el('b', {}, s.name), el('br'), el('small', {}, s.country || '')), el('div', { class: 'stat-num', title: 'Total listen time' }, `⏱ ${formatDuration(s.total_play_ms)}`), el('div', { class: 'stat-num', title: 'Average session length' }, `Ø ${formatDuration(s.avg_session_ms)}`), el('div', { class: 'stat-num', title: 'Play taps' }, `▶ ${s.plays}`), el('div', { class: 'stat-num', title: 'Sessions credited' }, `· ${s.sessions}`), el('div', { class: 'stat-num' }, `▲ ${s.up}`), el('div', { class: 'stat-num' }, `▼ ${s.down}`), )); }); root.appendChild(list); } // Compact human-readable duration: "42s", "7m12s", "3h08m", "2d04h". function formatDuration(ms) { const s = Math.max(0, Math.round((ms || 0) / 1000)); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`; const h = Math.floor(m / 60); if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`; const d = Math.floor(h / 24); return `${d}d${String(h % 24).padStart(2, '0')}h`; } // ============================================================ // Rooms view // ============================================================ function renderRooms(root) { root.appendChild(el('h2', {}, 'Rooms')); root.appendChild(el('table', {}, el('thead', {}, el('tr', {}, el('th', {}, 'Slug'), el('th', {}, 'Name'), el('th', {}, 'Members'), el('th', {}, 'Active station'), el('th', {}, 'Created'), el('th', {}, ''))), el('tbody', {}, ...state.rooms.map((r) => el('tr', {}, el('td', {}, el('code', {}, r.slug)), el('td', {}, r.name), el('td', {}, String(r.members)), el('td', {}, r.active ? '●' : '—'), el('td', {}, el('small', {}, r.created_at)), el('td', {}, r.slug.startsWith('u-') ? el('small', { style: { color: 'var(--muted)' } }, 'personal') : el('button', { class: 'btn danger', onClick: async () => { if (!confirm(`Delete room ${r.slug}?`)) return; await api.del(`/api/admin/rooms/${r.slug}`); await refresh(); render(); } }, 'Delete') ) ))) )); } // ============================================================ // Users view (unchanged behaviour) // ============================================================ function renderUsers(root) { root.appendChild(el('div', { class: 'bar' }, el('h2', { style: { margin: 0, flex: 1 } }, 'Users'), el('button', { class: 'btn primary', onClick: openUserDialog }, '+ Add user') )); root.appendChild(el('table', {}, el('thead', {}, el('tr', {}, el('th', {}, 'Username'), el('th', {}, 'Role'), el('th', {}, 'Created'), el('th', {}, ''))), el('tbody', {}, ...state.users.map((u) => el('tr', {}, el('td', {}, u.username), el('td', {}, u.role), el('td', {}, u.created_at), el('td', {}, el('button', { class: 'btn', onClick: async () => { const pw = prompt(`New password for ${u.username}:`); if (pw) { await api.patch(`/api/auth/users/${u.id}`, { password: pw }); alert('Updated'); } } }, 'Reset PW'), ' ', el('button', { class: 'btn', onClick: async () => { const r = u.role === 'admin' ? 'user' : 'admin'; await api.patch(`/api/auth/users/${u.id}`, { role: r }); await refresh(); render(); } }, 'Toggle role'), ' ', u.id !== state.user.id ? el('button', { class: 'btn danger', onClick: async () => { if (confirm(`Delete ${u.username}?`)) { await api.del(`/api/auth/users/${u.id}`); await refresh(); render(); } } }, 'Delete') : null ) ))) )); } function openUserDialog() { const dlg = el('dialog'); dlg.appendChild(el('form', { method: 'dialog', onSubmit: async (e) => { e.preventDefault(); const fd = new FormData(e.target); await api.post('/api/auth/users', { username: fd.get('username'), password: fd.get('password'), role: fd.get('role') }); dlg.close(); await refresh(); render(); } }, el('h2', {}, 'New user'), el('div', { class: 'row' }, el('label', {}, 'Username'), el('input', { name: 'username', required: true })), el('div', { class: 'row' }, el('label', {}, 'Password'), el('input', { name: 'password', type: 'password', required: true })), el('div', { class: 'row' }, el('label', {}, 'Role'), el('select', { name: 'role' }, el('option', { value: 'user' }, 'user'), el('option', { value: 'admin' }, 'admin'))), el('div', { class: 'actions' }, el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'), el('button', { class: 'btn primary', type: 'submit' }, 'Create')) )); document.body.appendChild(dlg); dlg.showModal(); dlg.addEventListener('close', () => dlg.remove()); } // ============================================================ // System view // ============================================================ function renderSystem(root) { root.appendChild(el('h2', {}, 'System')); const s = state.system || {}; root.appendChild(el('div', { class: 'system-grid' }, stat('Stations', s.stations || 0), stat('Streams', s.streams || 0), stat('Users', s.users || 0), stat('Favorites', s.favorites || 0), stat('Cached images', s.image_cache?.files || 0), stat('Cache size (MB)', s.image_cache ? (s.image_cache.bytes / 1048576).toFixed(1) : '0'), stat('Node', s.node || ''), stat('Uptime (s)', s.uptime_s || 0) )); root.appendChild(el('div', { style: { marginTop: '16px', display: 'flex', gap: '8px' } }, el('button', { class: 'btn', onClick: async () => { if (!confirm('Re-seed from data/seed/?')) return; const r = await api.post('/api/admin/reseed'); alert(JSON.stringify(r)); await refresh(); render(); } }, 'Re-seed'), el('button', { class: 'btn', onClick: async () => { const r = await api.post('/api/admin/scrape-icons?all=1'); alert(`Updated ${r.updated}, failed ${r.failed}`); await refresh(); render(); } }, 'Scrape all icons'), )); } bootstrap();