// 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();