Files
radio-explorer/web/admin/main.js
Marco Mooren e0a60f7b64 Add player functionality with HLS support and API integration
- Implemented a new Player class in player.js to handle audio playback, including HLS support using hls.js.
- Created a shared API module in api.js for making HTTP requests with proper error handling.
- Added DOM utility functions in dom.js for creating and clearing elements.
- Introduced WebSocket connection handling in ws.js for real-time updates.
- Developed a comprehensive CSS stylesheet for styling the application, including a high-contrast theme.
2026-05-10 14:43:00 +02:00

295 lines
14 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.

import { api } from '../shared/api.js';
import { el, clear } from '../shared/dom.js';
const app = document.getElementById('app');
const state = { user: null, view: 'stations', stations: [], users: [], system: null, search: '' };
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'));
const [stations, more1, more2] = await Promise.all(tasks);
state.stations = stations;
if (state.view === 'users') state.users = more1 || [];
if (state.view === 'system') state.system = more1 || more2 || null;
}
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 side = el('aside', { class: 'side' },
el('h1', {}, 'Online Radio Explorer'),
...['stations', 'import', 'users', 'system'].map((v) =>
el('button', { class: `nav ${state.view === v ? 'active' : ''}`,
onClick: async () => { state.view = v; await refresh(); render(); } }, label(v))),
el('div', { class: 'me' }, `Signed in as ${state.user.username}`,
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' });
if (state.view === 'stations') renderStations(main);
else if (state.view === 'import') renderImport(main);
else if (state.view === 'users') renderUsers(main);
else if (state.view === 'system') renderSystem(main);
app.appendChild(el('div', { class: 'shell' }, side, main));
}
function label(v) {
return ({ stations: 'Stations', import: 'Import', users: 'Users', system: 'System' })[v];
}
// ---------- Stations ----------
function renderStations(root) {
root.appendChild(el('div', { class: 'bar' },
el('input', { placeholder: 'Search…', value: state.search,
onInput: (e) => { state.search = e.target.value; renderStationsTable(); } }),
el('button', { class: 'btn primary', onClick: () => openStationDialog() }, '+ Add station'),
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check')
));
const tableWrap = el('div', { id: 'tableWrap' });
root.appendChild(tableWrap);
renderStationsTable();
}
function renderStationsTable() {
const wrap = document.getElementById('tableWrap');
if (!wrap) return;
clear(wrap);
const q = state.search.toLowerCase();
const filtered = state.stations.filter((s) =>
!q || s.name.toLowerCase().includes(q) || (s.country || '').toLowerCase().includes(q) ||
(s.genres || []).some((g) => g.toLowerCase().includes(q))
);
const table = el('table', {},
el('thead', {}, el('tr', {},
el('th', {}, 'Name'), el('th', {}, 'Source'), el('th', {}, 'Genres'),
el('th', {}, 'Country'), el('th', {}, 'Enabled'), el('th', {}, 'Actions'))),
el('tbody', {}, ...filtered.map((s) => el('tr', {},
el('td', {}, el('strong', {}, s.name), el('br'), el('small', {}, s.homepage || '')),
el('td', {}, s.source),
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
el('td', {}, s.country || ''),
el('td', {}, s.enabled ? '✅' : '⛔'),
el('td', {},
el('button', { class: 'btn', onClick: () => openStationDialog(s.id) }, 'Edit'),
' ',
el('button', { class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${s.name}?`)) { await api.del(`/api/stations/${s.id}`); await refresh(); render(); }
} }, 'Delete')
)
)))
);
wrap.appendChild(table);
}
async function openStationDialog(id) {
const station = id ? await api.get(`/api/stations/${id}`) : { name: '', genres: [], streams: [], enabled: true };
const dlg = el('dialog');
const streamsBox = el('div', { class: 'streams' });
function paintStreams() {
clear(streamsBox);
streamsBox.appendChild(el('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'Streams'));
if (!station.streams?.length) streamsBox.appendChild(el('div', { style: { color: '#6b7280' } }, 'No streams yet.'));
for (const s of station.streams || []) {
streamsBox.appendChild(el('div', { class: 'stream-row' },
el('select', { onChange: (e) => s.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) => s.url = e.target.value }),
el('input', { type: 'number', placeholder: 'kbps', value: s.bitrate || '', onInput: (e) => s.bitrate = Number(e.target.value) || null }),
el('input', { value: s.label || '', placeholder: 'Label', onInput: (e) => s.label = e.target.value }),
s.last_status ? el('span', { class: `pill ${s.last_status === 'up' ? 'up' : 'down'}` }, s.last_status) : el('span'),
el('button', { class: 'btn danger', type: 'button', onClick: () => { station.streams = station.streams.filter((x) => x !== s); paintStreams(); } }, '×')
));
}
streamsBox.appendChild(el('button', { class: 'btn', type: 'button', onClick: () => {
station.streams = [...(station.streams || []), { url: '', format: 'mp3', priority: (station.streams?.length || 0) }];
paintStreams();
} }, '+ Add stream'));
}
const form = el('form', { method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const payload = {
name: station.name, homepage: station.homepage, country: station.country,
genres: station.genres, description: station.description, image_url: station.image_url,
enabled: station.enabled
};
if (id) {
await api.patch(`/api/stations/${id}`, payload);
// sync streams: simple approach — delete all & re-add
const fresh = await api.get(`/api/stations/${id}`);
for (const s of fresh.streams || []) await api.del(`/api/stations/${id}/streams/${s.id}`);
for (const s of station.streams || []) if (s.url) await api.post(`/api/stations/${id}/streams`, s);
} else {
payload.streams = (station.streams || []).filter((s) => s.url);
await api.post('/api/stations', payload);
}
dlg.close();
await refresh();
render();
} },
el('h2', {}, id ? 'Edit station' : 'Add station'),
el('div', { class: 'row' }, el('label', {}, 'Name'), el('input', { value: station.name, onInput: (e) => station.name = e.target.value, required: true })),
el('div', { class: 'row' }, el('label', {}, 'Homepage'), el('input', { value: station.homepage || '', onInput: (e) => station.homepage = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Country'), el('input', { value: station.country || '', maxlength: 4, onInput: (e) => station.country = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Genres'), el('input', { value: (station.genres || []).join(', '), onInput: (e) => station.genres = e.target.value.split(',').map((s) => s.trim()).filter(Boolean) })),
el('div', { class: 'row' }, el('label', {}, 'Image URL'),el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })),
el('div', { class: 'row col' }, el('textarea', { rows: 2, placeholder: 'Description', onInput: (e) => station.description = e.target.value }, station.description || '')),
el('div', { class: 'row' }, el('label', {}, 'Enabled'), el('input', { type: 'checkbox', checked: station.enabled, onChange: (e) => station.enabled = e.target.checked })),
streamsBox,
el('div', { class: 'actions' },
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn primary', type: 'submit' }, 'Save'))
);
paintStreams();
dlg.appendChild(form);
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---------- Import (Radio-Browser) ----------
function renderImport(root) {
let results = [];
const resultsBox = el('div');
root.appendChild(el('h2', {}, 'Import from Radio-Browser'));
root.appendChild(el('div', { class: 'bar' },
el('input', { id: 'rbq', placeholder: 'Search by name…' }),
el('input', { id: 'rbcountry', placeholder: 'Country (e.g. NL)', style: { minWidth: '120px' } }),
el('input', { id: 'rbtag', placeholder: 'Tag/genre' }),
el('button', { class: 'btn primary', onClick: async () => {
const params = new URLSearchParams({
q: document.getElementById('rbq').value,
country: document.getElementById('rbcountry').value,
tag: document.getElementById('rbtag').value
});
results = await api.get(`/api/stations/sources/radiobrowser/search?${params}`);
paint();
} }, 'Search')
));
root.appendChild(resultsBox);
function paint() {
clear(resultsBox);
if (!results.length) { resultsBox.appendChild(el('p', {}, 'No results yet.')); return; }
const table = el('table', {},
el('thead', {}, el('tr', {}, el('th', {}, 'Name'), el('th', {}, 'Country'), el('th', {}, 'Tags'), el('th', {}, 'Stream'), el('th', {}, ''))),
el('tbody', {}, ...results.map((s) => el('tr', {},
el('td', {}, s.name),
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 || ''))),
el('td', {}, el('button', { class: 'btn primary', onClick: async () => {
await api.post('/api/stations/sources/radiobrowser/import', s);
alert(`Imported ${s.name}`);
} }, 'Import'))
)))
);
resultsBox.appendChild(table);
}
}
// ---------- Users ----------
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 ----------
function renderSystem(root) {
const s = state.system || {};
root.appendChild(el('h2', {}, 'System'));
root.appendChild(el('div', { class: 'system-grid' },
stat('Stations', s.stations), stat('Streams', s.streams), stat('Users', s.users),
stat('Favorites', s.favorites), stat('Node', s.node), stat('Uptime (s)', s.uptime_s)
));
root.appendChild(el('div', { class: 'bar', style: { marginTop: '16px' } },
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check'),
el('button', { class: 'btn', onClick: async () => { const r = await api.post('/api/admin/reseed'); alert(JSON.stringify(r)); } }, 'Reseed (if empty)')
));
}
function stat(k, v) { return el('div', { class: 'stat' }, el('div', { class: 'k' }, k), el('div', { class: 'v' }, v ?? '—')); }
bootstrap();