- 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.
1032 lines
47 KiB
JavaScript
1032 lines
47 KiB
JavaScript
// 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();
|