Files
radio-explorer/web/admin/main.js
Marco Mooren 29423288ca feat: add multi-user support for favorites management and room clock synchronization
- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
2026-05-13 13:53:12 +02:00

1126 lines
51 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';
import { mountDebugPane } from '../shared/debug.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();
// Admin doesn't run a RoomClock or WS — the pane mostly shows the
// audition player state (useful for diagnosing broken stations).
mountDebugPane({ player: preview, clock: null, ws: null, role: 'admin' });
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', onClick: openTrustDeviceDialog }, '🔑 Trust this device'),
' ',
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', {}, 'Main'),
el('th', {}, 'Avatar'),
el('th', {}, 'Created'),
el('th', {}, ''))),
el('tbody', {}, ...state.users.map((u) => el('tr', {},
el('td', {}, u.username),
el('td', {}, u.role),
el('td', {}, u.is_main ? '★ main' : el('button', {
class: 'btn', title: 'Promote to main (shared) user',
onClick: async () => {
if (!confirm(`Make ${u.username} the shared/main user?`)) return;
await api.patch(`/api/auth/users/${u.id}`, { is_main: true });
await refresh(); render();
}
}, 'Make main')),
el('td', {},
el('span', {
style: {
display: 'inline-grid', placeItems: 'center', width: '24px', height: '24px',
background: u.avatar_color || '#ff7a3d', color: '#1a0a00', fontWeight: '800', fontSize: '12px'
}
}, u.avatar_emoji || u.username.slice(0, 1).toUpperCase()),
' ',
el('button', {
class: 'btn', onClick: async () => {
const emoji = prompt(`Avatar emoji / letter for ${u.username} (leave empty to clear):`, u.avatar_emoji || '');
if (emoji === null) return;
const color = prompt(`Avatar color (hex, e.g. #ff7a3d):`, u.avatar_color || '#ff7a3d');
if (color === null) return;
await api.patch(`/api/auth/users/${u.id}`, { avatar_emoji: emoji, avatar_color: color });
await refresh(); render();
}
}, 'Edit')
),
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
)
)))
));
}
async function openTrustDeviceDialog() {
// Snapshot of current state to pre-check existing whitelist.
let info = { trusted: false, users: [], label: '' };
try { info = await api.get('/api/auth/devices/me'); } catch { }
const existing = new Set((info.users || []).map((u) => u.id));
const dlg = el('dialog');
dlg.appendChild(el('form', {
method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const ids = [...fd.getAll('user_id')].map(Number);
const label = fd.get('label') || null;
try {
if (info.trusted) {
await api.patch('/api/auth/devices/me', { label, user_ids: ids });
alert('Device whitelist updated.');
} else {
await api.post('/api/auth/devices/trust', { label, user_ids: ids });
alert('This device is now trusted. Fast-switching is enabled for the selected users.');
}
} catch (err) {
alert(err.message || 'Failed');
return;
}
dlg.close();
}
},
el('h2', {}, info.trusted ? 'Edit trusted device' : 'Trust this device'),
el('p', { style: { color: '#8a90a0', fontSize: '13px' } },
'Listed users can fast-switch on this device without a password. ',
'The cookie is HttpOnly and lasts 1 year.'),
el('div', { class: 'row' },
el('label', {}, 'Device label'),
el('input', { name: 'label', value: info.label || '', placeholder: 'e.g. Kitchen kiosk' })),
el('div', { style: { maxHeight: '300px', overflowY: 'auto', border: '1px solid #262b36', padding: '8px' } },
...state.users.map((u) => el('label', {
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 6px', cursor: 'pointer' }
},
el('input', {
type: 'checkbox', name: 'user_id', value: String(u.id),
checked: existing.has(u.id) || u.id === state.user.id
}),
el('span', {}, u.username),
u.is_main ? el('span', { style: { color: '#ffb37a', fontSize: '11px' } }, ' ★ main') : null,
u.id === state.user.id ? el('span', { style: { color: '#5d6373', fontSize: '11px' } }, ' (you)') : null,
))),
el('div', { class: 'actions' },
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn primary', type: 'submit' }, info.trusted ? 'Update' : 'Trust device'))
));
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
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();