Files
radio-explorer/web/admin/main.js
Marco Mooren b86dcfbb8d Add master display UI with audio output management and styling
- Implement main.js for the master display functionality, including WebSocket connection, audio output management, and state handling.
- Create style.css for the master display's visual design, ensuring a cohesive look and feel with a dark theme and responsive layout.
- Integrate device management with a fallback for non-Electron environments, allowing users to select audio outputs.
- Add features for managing favorites, including toggling favorites and filtering by genre.
- Enhance user experience with a responsive favorites grid and drag-to-scroll functionality.
2026-05-11 17:55:09 +02:00

1032 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `<div class="login"><div><h1>Admin only</h1><p>Signed in as ${state.user.username} (${state.user.role}).</p></div></div>`;
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();