Files
furumi-fd/static/index.html
T
2026-07-16 18:45:39 +03:00

615 lines
27 KiB
HTML
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.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>furumi-fd</title>
<link rel="icon" href="data:,">
<style>
:root {
--bg: #14161a; --panel: #1d2026; --border: #2c313a;
--text: #e6e8ec; --muted: #8b93a1; --accent: #6aa5ff; --danger: #e06c75;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, sans-serif;
}
header {
display: flex; align-items: baseline; gap: 16px;
padding: 14px 20px; border-bottom: 1px solid var(--border);
}
header h1 { margin: 0; font-size: 18px; }
header span { color: var(--muted); font-size: 12px; }
nav { display: flex; gap: 4px; padding: 10px 20px 0; }
nav button {
background: none; border: 1px solid transparent; border-bottom: none;
color: var(--muted); padding: 8px 16px; cursor: pointer;
border-radius: 8px 8px 0 0; font-size: 14px;
}
nav button.active {
background: var(--panel); border-color: var(--border); color: var(--text);
}
main { padding: 16px 20px 40px; }
section { display: none; }
section.active { display: block; }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
input, select {
background: var(--panel); border: 1px solid var(--border); color: var(--text);
border-radius: 6px; padding: 7px 10px; font-size: 14px;
}
input:focus, select:focus { outline: 1px solid var(--accent); }
button.btn {
background: var(--accent); border: none; color: #0d1117; font-weight: 600;
border-radius: 6px; padding: 7px 14px; cursor: pointer; font-size: 14px;
}
button.btn.secondary { background: var(--panel); color: var(--text); border: 1px solid var(--border); font-weight: 400; }
button.btn.danger { background: none; color: var(--danger); border: 1px solid transparent; padding: 3px 8px; }
button.btn.danger:hover { border-color: var(--danger); }
button.btn.small { padding: 3px 8px; font-weight: 400; }
form.add {
display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;
background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 12px; margin-bottom: 16px;
}
form.add label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--muted); }
form.add select[multiple] { min-width: 160px; min-height: 64px; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; font-size: 12px; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
td.actions { text-align: right; white-space: nowrap; }
td .muted { color: var(--muted); }
#toast {
position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%);
background: var(--danger); color: #fff; padding: 10px 18px; border-radius: 8px;
display: none; max-width: 80vw; z-index: 10;
}
#player {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 9;
display: flex; align-items: center; gap: 14px;
padding: 8px 20px; background: var(--panel); border-top: 1px solid var(--border);
}
#player-title { min-width: 200px; max-width: 40vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#player audio { flex: 1; max-width: 640px; height: 36px; }
main { padding-bottom: 90px; }
#toast.ok { background: #3f9d5f; }
dialog {
background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 10px; max-width: min(700px, 90vw); max-height: 80vh;
}
dialog::backdrop { background: rgba(0,0,0,.5); }
dialog pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.empty { color: var(--muted); padding: 20px 0; }
</style>
</head>
<body>
<header><h1>furumi-fd</h1><span>менеджер музыкальной библиотеки (индекс + прослушивание; файлы никогда не изменяются)</span></header>
<nav id="tabs">
<button data-tab="artists" class="active">Артисты</button>
<button data-tab="releases">Релизы</button>
<button data-tab="tracks">Треки</button>
<button data-tab="files">Файлы</button>
<button data-tab="federation">Федерация</button>
</nav>
<main>
<section id="tab-artists" class="active">
<form class="add" id="artist-form">
<label>Имя <input name="name" required placeholder="Boards of Canada"></label>
<button class="btn" type="submit">Добавить артиста</button>
</form>
<div class="toolbar">
<input id="artists-q" placeholder="Поиск…">
</div>
<table><thead><tr>
<th class="num">ID</th><th>Имя</th><th>Создан</th><th></th>
</tr></thead><tbody id="artists-body"></tbody></table>
<div class="empty" id="artists-empty" hidden>Пусто</div>
</section>
<section id="tab-releases">
<form class="add" id="release-form">
<label>Название <input name="title" required placeholder="Music Has the Right…"></label>
<label>Тип <select name="release_type">
<option value="album">Альбом</option><option value="single">Сингл</option>
<option value="ep">EP</option><option value="compilation">Сборник</option>
<option value="mixtape">Микстейп</option><option value="live">Концерт</option>
<option value="soundtrack">Саундтрек</option><option value="remix">Ремикс</option>
<option value="demo">Демо</option>
</select></label>
<label>Год <input name="year" type="number" min="0" max="3000" style="width:90px"></label>
<label>Артисты <select name="artist_ids" multiple id="release-artists-select"></select></label>
<button class="btn" type="submit">Добавить релиз</button>
</form>
<div class="toolbar">
<input id="releases-q" placeholder="Поиск…">
</div>
<table><thead><tr>
<th class="num">ID</th><th>Название</th><th>Тип</th><th class="num">Год</th><th>Артисты</th><th></th>
</tr></thead><tbody id="releases-body"></tbody></table>
<div class="empty" id="releases-empty" hidden>Пусто</div>
</section>
<section id="tab-tracks">
<form class="add" id="track-form">
<label>Название <input name="title" required placeholder="Wildlife Analysis"></label>
<label>Релиз <select name="release_id" required id="track-release-select"></select></label>
<label>Аудиофайл <select name="audio_file_id" required id="track-audio-select"></select></label>
<label># <input name="track_number" type="number" min="0" style="width:60px"></label>
<label>Длит., c <input name="duration_seconds" type="number" min="0" step="0.1" style="width:90px"></label>
<label>Артисты <select name="artists" multiple id="track-artists-select"></select></label>
<button class="btn" type="submit">Добавить трек</button>
</form>
<div class="toolbar">
<input id="tracks-q" placeholder="Поиск…">
</div>
<table><thead><tr>
<th class="num">ID</th><th>Название</th><th>Релиз</th><th class="num">#</th><th class="num">Длит.</th><th></th>
</tr></thead><tbody id="tracks-body"></tbody></table>
<div class="empty" id="tracks-empty" hidden>Пусто</div>
</section>
<section id="tab-files">
<form class="add" id="file-form">
<label>Тип <select name="file_type">
<option value="audio">audio</option><option value="cover_art">cover_art</option>
</select></label>
<label>Путь (от media root) <input name="file_path" required style="width:340px" placeholder="Artist/Album/01 Track.flac"></label>
<label>MIME <input name="mime_type" placeholder="audio/flac" style="width:130px"></label>
<button class="btn" type="submit">Добавить файл</button>
</form>
<div class="toolbar">
<input id="files-q" placeholder="Поиск по пути…">
<select id="files-type">
<option value="">все типы</option>
<option value="audio">audio</option>
<option value="cover_art">cover_art</option>
</select>
</div>
<table><thead><tr>
<th class="num">ID</th><th>Тип</th><th>Путь</th><th>MIME</th><th></th>
</tr></thead><tbody id="files-body"></tbody></table>
<div class="empty" id="files-empty" hidden>Пусто</div>
</section>
<section id="tab-federation">
<form class="add" id="federation-form">
<label style="flex-direction:row;align-items:center;gap:8px;font-size:14px;color:var(--text)">
<input type="checkbox" name="enabled" id="fed-enabled" style="width:auto"> Включить федерацию
</label>
<label>ID сети (общий секрет участников)
<input name="network_id" id="fed-network" placeholder="my-music-network-x7f3" style="width:260px">
</label>
<button class="btn" type="submit">Сохранить</button>
<button class="btn secondary" type="button" id="fed-sync-now">Синхронизировать сейчас</button>
</form>
<div id="fed-status" class="empty">Загрузка…</div>
<h3 style="margin:20px 0 8px">Поиск по сети</h3>
<div class="toolbar">
<input id="fed-q" placeholder="Артист, релиз или трек…" style="width:280px">
<select id="fed-kind">
<option value="">все типы</option>
<option value="artist">артисты</option>
<option value="release">релизы</option>
<option value="track">треки</option>
</select>
<button class="btn" id="fed-search-btn">Искать</button>
<span id="fed-search-meta" style="color:var(--muted);align-self:center"></span>
</div>
<table><thead><tr>
<th>Тип</th><th>Название</th><th>Артисты</th><th class="num">Год</th><th>Детали</th><th>Владелец</th><th></th>
</tr></thead><tbody id="fed-results-body"></tbody></table>
<div class="empty" id="fed-results-empty" hidden>Ничего не найдено</div>
</section>
</main>
<div id="player" hidden>
<span id="player-title" class="muted"></span>
<audio id="player-audio" controls></audio>
</div>
<div id="toast"></div>
<dialog id="detail-dialog">
<div style="display:flex;justify-content:space-between;align-items:center">
<strong id="detail-title"></strong>
<button class="btn secondary small" onclick="document.getElementById('detail-dialog').close()">закрыть</button>
</div>
<pre id="detail-body"></pre>
</dialog>
<script>
const API = '/api';
const LIMIT = 500;
// ---------- helpers ----------
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'content-type': 'application/json' },
...opts,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.status + ' ' + res.statusText);
return data;
}
let toastTimer;
function toast(message, ok = false) {
const el = document.getElementById('toast');
el.textContent = message;
el.className = ok ? 'ok' : '';
el.style.display = 'block';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.style.display = 'none'; }, ok ? 2500 : 6000);
}
function esc(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
function debounce(fn, ms = 300) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}
async function showDetail(title, path) {
try {
const data = await api(path);
document.getElementById('detail-title').textContent = title;
document.getElementById('detail-body').textContent = JSON.stringify(data, null, 2);
document.getElementById('detail-dialog').showModal();
} catch (err) { toast(err.message); }
}
function rowButtons(kind, id, extra = '') {
return `<td class="actions">${extra}
<button class="btn secondary small" onclick="viewItem('${kind}', ${id})">инфо</button>
<button class="btn danger" onclick="deleteItem('${kind}', ${id})">удалить</button>
</td>`;
}
function escAttr(value) {
return esc(value).replaceAll('"', '&quot;');
}
// ---------- player ----------
function playUrl(url, title) {
document.getElementById('player').hidden = false;
document.getElementById('player-title').textContent = title;
const audio = document.getElementById('player-audio');
audio.src = url;
audio.play().catch(err => toast('Не удалось запустить воспроизведение: ' + err.message));
}
document.getElementById('player-audio').addEventListener('error', async () => {
const audio = document.getElementById('player-audio');
if (!audio.src) return;
// The stream endpoint reports problems as JSON; surface them.
try {
const res = await fetch(audio.src, { headers: { range: 'bytes=0-' } });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
toast(data.error || `Ошибка воспроизведения (${res.status})`);
return;
}
} catch { /* fall through to the generic message */ }
toast('Ошибка воспроизведения: формат не поддерживается браузером или поток оборвался');
});
document.addEventListener('click', event => {
const local = event.target.closest('button.play-track');
if (local) {
playUrl(`${API}/tracks/${local.dataset.id}/audio`, local.dataset.title);
return;
}
const fed = event.target.closest('button.play-fed');
if (fed) {
const params = new URLSearchParams({ owner: fed.dataset.owner, item_id: fed.dataset.item });
const from = fed.dataset.own === '1' ? 'своя библиотека' : `пир ${fed.dataset.owner.slice(0, 12)}…`;
playUrl(`${API}/federation/stream?${params}`, `${fed.dataset.title}${from}`);
}
});
const KIND_PATHS = { artist: 'artists', release: 'releases', track: 'tracks', file: 'media-files' };
const KIND_NAMES = { artist: 'Артист', release: 'Релиз', track: 'Трек', file: 'Файл' };
const KIND_CONFIRM = {
artist: 'Удалить артиста? Его релизы и треки останутся, снимутся только связи.',
release: 'Удалить релиз? Его треки тоже будут удалены из базы (файлы не трогаются).',
track: 'Удалить трек из базы? Файл на диске не трогается.',
file: 'Удалить запись о файле? Сам файл на диске не трогается.',
};
function viewItem(kind, id) {
showDetail(`${KIND_NAMES[kind]} #${id}`, `/${KIND_PATHS[kind]}/${id}`);
}
async function deleteItem(kind, id) {
if (!confirm(KIND_CONFIRM[kind])) return;
try {
await api(`/${KIND_PATHS[kind]}/${id}`, { method: 'DELETE' });
toast(`${KIND_NAMES[kind]} #${id} удалён`, true);
refreshAll();
} catch (err) { toast(err.message); }
}
function fmtDuration(seconds) {
if (!seconds) return '';
const m = Math.floor(seconds / 60), s = Math.round(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
function renderRows(tbodyId, emptyId, rows, renderRow) {
document.getElementById(tbodyId).innerHTML = rows.map(renderRow).join('');
document.getElementById(emptyId).hidden = rows.length > 0;
}
function fillSelect(select, items, label, { placeholder } = {}) {
const selected = new Set([...select.selectedOptions].map(o => o.value));
select.innerHTML = (placeholder ? `<option value="">${placeholder}</option>` : '')
+ items.map(i => `<option value="${i.id}" ${selected.has(String(i.id)) ? 'selected' : ''}>${esc(label(i))}</option>`).join('');
}
// ---------- data caches (for name lookups and selects) ----------
let artistsCache = [], releasesCache = [], audioFilesCache = [];
// ---------- loaders ----------
async function loadArtists() {
const q = document.getElementById('artists-q').value.trim();
const rows = await api(`/artists?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
if (!q) artistsCache = rows;
renderRows('artists-body', 'artists-empty', rows, a => `<tr>
<td class="num">${a.id}</td>
<td>${esc(a.name)}</td>
<td class="muted">${esc(a.created_at)}</td>
${rowButtons('artist', a.id)}
</tr>`);
const artistLabel = a => `${a.name} (#${a.id})`;
fillSelect(document.getElementById('release-artists-select'), artistsCache, artistLabel);
fillSelect(document.getElementById('track-artists-select'), artistsCache, artistLabel);
}
async function loadReleases() {
const q = document.getElementById('releases-q').value.trim();
const rows = await api(`/releases?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
if (!q) releasesCache = rows;
// artist names per release come from the detail endpoint; to keep the list
// fast we show them only when artistsCache can't be avoided — fetch lazily.
renderRows('releases-body', 'releases-empty', rows, r => `<tr>
<td class="num">${r.id}</td>
<td>${esc(r.title)}</td>
<td class="muted">${esc(r.release_type)}</td>
<td class="num">${r.year ?? ''}</td>
<td class="muted" id="release-artists-${r.id}">…</td>
${rowButtons('release', r.id)}
</tr>`);
fillSelect(document.getElementById('track-release-select'), releasesCache,
r => `${r.title}${r.year ? ' (' + r.year + ')' : ''} (#${r.id})`,
{ placeholder: '— релиз —' });
fillReleaseArtists(rows);
}
async function fillReleaseArtists(rows) {
for (const r of rows) {
try {
const detail = await api(`/releases/${r.id}`);
const cell = document.getElementById(`release-artists-${r.id}`);
if (cell) cell.textContent = detail.artists.map(a => a.name).join(', ');
} catch { /* list may have been re-rendered */ }
}
}
async function loadTracks() {
const q = document.getElementById('tracks-q').value.trim();
const rows = await api(`/tracks?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
const releaseTitles = Object.fromEntries(releasesCache.map(r => [r.id, r.title]));
renderRows('tracks-body', 'tracks-empty', rows, t => `<tr>
<td class="num">${t.id}</td>
<td>${esc(t.title)}</td>
<td class="muted">${esc(releaseTitles[t.release_id] ?? '#' + t.release_id)}</td>
<td class="num">${t.track_number ?? ''}</td>
<td class="num">${fmtDuration(t.duration_seconds)}</td>
${rowButtons('track', t.id,
`<button class="btn secondary small play-track" data-id="${t.id}" data-title="${escAttr(t.title)}">▶</button>`)}
</tr>`);
}
async function loadFiles() {
const q = document.getElementById('files-q').value.trim();
const type = document.getElementById('files-type').value;
const rows = await api(`/media-files?limit=${LIMIT}`
+ (q ? '&q=' + encodeURIComponent(q) : '')
+ (type ? '&file_type=' + type : ''));
if (!q && !type) audioFilesCache = rows.filter(f => f.file_type === 'audio');
renderRows('files-body', 'files-empty', rows, f => `<tr>
<td class="num">${f.id}</td>
<td class="muted">${esc(f.file_type)}</td>
<td>${esc(f.file_path)}</td>
<td class="muted">${esc(f.mime_type)}</td>
${rowButtons('file', f.id)}
</tr>`);
fillSelect(document.getElementById('track-audio-select'), audioFilesCache,
f => `${f.file_path} (#${f.id})`, { placeholder: '— аудиофайл —' });
}
async function refreshAll() {
try {
await loadArtists();
await loadFiles();
await loadReleases();
await loadTracks();
} catch (err) { toast(err.message); }
}
// ---------- forms ----------
function onSubmit(formId, handler) {
document.getElementById(formId).addEventListener('submit', async event => {
event.preventDefault();
const form = event.target;
try {
await handler(new FormData(form), form);
form.reset();
toast('Добавлено', true);
refreshAll();
} catch (err) { toast(err.message); }
});
}
onSubmit('artist-form', fd =>
api('/artists', { method: 'POST', body: JSON.stringify({ name: fd.get('name') }) }));
onSubmit('release-form', (fd, form) => {
const artistIds = [...form.querySelector('[name=artist_ids]').selectedOptions].map(o => +o.value);
return api('/releases', { method: 'POST', body: JSON.stringify({
title: fd.get('title'),
release_type: fd.get('release_type'),
year: fd.get('year') ? +fd.get('year') : null,
artist_ids: artistIds,
}) });
});
onSubmit('track-form', (fd, form) => {
const artists = [...form.querySelector('[name=artists]').selectedOptions]
.map(o => ({ artist_id: +o.value, role: 'main' }));
return api('/tracks', { method: 'POST', body: JSON.stringify({
title: fd.get('title'),
release_id: +fd.get('release_id'),
audio_file_id: +fd.get('audio_file_id'),
track_number: fd.get('track_number') ? +fd.get('track_number') : null,
duration_seconds: fd.get('duration_seconds') ? +fd.get('duration_seconds') : 0,
artists,
}) });
});
onSubmit('file-form', fd =>
api('/media-files', { method: 'POST', body: JSON.stringify({
file_type: fd.get('file_type'),
file_path: fd.get('file_path'),
mime_type: fd.get('mime_type') || '',
}) }));
// ---------- federation ----------
const FED_KIND_NAMES = { artist: 'артист', release: 'релиз', track: 'трек' };
function shortId(id) { return id ? id.slice(0, 12) + '…' : ''; }
function renderFedStatus(data) {
document.getElementById('fed-enabled').checked = data.settings.enabled;
const networkInput = document.getElementById('fed-network');
if (document.activeElement !== networkInput) networkInput.value = data.settings.network_id;
const el = document.getElementById('fed-status');
if (!data.node.running) {
el.innerHTML = data.settings.enabled
? 'Узел не запущен.' + (data.last_error ? ' Ошибка: ' + esc(data.last_error) : '')
: 'Федерация выключена. Включите её и укажите ID сети — все инстансы с тем же ID найдут друг друга автоматически.';
return;
}
const n = data.node;
const sync = data.last_sync
? `${data.last_sync.at} (+${data.last_sync.added} / ~${data.last_sync.updated} / ${data.last_sync.removed}, без изменений ${data.last_sync.unchanged})`
: 'ещё не было';
el.innerHTML = `
<table style="max-width:720px">
<tr><td class="muted">Сеть</td><td>${esc(n.network)}</td></tr>
<tr><td class="muted">Endpoint ID</td><td title="${esc(n.endpoint_id)}">${esc(shortId(n.endpoint_id))}</td></tr>
<tr><td class="muted">Подключено пиров</td><td>${n.connected_peers.length}${n.connected_peers.length ? ' — ' + n.connected_peers.map(shortId).map(esc).join(', ') : ''}</td></tr>
<tr><td class="muted">Известно контактов</td><td>${n.known_contacts}</td></tr>
<tr><td class="muted">Опубликовано записей</td><td>${n.published_items}</td></tr>
<tr><td class="muted">Последняя синхронизация</td><td>${esc(sync)}</td></tr>
${data.last_error ? `<tr><td class="muted">Ошибка</td><td style="color:var(--danger)">${esc(data.last_error)}</td></tr>` : ''}
</table>`;
}
async function loadFederation() {
try { renderFedStatus(await api('/federation')); }
catch (err) { document.getElementById('fed-status').textContent = err.message; }
}
document.getElementById('federation-form').addEventListener('submit', async event => {
event.preventDefault();
try {
const data = await api('/federation/settings', { method: 'PUT', body: JSON.stringify({
enabled: document.getElementById('fed-enabled').checked,
network_id: document.getElementById('fed-network').value.trim(),
}) });
renderFedStatus(data);
toast('Настройки федерации сохранены', true);
} catch (err) { toast(err.message); }
});
document.getElementById('fed-sync-now').addEventListener('click', async () => {
try {
renderFedStatus(await api('/federation/sync', { method: 'POST' }));
toast('Синхронизация выполнена', true);
} catch (err) { toast(err.message); }
});
function fedDetails(r) {
if (r.kind === 'release') return [r.release_type, r.year].filter(Boolean).join(', ');
if (r.kind === 'track') return fmtDuration(r.duration_seconds);
return '';
}
async function fedSearch() {
const q = document.getElementById('fed-q').value.trim();
if (!q) return;
const kind = document.getElementById('fed-kind').value;
const meta = document.getElementById('fed-search-meta');
meta.textContent = 'ищем…';
try {
const data = await api(`/federation/search?q=${encodeURIComponent(q)}${kind ? '&kind=' + kind : ''}`);
renderRows('fed-results-body', 'fed-results-empty', data.results, r => `<tr>
<td class="muted">${FED_KIND_NAMES[r.kind] ?? esc(r.kind)}</td>
<td>${esc(r.name)}</td>
<td class="muted">${esc((r.artist_names || []).join(', '))}</td>
<td class="num">${r.year ?? ''}</td>
<td class="muted">${esc(fedDetails(r))}</td>
<td class="muted" title="${esc(r.owner)}">${r.own ? 'вы' : esc(shortId(r.owner))}</td>
<td class="actions">${r.kind === 'track'
? `<button class="btn secondary small play-fed" data-owner="${escAttr(r.owner)}" data-item="${escAttr(r.id)}" data-own="${r.own ? 1 : 0}" data-title="${escAttr(r.name)}">▶</button>`
: ''}</td>
</tr>`);
meta.textContent = `узлов опрошено: ${data.queried_nodes}, ${data.duration_ms} мс`;
} catch (err) {
meta.textContent = '';
toast(err.message);
}
}
document.getElementById('fed-search-btn').addEventListener('click', fedSearch);
document.getElementById('fed-q').addEventListener('keydown', e => { if (e.key === 'Enter') fedSearch(); });
// Poll the status while the federation tab is visible.
let fedTimer = null;
function setFederationPolling(active) {
clearInterval(fedTimer);
fedTimer = null;
if (active) {
loadFederation();
fedTimer = setInterval(loadFederation, 5000);
}
}
// ---------- search & tabs ----------
document.getElementById('artists-q').addEventListener('input', debounce(loadArtists));
document.getElementById('releases-q').addEventListener('input', debounce(loadReleases));
document.getElementById('tracks-q').addEventListener('input', debounce(loadTracks));
document.getElementById('files-q').addEventListener('input', debounce(loadFiles));
document.getElementById('files-type').addEventListener('change', loadFiles);
document.getElementById('tabs').addEventListener('click', event => {
const button = event.target.closest('button[data-tab]');
if (!button) return;
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('active', b === button));
document.querySelectorAll('main section').forEach(s =>
s.classList.toggle('active', s.id === 'tab-' + button.dataset.tab));
setFederationPolling(button.dataset.tab === 'federation');
});
refreshAll();
</script>
</body>
</html>