Files
radio-explorer/server/public/assets/admin-GqZPhz-K.js
Marco Mooren b86dcfbb8d Add master display UI with audio output management and styling
- 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.
2026-05-11 17:55:09 +02:00

2 lines
25 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"./modulepreload-polyfill-B5Qt9EMX.js";import{P,a as c,c as y,e}from"./player-BBOsFRH-.js";const C=document.getElementById("app"),l={user:null,view:"stations",stations:[],users:[],rooms:[],leaderboard:[],system:null,search:"",sourceFilter:"",selected:new Set,discoverResults:[],discoverQuery:{q:"",country:"",tag:""}},I=new P({onState:s=>{Object.assign(f,s),R()}}),f={stationId:null,playing:!1,loading:!1};async function L(){try{l.user=await c.get("/api/auth/me")}catch{return q()}if(l.user.role!=="admin"){C.innerHTML=`<div class="login"><div><h1>Admin only</h1><p>Signed in as ${l.user.username} (${l.user.role}).</p></div></div>`;return}await h(),g()}async function h(){const s=[c.get("/api/stations?all=1")];l.view==="users"&&s.push(c.get("/api/auth/users")),l.view==="system"&&s.push(c.get("/api/admin/system")),l.view==="rooms"&&s.push(c.get("/api/admin/rooms")),l.view==="leaderboard"&&s.push(c.get("/api/admin/leaderboard"));const t=await Promise.all(s);l.stations=t[0],l.view==="users"&&(l.users=t[1]||[]),l.view==="system"&&(l.system=t[1]||null),l.view==="rooms"&&(l.rooms=t[1]||[]),l.view==="leaderboard"&&(l.leaderboard=t[1]||[])}function q(){y(C),C.appendChild(e("div",{class:"login"},e("form",{onSubmit:async s=>{s.preventDefault();const t=new FormData(s.target);try{l.user=await c.post("/api/auth/login",{username:t.get("username"),password:t.get("password")}),await L()}catch(a){s.target.querySelector(".err").textContent=a.message}}},e("h1",{},"Admin sign in"),e("input",{name:"username",placeholder:"Username",required:!0}),e("input",{name:"password",type:"password",placeholder:"Password",required:!0}),e("div",{class:"err"}),e("button",{class:"btn primary",type:"submit"},"Sign in"))))}function g(){y(C);const s=["stations","discover","leaderboard","rooms","users","system"],t=e("aside",{class:"side"},e("h1",{},"OnlineRadio · Admin"),...s.map(i=>e("button",{class:`nav ${l.view===i?"active":""}`,onClick:async()=>{l.view=i,l.selected.clear(),await h(),g()}},B(i))),e("div",{class:"me"},`Signed in as ${l.user.username}`,e("br"),e("a",{href:"/master",target:"_blank"},"Open master ↗"),e("br"),e("a",{href:"/",target:"_blank"},"Open kiosk ↗"),e("br"),e("a",{href:"#",onClick:async i=>{i.preventDefault(),await c.post("/api/auth/logout"),location.reload()}},"Sign out"))),a=e("main",{class:"main"});C.appendChild(e("div",{class:"shell"},t,a)),l.view==="stations"?N(a):l.view==="discover"?A(a):l.view==="leaderboard"?j(a):l.view==="rooms"?G(a):l.view==="users"?H(a):l.view==="system"&&V(a)}const B=s=>({stations:"Stations",discover:"Discover",leaderboard:"Leaderboard",rooms:"Rooms",users:"Users",system:"System"})[s];function N(s){s.appendChild(e("div",{class:"bar"},e("input",{placeholder:"Search name / country / genre…",value:l.search,onInput:t=>{l.search=t.target.value,w()}}),e("select",{onChange:t=>{l.sourceFilter=t.target.value,w()}},e("option",{value:""},"All sources"),...U(l.stations).map(t=>e("option",{value:t,selected:l.sourceFilter===t},t))),e("button",{class:"btn primary",onClick:()=>T()},"+ Add station"),e("button",{class:"btn",onClick:async()=>{if(!confirm("Run health check on every stream?"))return;const t=await c.post("/api/admin/health-check");alert(`Checked ${t.checked} streams.`),await h(),g()}},"Health check"))),s.appendChild(e("div",{id:"bulkSlot"})),s.appendChild(e("div",{id:"tableWrap"})),w()}function U(s){return[...new Set(s.map(t=>t.source).filter(Boolean))].sort()}function w(){const s=document.getElementById("tableWrap"),t=document.getElementById("bulkSlot");if(!s||!t)return;y(s),y(t);const a=l.search.toLowerCase(),i=l.sourceFilter,n=l.stations.filter(o=>(!i||o.source===i)&&(!a||o.name.toLowerCase().includes(a)||(o.country||"").toLowerCase().includes(a)||(o.genres||[]).some(m=>m.toLowerCase().includes(a))));l.selected.size&&t.appendChild(O());const r=n.length&&n.every(o=>l.selected.has(o.id)),d=e("table",{},e("thead",{},e("tr",{},e("th",{style:{width:"32px"}},e("input",{type:"checkbox",checked:r,onChange:o=>{o.target.checked?n.forEach(m=>l.selected.add(m.id)):n.forEach(m=>l.selected.delete(m.id)),w()}})),e("th",{},"Name"),e("th",{},"Source"),e("th",{},"Genres"),e("th",{},"Country"),e("th",{},"Streams"),e("th",{},"▲/▼/▶"),e("th",{},"On"),e("th",{},"Actions"))),e("tbody",{},...n.map(o=>{const m=o.image_display_url||o.image_url;return e("tr",{"data-id":o.id},e("td",{},e("input",{type:"checkbox",checked:l.selected.has(o.id),onChange:p=>{p.target.checked?l.selected.add(o.id):l.selected.delete(o.id),w()}})),e("td",{},e("div",{class:"station-cell"},e("div",{class:"station-art-thumb"+(m?"":" empty")},m?e("img",{src:m,alt:"",loading:"lazy",referrerpolicy:"no-referrer",onError:p=>{const u=p.target.parentNode;p.target.remove(),u&&u.classList.add("empty")}}):null),e("div",{class:"meta"},e("strong",{},o.name),e("small",{},o.homepage||o.uuid)))),e("td",{},o.source||""),e("td",{},...(o.genres||[]).slice(0,3).map(p=>e("span",{class:"tag"},p))),e("td",{},o.country||""),e("td",{},String(o.stream_count??"—")),e("td",{},`${o.up||0}/${o.down||0}/${o.plays||0}`),e("td",{},o.enabled?"✓":"✗"),e("td",{},W(o)," ",e("button",{class:"btn",onClick:()=>T(o.id)},"Edit")," ",e("button",{class:"btn danger",onClick:async()=>{await _({stations:[o]})&&(await c.del(`/api/admin/stations/${o.id}`),await h(),g())}},"×")))})));s.appendChild(d)}function O(s){const t=[...l.selected];async function a(n,r){if(r&&!confirm(`${r} (${t.length} stations)`))return;const d=await c.post("/api/admin/stations/bulk",{ids:t,action:n});alert(`${n}: ${d.ok} ok / ${d.failed} failed`),l.selected.clear(),await h(),g()}async function i(){const n=l.stations.filter(d=>l.selected.has(d.id));if(!n.length||!await _({stations:n}))return;const r=await c.post("/api/admin/stations/bulk",{ids:n.map(d=>d.id),action:"delete"});alert(`delete: ${r.ok} ok / ${r.failed} failed`),l.selected.clear(),await h(),g()}return e("div",{class:"bulkbar"},e("span",{class:"count"},`${t.length} selected`),e("button",{class:"btn",onClick:()=>a("enable")},"Enable"),e("button",{class:"btn",onClick:()=>a("disable")},"Disable"),e("button",{class:"btn",onClick:()=>a("scrape-icon","Scrape icons for")},"Scrape icons"),e("button",{class:"btn",onClick:()=>a("refetch-image","Refetch images for")},"Refetch images"),e("button",{class:"btn danger",onClick:()=>i()},"Delete"),e("button",{class:"btn",onClick:()=>{l.selected.clear(),w()}},"Clear"))}function W(s){const t=f.stationId===s.id&&f.playing;return e("span",{class:"preview-player"+(t?" playing":""),"data-preview-station":s.id},e("button",{title:"Preview",onClick:a=>{a.stopPropagation(),f.stationId===s.id&&f.playing?(I.stop(),f.stationId=null,f.playing=!1):(f.stationId=s.id,I.play(s)),R()}},t?"❚❚":"▶"))}function R(){document.querySelectorAll(".preview-player").forEach(s=>{const t=Number(s.getAttribute("data-preview-station")),a=f.stationId===t&&f.playing;s.classList.toggle("playing",a);const i=s.querySelector("button");i&&(i.textContent=a?"❚❚":f.stationId===t&&f.loading?"…":"▶")})}async function T(s){const t=s?await c.get(`/api/stations/${s}`):{name:"",genres:[],streams:[],enabled:!0,image_url:"",country:"",homepage:""},a=e("dialog",{class:"wide"});let i="details";const n=e("div",{class:"tab-body"}),r=e("div",{class:"tabs"},...[["details","Details"],["streams","Streams"],["image","Image"],["stats","Stats"]].map(([m,p])=>e("button",{type:"button",class:i===m?"active":"",onClick:()=>{i=m,d()}},p)));function d(){y(n),r.querySelectorAll("button").forEach((m,p)=>{const u=["details","streams","image","stats"][p];m.classList.toggle("active",u===i)}),i==="details"?M(n,t):i==="streams"?F(n,t):i==="image"?z(n,t,async()=>{s&&Object.assign(t,await c.get(`/api/stations/${s}`)),d()}):i==="stats"&&S(n,t,s)}d();const o=e("div",{class:"actions"},s?e("button",{class:"btn danger",type:"button",onClick:async()=>{await _({stations:[t]})&&(await c.del(`/api/admin/stations/${s}`),a.close(),await h(),g())}},"Delete"):null,e("button",{class:"btn",type:"button",onClick:()=>a.close()},"Cancel"),e("button",{class:"btn primary",type:"button",onClick:async()=>{const m={name:t.name,homepage:t.homepage,country:t.country,genres:t.genres,description:t.description,image_url:t.image_url,category:t.category,enabled:t.enabled};if(s)await c.patch(`/api/admin/stations/${s}`,m);else{m.streams=(t.streams||[]).filter(u=>u.url);const p=await c.post("/api/stations",m);Object.assign(t,p)}a.close(),await h(),g()}},"Save"));a.appendChild(e("form",{method:"dialog",onSubmit:m=>m.preventDefault()},e("h2",{},s?`Edit · ${t.name}`:"Add station"),r,n,o)),document.body.appendChild(a),a.showModal(),a.addEventListener("close",()=>a.remove())}function M(s,t){if(s.appendChild(e("div",{class:"row"},e("label",{},"Name"),e("input",{value:t.name||"",onInput:a=>t.name=a.target.value,required:!0}))),s.appendChild(e("div",{class:"row"},e("label",{},"Homepage"),e("input",{value:t.homepage||"",onInput:a=>t.homepage=a.target.value}))),s.appendChild(e("div",{class:"row"},e("label",{},"Country"),e("input",{value:t.country||"",maxlength:4,onInput:a=>t.country=a.target.value}))),s.appendChild(e("div",{class:"row"},e("label",{},"Genres (CSV)"),e("input",{value:(t.genres||[]).join(", "),onInput:a=>t.genres=a.target.value.split(",").map(i=>i.trim()).filter(Boolean)}))),s.appendChild(e("div",{class:"row"},e("label",{},"Category"),e("input",{value:t.category||"",onInput:a=>t.category=a.target.value}))),s.appendChild(e("div",{class:"row"},e("label",{},"Description"),e("textarea",{rows:4,placeholder:"Short description shown to listeners",onInput:a=>t.description=a.target.value},t.description||""))),s.appendChild(e("div",{class:"row"},e("label",{},"Enabled"),e("input",{type:"checkbox",checked:t.enabled,onChange:a=>t.enabled=a.target.checked}))),t.id){const a=(i,n)=>e("div",{class:"meta-row"},e("span",{class:"meta-k"},i),e("span",{class:"meta-v mono"},n==null||n===""?"—":String(n)));s.appendChild(e("div",{class:"readonly-meta"},e("div",{class:"readonly-meta-head"},"Read-only metadata (used by the public API)"),a("id",t.id),a("uuid",t.uuid),a("slug",t.slug),a("source",t.source),a("source_ref",t.source_ref),a("image_source",t.image_source),a("image_path",t.image_path),a("created_at",t.created_at),a("updated_at",t.updated_at)))}}function F(s,t){if(!t.id){const r=e("div",{class:"streams"}),d=()=>{y(r),r.appendChild(e("div",{style:{fontWeight:700,marginBottom:"6px"}},"Streams"));for(const o of t.streams||[])r.appendChild(x(o,()=>{t.streams=t.streams.filter(m=>m!==o),d()}));r.appendChild(e("button",{class:"btn",type:"button",onClick:()=>{var o;t.streams=[...t.streams||[],{url:"",format:"mp3",priority:((o=t.streams)==null?void 0:o.length)||0}],d()}},"+ Add stream"))};d(),s.appendChild(r);return}const a=e("div",{class:"streams"}),i=async()=>{t.streams=await c.get(`/api/admin/stations/${t.id}/streams`),n()},n=()=>{y(a),a.appendChild(e("div",{style:{fontWeight:700,marginBottom:"6px"}},"Streams"));for(const r of t.streams||[])a.appendChild(x(r,async()=>{confirm("Delete stream?")&&(await c.del(`/api/admin/streams/${r.id}`),await i())},async()=>{await c.patch(`/api/admin/streams/${r.id}`,{url:r.url,format:r.format,bitrate:r.bitrate||null,label:r.label||null,priority:r.priority||0})},async()=>{const d=await c.post(`/api/admin/streams/${r.id}/probe`);r.last_status=d.status,n()}));a.appendChild(e("button",{class:"btn",type:"button",onClick:async()=>{var d;const r=await c.post(`/api/admin/stations/${t.id}/streams`,{url:"",format:"mp3",priority:((d=t.streams)==null?void 0:d.length)||0});t.streams=[...t.streams||[],r],n()}},"+ Add stream"))};n(),s.appendChild(a)}function x(s,t,a,i){const n=(r,d)=>{s[r]=d};return e("div",{class:"stream-row"},e("select",{onChange:r=>n("format",r.target.value)},...["mp3","aac","hls","m3u","pls","ogg","unknown"].map(r=>e("option",{value:r,selected:s.format===r},r))),e("input",{value:s.url||"",placeholder:"https://…",onInput:r=>n("url",r.target.value)}),e("input",{type:"number",placeholder:"kbps",value:s.bitrate||"",onInput:r=>n("bitrate",Number(r.target.value)||null)}),e("input",{value:s.label||"",placeholder:"Label",onInput:r=>n("label",r.target.value)}),s.last_status?e("span",{class:`pill ${s.last_status==="up"?"up":s.last_status==="down"?"down":"unknown"}`},s.last_status):e("span"),e("span",{style:{display:"flex",gap:"4px"}},i?e("button",{class:"btn",type:"button",onClick:i},"Test"):null,a?e("button",{class:"btn",type:"button",onClick:a},"Save"):null,e("button",{class:"btn danger",type:"button",onClick:t},"×")))}function z(s,t,a){if(!t.id){s.appendChild(e("p",{},"Save the station first, then come back to upload an image."));return}const i=t.image_display_url||t.image_url,n=e("div",{class:"image-area"}),r=e("div",{class:"preview",style:i?{backgroundImage:`url("${i}")`}:{}},i?"":"No image"),d=e("div",{class:"dropzone"},"Drop image file here, or click to upload"),o=e("input",{type:"file",accept:"image/*",style:{display:"none"}});async function m(u){if(!u)return;if(u.size>5*1024*1024)return alert("File is too large (5 MB max).");const v=await u.arrayBuffer(),D=await fetch(`/api/admin/stations/${t.id}/image`,{method:"PUT",headers:{"Content-Type":u.type||"application/octet-stream"},credentials:"same-origin",body:v});if(!D.ok){alert("Upload failed: "+D.status);return}await a()}d.addEventListener("click",()=>o.click()),d.addEventListener("dragover",u=>{u.preventDefault(),d.classList.add("over")}),d.addEventListener("dragleave",()=>d.classList.remove("over")),d.addEventListener("drop",u=>{var v;u.preventDefault(),d.classList.remove("over"),m((v=u.dataTransfer.files)==null?void 0:v[0])}),o.addEventListener("change",()=>{var u;return m((u=o.files)==null?void 0:u[0])});const p=e("div",{class:"actions-col"},e("div",{class:"row"},e("label",{},"Image URL"),e("input",{value:t.image_url||"",onInput:u=>t.image_url=u.target.value})),d,o,e("div",{style:{display:"flex",gap:"6px",flexWrap:"wrap"}},e("button",{class:"btn",type:"button",onClick:async()=>{await c.patch(`/api/admin/stations/${t.id}`,{image_url:t.image_url});const u=await fetch(`/api/admin/stations/${t.id}/image/refetch`,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"same-origin",body:JSON.stringify({url:t.image_url})});if(!u.ok){alert("Refetch failed: "+u.status);return}await a()}},"Refetch from URL"),e("button",{class:"btn",type:"button",onClick:async()=>{const u=await c.post(`/api/admin/stations/${t.id}/scrape-icon`).catch(v=>({error:v.message}));u.error&&alert("Scrape failed: "+u.error),await a()}},"Auto-scrape"),e("button",{class:"btn danger",type:"button",onClick:async()=>{confirm("Drop the cached image?")&&(await c.del(`/api/admin/stations/${t.id}/image`),await a())}},"Clear cache")),e("div",{style:{fontSize:"11px",color:"var(--muted)"}},`Source: ${t.image_source||"—"} · Path: ${t.image_path||"—"}`));n.appendChild(r),n.appendChild(p),s.appendChild(n)}function S(s,t,a){if(!a){s.appendChild(e("p",{},"Stats are available after the station is created."));return}s.appendChild(e("div",{class:"system-grid"},b("Votes up",t.up||0),b("Votes down",t.down||0),b("Score",(t.score||0).toFixed(2)),b("Plays",t.plays||0),b("Streams",(t.streams||[]).length))),s.appendChild(e("div",{style:{marginTop:"12px",display:"flex",gap:"8px",flexWrap:"wrap"}},e("button",{class:"btn danger",onClick:async()=>{confirm("Delete all votes for this station?")&&(await c.del(`/api/admin/stations/${a}/votes`),Object.assign(t,await c.get(`/api/stations/${a}`)),S($(s),t,a))}},"Reset votes"),e("button",{class:"btn danger",onClick:async()=>{confirm("Delete all plays for this station?")&&(await c.del(`/api/admin/stations/${a}/plays`),Object.assign(t,await c.get(`/api/stations/${a}`)),S($(s),t,a))}},"Reset plays")))}function $(s){return y(s),s}function b(s,t){return e("div",{class:"stat"},e("div",{class:"v"},String(t)),e("div",{class:"k"},s))}function _({stations:s}){const t=Array.isArray(s)?s:[s],a=t.length===1?t[0]:null,i=a?a.name:"DELETE";return new Promise(n=>{const r=e("dialog",{class:"danger-confirm"});let d="";const o=e("input",{placeholder:`Type "${i}" to confirm`,autocomplete:"off",onInput:p=>{d=p.target.value,m.disabled=d.trim()!==i}}),m=e("button",{class:"btn danger",type:"button",disabled:!0,onClick:()=>{r.close(),n(!0)}},a?"Permanently delete":`Permanently delete ${t.length} stations`);r.appendChild(e("form",{method:"dialog",onSubmit:p=>p.preventDefault()},e("div",{class:"danger-header"},e("div",{class:"danger-icon"},"⚠"),e("h2",{},a?`Delete "${a.name}"?`:`Delete ${t.length} stations?`)),e("div",{class:"danger-body"},e("p",{class:"lede"},"This is irreversible and ",e("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."),e("ul",{class:"impact"},e("li",{},e("code",{},"GET /api/v1/stations/{id}")," will return 404"),e("li",{},e("code",{},"GET /api/v1/stations/{uuid}")," will return 404"),e("li",{},"All streams, votes, plays and favorites attached to ",a?"this station":"these stations"," are dropped (cascade)"),e("li",{},"Active listeners playing ",a?"this station":"one of these"," will receive a stop event"),e("li",{},"Cached image files are unlinked from disk")),t.length>1||a?e("div",{class:"impact-list"},e("div",{class:"impact-list-head"},"Targets:"),e("ul",{},...t.slice(0,12).map(p=>e("li",{},e("b",{},p.name),p.uuid?e("span",{class:"mono"},` · ${p.uuid}`):null,e("span",{class:"mono"},` · id=${p.id}`))),t.length>12?e("li",{class:"more"},`…and ${t.length-12} more`):null)):null,e("label",{class:"type-to-confirm"},"Type ",e("code",{},i)," to confirm:",o)),e("div",{class:"actions"},e("button",{class:"btn",type:"button",onClick:()=>{r.close(),n(!1)}},"Cancel"),m))),document.body.appendChild(r),r.addEventListener("close",()=>r.remove()),r.showModal(),setTimeout(()=>o.focus(),50)})}function A(s){s.appendChild(e("h2",{},"Discover · Radio-Browser"));const t=l.discoverQuery;async function a(){const i=new URLSearchParams;t.q&&i.set("q",t.q),t.country&&i.set("country",t.country),t.tag&&i.set("tag",t.tag),i.set("limit","50");const n=document.getElementById("discoverWrap");n&&(y(n),n.appendChild(e("p",{class:"muted"},"Loading…")));try{l.discoverResults=await c.get(`/api/stations/sources/radiobrowser/search?${i}`)}catch(r){l.discoverResults=[],n&&(y(n),n.appendChild(e("p",{class:"err"},r.message||"Search failed")));return}k()}s.appendChild(e("div",{class:"bar"},e("input",{placeholder:"Name…",value:t.q,onInput:i=>t.q=i.target.value,onKeyDown:i=>{i.key==="Enter"&&a()}}),e("input",{placeholder:"Country (e.g. NL)",value:t.country,onInput:i=>t.country=i.target.value,onKeyDown:i=>{i.key==="Enter"&&a()},style:{minWidth:"120px"}}),e("input",{placeholder:"Tag/genre",value:t.tag,onInput:i=>t.tag=i.target.value,onKeyDown:i=>{i.key==="Enter"&&a()}}),e("button",{class:"btn primary",onClick:a},"Search"),e("button",{class:"btn",onClick:()=>{t.q="",t.country="",t.tag="",A($(s))}},"Reset"),e("button",{class:"btn",onClick:async()=>{const i=l.discoverResults.filter(n=>n.__import);if(!i.length)return alert("Nothing selected.");if(confirm(`Import ${i.length} stations?`)){for(const n of i)await c.post("/api/stations/sources/radiobrowser/import",n).catch(()=>{});alert("Done."),await h(),await a()}}},"Import selected"))),s.appendChild(e("div",{id:"discoverWrap"})),l.discoverResults.length?k():a()}function k(){const s=document.getElementById("discoverWrap");if(!s)return;y(s);const t=l.discoverResults;if(!t.length){s.appendChild(e("p",{class:"muted"},"No results. Try a different query — leave fields blank for the top stations on Radio-Browser."));return}const a=new Set(l.stations.map(n=>n.uuid).filter(Boolean)),i=t.length&&t.every(n=>n.__import);s.appendChild(e("table",{},e("thead",{},e("tr",{},e("th",{style:{width:"32px"}},e("input",{type:"checkbox",checked:!!i,onChange:n=>{t.forEach(r=>{r.__import=n.target.checked&&!a.has(r.uuid)}),k()}})),e("th",{},"Name"),e("th",{},"Country"),e("th",{},"Tags"),e("th",{},"Stream"),e("th",{},"Status"),e("th",{},""))),e("tbody",{},...t.map(n=>{var o,m;const r=a.has(n.uuid),d=n.image_url;return e("tr",{class:r?"discover-existing":""},e("td",{},e("input",{type:"checkbox",checked:!!n.__import,disabled:r,onChange:p=>{n.__import=p.target.checked}})),e("td",{},e("div",{class:"station-cell"},e("div",{class:"station-art-thumb"+(d?"":" empty")},d?e("img",{src:d,alt:"",loading:"lazy",referrerpolicy:"no-referrer",onError:p=>{const u=p.target.parentNode;p.target.remove(),u&&u.classList.add("empty")}}):null),e("div",{class:"meta"},e("strong",{},n.name),e("small",{},n.homepage||n.uuid)))),e("td",{},n.country||""),e("td",{},...(n.genres||[]).slice(0,4).map(p=>e("span",{class:"tag"},p))),e("td",{},e("small",{},(((o=n.streams[0])==null?void 0:o.format)||"")+" "+((m=n.streams[0])!=null&&m.bitrate?`· ${n.streams[0].bitrate}kbps`:""))),e("td",{},r?e("span",{class:"pill up"},"in library"):e("span",{class:"pill unknown"},"new")),e("td",{},r?e("button",{class:"btn",disabled:!0},"Imported"):e("button",{class:"btn primary",onClick:async p=>{p.target.disabled=!0;try{await c.post("/api/stations/sources/radiobrowser/import",n),await h(),k()}catch(u){alert("Import failed: "+u.message)}}},"Import")))}))))}function j(s){s.appendChild(e("h2",{},"Leaderboard")),s.appendChild(e("p",{style:{color:"var(--muted)",marginTop:0}},"Top stations by total listen time. Use the reset buttons to moderate runaway counters."));const t=e("div",{class:"leaderboard"});l.leaderboard.forEach((a,i)=>{const n=a.image_display_url||(a.image_path?`/media/${a.image_path}`:a.image_url);t.appendChild(e("div",{class:"leader-row"},e("div",{class:"rank"},String(i+1)),e("div",{class:"art"+(n?"":" empty")},n?e("img",{src:n,alt:"",loading:"lazy",referrerpolicy:"no-referrer",onError:r=>{const d=r.target.parentNode;r.target.remove(),d&&d.classList.add("empty")}}):null),e("div",{class:"name"},e("b",{},a.name),e("br"),e("small",{},a.country||"")),e("div",{class:"stat-num",title:"Total listen time"},`${E(a.total_play_ms)}`),e("div",{class:"stat-num",title:"Average session length"},`Ø ${E(a.avg_session_ms)}`),e("div",{class:"stat-num",title:"Play taps"},`${a.plays}`),e("div",{class:"stat-num",title:"Sessions credited"},`· ${a.sessions}`),e("div",{class:"stat-num"},`${a.up}`),e("div",{class:"stat-num"},`${a.down}`)))}),s.appendChild(t)}function E(s){const t=Math.max(0,Math.round((s||0)/1e3));if(t<60)return`${t}s`;const a=Math.floor(t/60);if(a<60)return`${a}m${String(t%60).padStart(2,"0")}s`;const i=Math.floor(a/60);return i<24?`${i}h${String(a%60).padStart(2,"0")}m`:`${Math.floor(i/24)}d${String(i%24).padStart(2,"0")}h`}function G(s){s.appendChild(e("h2",{},"Rooms")),s.appendChild(e("table",{},e("thead",{},e("tr",{},e("th",{},"Slug"),e("th",{},"Name"),e("th",{},"Members"),e("th",{},"Active station"),e("th",{},"Created"),e("th",{},""))),e("tbody",{},...l.rooms.map(t=>e("tr",{},e("td",{},e("code",{},t.slug)),e("td",{},t.name),e("td",{},String(t.members)),e("td",{},t.active?"●":"—"),e("td",{},e("small",{},t.created_at)),e("td",{},t.slug.startsWith("u-")?e("small",{style:{color:"var(--muted)"}},"personal"):e("button",{class:"btn danger",onClick:async()=>{confirm(`Delete room ${t.slug}?`)&&(await c.del(`/api/admin/rooms/${t.slug}`),await h(),g())}},"Delete")))))))}function H(s){s.appendChild(e("div",{class:"bar"},e("h2",{style:{margin:0,flex:1}},"Users"),e("button",{class:"btn primary",onClick:K},"+ Add user"))),s.appendChild(e("table",{},e("thead",{},e("tr",{},e("th",{},"Username"),e("th",{},"Role"),e("th",{},"Created"),e("th",{},""))),e("tbody",{},...l.users.map(t=>e("tr",{},e("td",{},t.username),e("td",{},t.role),e("td",{},t.created_at),e("td",{},e("button",{class:"btn",onClick:async()=>{const a=prompt(`New password for ${t.username}:`);a&&(await c.patch(`/api/auth/users/${t.id}`,{password:a}),alert("Updated"))}},"Reset PW")," ",e("button",{class:"btn",onClick:async()=>{const a=t.role==="admin"?"user":"admin";await c.patch(`/api/auth/users/${t.id}`,{role:a}),await h(),g()}},"Toggle role")," ",t.id!==l.user.id?e("button",{class:"btn danger",onClick:async()=>{confirm(`Delete ${t.username}?`)&&(await c.del(`/api/auth/users/${t.id}`),await h(),g())}},"Delete"):null))))))}function K(){const s=e("dialog");s.appendChild(e("form",{method:"dialog",onSubmit:async t=>{t.preventDefault();const a=new FormData(t.target);await c.post("/api/auth/users",{username:a.get("username"),password:a.get("password"),role:a.get("role")}),s.close(),await h(),g()}},e("h2",{},"New user"),e("div",{class:"row"},e("label",{},"Username"),e("input",{name:"username",required:!0})),e("div",{class:"row"},e("label",{},"Password"),e("input",{name:"password",type:"password",required:!0})),e("div",{class:"row"},e("label",{},"Role"),e("select",{name:"role"},e("option",{value:"user"},"user"),e("option",{value:"admin"},"admin"))),e("div",{class:"actions"},e("button",{class:"btn",type:"button",onClick:()=>s.close()},"Cancel"),e("button",{class:"btn primary",type:"submit"},"Create")))),document.body.appendChild(s),s.showModal(),s.addEventListener("close",()=>s.remove())}function V(s){var a;s.appendChild(e("h2",{},"System"));const t=l.system||{};s.appendChild(e("div",{class:"system-grid"},b("Stations",t.stations||0),b("Streams",t.streams||0),b("Users",t.users||0),b("Favorites",t.favorites||0),b("Cached images",((a=t.image_cache)==null?void 0:a.files)||0),b("Cache size (MB)",t.image_cache?(t.image_cache.bytes/1048576).toFixed(1):"0"),b("Node",t.node||""),b("Uptime (s)",t.uptime_s||0))),s.appendChild(e("div",{style:{marginTop:"16px",display:"flex",gap:"8px"}},e("button",{class:"btn",onClick:async()=>{if(!confirm("Re-seed from data/seed/?"))return;const i=await c.post("/api/admin/reseed");alert(JSON.stringify(i)),await h(),g()}},"Re-seed"),e("button",{class:"btn",onClick:async()=>{const i=await c.post("/api/admin/scrape-icons?all=1");alert(`Updated ${i.updated}, failed ${i.failed}`),await h(),g()}},"Scrape all icons")))}L();