Add API documentation and underground station importer

- Introduced a new HTML documentation page for the oradio API, including a JavaScript file to handle dynamic content and API requests.
- Added a CSS file for styling the documentation page.
- Implemented an underground station importer script that fetches data from Radio-Browser and writes it to a JSON file.
- Created a stats module to compute and manage vote and play statistics for radio stations.
- Added a polyfill for modulepreload to ensure compatibility with older browsers.
This commit is contained in:
Marco Mooren
2026-05-11 02:06:48 +02:00
parent e0a60f7b64
commit 00246389bc
52 changed files with 6280 additions and 2475 deletions

View File

@@ -1,21 +1,21 @@
async function http(method, path, body) {
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
const data = ct.includes('json') ? await res.json() : await res.text();
if (!res.ok) throw Object.assign(new Error(data?.error || res.statusText), { status: res.status, data });
return data;
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
const data = ct.includes('json') ? await res.json() : await res.text();
if (!res.ok) throw Object.assign(new Error(data?.error || res.statusText), { status: res.status, data });
return data;
}
export const api = {
get: (p) => http('GET', p),
post: (p, b) => http('POST', p, b),
put: (p, b) => http('PUT', p, b),
patch: (p, b) => http('PATCH', p, b),
del: (p) => http('DELETE', p)
get: (p) => http('GET', p),
post: (p, b) => http('POST', p, b),
put: (p, b) => http('PUT', p, b),
patch: (p, b) => http('PATCH', p, b),
del: (p) => http('DELETE', p)
};

View File

@@ -1,17 +1,17 @@
export function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k === 'class') node.className = v;
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'html') node.innerHTML = v;
else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
return node;
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k === 'class') node.className = v;
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'html') node.innerHTML = v;
else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
return node;
}
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }

View File

@@ -1,22 +1,22 @@
export function connectWs(onMessage) {
let ws, retry = 0, closed = false;
function open() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.addEventListener('open', () => { retry = 0; });
ws.addEventListener('message', (ev) => {
try { onMessage(JSON.parse(ev.data)); } catch {}
});
ws.addEventListener('close', () => {
if (closed) return;
retry = Math.min(retry + 1, 6);
setTimeout(open, 500 * 2 ** retry);
});
ws.addEventListener('error', () => ws.close());
}
open();
return {
send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); },
close() { closed = true; ws?.close(); }
};
let ws, retry = 0, closed = false;
function open() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.addEventListener('open', () => { retry = 0; });
ws.addEventListener('message', (ev) => {
try { onMessage(JSON.parse(ev.data)); } catch { }
});
ws.addEventListener('close', () => {
if (closed) return;
retry = Math.min(retry + 1, 6);
setTimeout(open, 500 * 2 ** retry);
});
ws.addEventListener('error', () => ws.close());
}
open();
return {
send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); },
close() { closed = true; ws?.close(); }
};
}