Files
radio-explorer/server/public/assets/admin-BnGhtAku.js
Marco Mooren 29423288ca feat: add multi-user support for favorites management and room clock synchronization
- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
2026-05-13 13:53:12 +02:00

2 lines
28 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,m as q,c as y,e}from"./debug-DBzSAgZo.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:""}},S=new P({onState:s=>{Object.assign(f,s),T()}}),f={stationId:null,playing:!1,loading:!1};async function L(){try{l.user=await c.get("/api/auth/me")}catch{return B()}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(),q({player:S,clock:null,ws:null,role:"admin"}),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 B(){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(n=>e("button",{class:`nav ${l.view===n?"active":""}`,onClick:async()=>{l.view=n,l.selected.clear(),await h(),g()}},N(n))),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 n=>{n.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"?U(a):l.view==="discover"?A(a):l.view==="leaderboard"?H(a):l.view==="rooms"?G(a):l.view==="users"?K(a):l.view==="system"&&Q(a)}const N=s=>({stations:"Stations",discover:"Discover",leaderboard:"Leaderboard",rooms:"Rooms",users:"Users",system:"System"})[s];function U(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"),...M(l.stations).map(t=>e("option",{value:t,selected:l.sourceFilter===t},t))),e("button",{class:"btn primary",onClick:()=>R()},"+ 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 M(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(),n=l.sourceFilter,i=l.stations.filter(o=>(!n||o.source===n)&&(!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=i.length&&i.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?i.forEach(m=>l.selected.add(m.id)):i.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",{},...i.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",{},z(o)," ",e("button",{class:"btn",onClick:()=>R(o.id)},"Edit")," ",e("button",{class:"btn danger",onClick:async()=>{await x({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(i,r){if(r&&!confirm(`${r} (${t.length} stations)`))return;const d=await c.post("/api/admin/stations/bulk",{ids:t,action:i});alert(`${i}: ${d.ok} ok / ${d.failed} failed`),l.selected.clear(),await h(),g()}async function n(){const i=l.stations.filter(d=>l.selected.has(d.id));if(!i.length||!await x({stations:i}))return;const r=await c.post("/api/admin/stations/bulk",{ids:i.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:()=>n()},"Delete"),e("button",{class:"btn",onClick:()=>{l.selected.clear(),w()}},"Clear"))}function z(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?(S.stop(),f.stationId=null,f.playing=!1):(f.stationId=s.id,S.play(s)),T()}},t?"❚❚":"▶"))}function T(){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 n=s.querySelector("button");n&&(n.textContent=a?"❚❚":f.stationId===t&&f.loading?"…":"▶")})}async function R(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 n="details";const i=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:n===m?"active":"",onClick:()=>{n=m,d()}},p)));function d(){y(i),r.querySelectorAll("button").forEach((m,p)=>{const u=["details","streams","image","stats"][p];m.classList.toggle("active",u===n)}),n==="details"?F(i,t):n==="streams"?W(i,t):n==="image"?j(i,t,async()=>{s&&Object.assign(t,await c.get(`/api/stations/${s}`)),d()}):n==="stats"&&$(i,t,s)}d();const o=e("div",{class:"actions"},s?e("button",{class:"btn danger",type:"button",onClick:async()=>{await x({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,i,o)),document.body.appendChild(a),a.showModal(),a.addEventListener("close",()=>a.remove())}function F(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(n=>n.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=(n,i)=>e("div",{class:"meta-row"},e("span",{class:"meta-k"},n),e("span",{class:"meta-v mono"},i==null||i===""?"—":String(i)));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 W(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(I(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"}),n=async()=>{t.streams=await c.get(`/api/admin/stations/${t.id}/streams`),i()},i=()=>{y(a),a.appendChild(e("div",{style:{fontWeight:700,marginBottom:"6px"}},"Streams"));for(const r of t.streams||[])a.appendChild(I(r,async()=>{confirm("Delete stream?")&&(await c.del(`/api/admin/streams/${r.id}`),await n())},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,i()}));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],i()}},"+ Add stream"))};i(),s.appendChild(a)}function I(s,t,a,n){const i=(r,d)=>{s[r]=d};return e("div",{class:"stream-row"},e("select",{onChange:r=>i("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=>i("url",r.target.value)}),e("input",{type:"number",placeholder:"kbps",value:s.bitrate||"",onInput:r=>i("bitrate",Number(r.target.value)||null)}),e("input",{value:s.label||"",placeholder:"Label",onInput:r=>i("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"}},n?e("button",{class:"btn",type:"button",onClick:n},"Test"):null,a?e("button",{class:"btn",type:"button",onClick:a},"Save"):null,e("button",{class:"btn danger",type:"button",onClick:t},"×")))}function j(s,t,a){if(!t.id){s.appendChild(e("p",{},"Save the station first, then come back to upload an image."));return}const n=t.image_display_url||t.image_url,i=e("div",{class:"image-area"}),r=e("div",{class:"preview",style:n?{backgroundImage:`url("${n}")`}:{}},n?"":"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||"—"}`));i.appendChild(r),i.appendChild(p),s.appendChild(i)}function $(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),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),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 x({stations:s}){const t=Array.isArray(s)?s:[s],a=t.length===1?t[0]:null,n=a?a.name:"DELETE";return new Promise(i=>{const r=e("dialog",{class:"danger-confirm"});let d="";const o=e("input",{placeholder:`Type "${n}" to confirm`,autocomplete:"off",onInput:p=>{d=p.target.value,m.disabled=d.trim()!==n}}),m=e("button",{class:"btn danger",type:"button",disabled:!0,onClick:()=>{r.close(),i(!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",{},n)," to confirm:",o)),e("div",{class:"actions"},e("button",{class:"btn",type:"button",onClick:()=>{r.close(),i(!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 n=new URLSearchParams;t.q&&n.set("q",t.q),t.country&&n.set("country",t.country),t.tag&&n.set("tag",t.tag),n.set("limit","50");const i=document.getElementById("discoverWrap");i&&(y(i),i.appendChild(e("p",{class:"muted"},"Loading…")));try{l.discoverResults=await c.get(`/api/stations/sources/radiobrowser/search?${n}`)}catch(r){l.discoverResults=[],i&&(y(i),i.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:n=>t.q=n.target.value,onKeyDown:n=>{n.key==="Enter"&&a()}}),e("input",{placeholder:"Country (e.g. NL)",value:t.country,onInput:n=>t.country=n.target.value,onKeyDown:n=>{n.key==="Enter"&&a()},style:{minWidth:"120px"}}),e("input",{placeholder:"Tag/genre",value:t.tag,onInput:n=>t.tag=n.target.value,onKeyDown:n=>{n.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 n=l.discoverResults.filter(i=>i.__import);if(!n.length)return alert("Nothing selected.");if(confirm(`Import ${n.length} stations?`)){for(const i of n)await c.post("/api/stations/sources/radiobrowser/import",i).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(i=>i.uuid).filter(Boolean)),n=t.length&&t.every(i=>i.__import);s.appendChild(e("table",{},e("thead",{},e("tr",{},e("th",{style:{width:"32px"}},e("input",{type:"checkbox",checked:!!n,onChange:i=>{t.forEach(r=>{r.__import=i.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(i=>{var o,m;const r=a.has(i.uuid),d=i.image_url;return e("tr",{class:r?"discover-existing":""},e("td",{},e("input",{type:"checkbox",checked:!!i.__import,disabled:r,onChange:p=>{i.__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",{},i.name),e("small",{},i.homepage||i.uuid)))),e("td",{},i.country||""),e("td",{},...(i.genres||[]).slice(0,4).map(p=>e("span",{class:"tag"},p))),e("td",{},e("small",{},(((o=i.streams[0])==null?void 0:o.format)||"")+" "+((m=i.streams[0])!=null&&m.bitrate?`· ${i.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",i),await h(),k()}catch(u){alert("Import failed: "+u.message)}}},"Import")))}))))}function H(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,n)=>{const i=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(n+1)),e("div",{class:"art"+(i?"":" empty")},i?e("img",{src:i,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 n=Math.floor(a/60);return n<24?`${n}h${String(a%60).padStart(2,"0")}m`:`${Math.floor(n/24)}d${String(n%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 K(s){s.appendChild(e("div",{class:"bar"},e("h2",{style:{margin:0,flex:1}},"Users"),e("button",{class:"btn",onClick:V},"🔑 Trust this device")," ",e("button",{class:"btn primary",onClick:J},"+ Add user"))),s.appendChild(e("table",{},e("thead",{},e("tr",{},e("th",{},"Username"),e("th",{},"Role"),e("th",{},"Main"),e("th",{},"Avatar"),e("th",{},"Created"),e("th",{},""))),e("tbody",{},...l.users.map(t=>e("tr",{},e("td",{},t.username),e("td",{},t.role),e("td",{},t.is_main?"★ main":e("button",{class:"btn",title:"Promote to main (shared) user",onClick:async()=>{confirm(`Make ${t.username} the shared/main user?`)&&(await c.patch(`/api/auth/users/${t.id}`,{is_main:!0}),await h(),g())}},"Make main")),e("td",{},e("span",{style:{display:"inline-grid",placeItems:"center",width:"24px",height:"24px",background:t.avatar_color||"#ff7a3d",color:"#1a0a00",fontWeight:"800",fontSize:"12px"}},t.avatar_emoji||t.username.slice(0,1).toUpperCase())," ",e("button",{class:"btn",onClick:async()=>{const a=prompt(`Avatar emoji / letter for ${t.username} (leave empty to clear):`,t.avatar_emoji||"");if(a===null)return;const n=prompt("Avatar color (hex, e.g. #ff7a3d):",t.avatar_color||"#ff7a3d");n!==null&&(await c.patch(`/api/auth/users/${t.id}`,{avatar_emoji:a,avatar_color:n}),await h(),g())}},"Edit")),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))))))}async function V(){let s={trusted:!1,users:[],label:""};try{s=await c.get("/api/auth/devices/me")}catch{}const t=new Set((s.users||[]).map(n=>n.id)),a=e("dialog");a.appendChild(e("form",{method:"dialog",onSubmit:async n=>{n.preventDefault();const i=new FormData(n.target),r=[...i.getAll("user_id")].map(Number),d=i.get("label")||null;try{s.trusted?(await c.patch("/api/auth/devices/me",{label:d,user_ids:r}),alert("Device whitelist updated.")):(await c.post("/api/auth/devices/trust",{label:d,user_ids:r}),alert("This device is now trusted. Fast-switching is enabled for the selected users."))}catch(o){alert(o.message||"Failed");return}a.close()}},e("h2",{},s.trusted?"Edit trusted device":"Trust this device"),e("p",{style:{color:"#8a90a0",fontSize:"13px"}},"Listed users can fast-switch on this device without a password. ","The cookie is HttpOnly and lasts 1 year."),e("div",{class:"row"},e("label",{},"Device label"),e("input",{name:"label",value:s.label||"",placeholder:"e.g. Kitchen kiosk"})),e("div",{style:{maxHeight:"300px",overflowY:"auto",border:"1px solid #262b36",padding:"8px"}},...l.users.map(n=>e("label",{style:{display:"flex",alignItems:"center",gap:"8px",padding:"4px 6px",cursor:"pointer"}},e("input",{type:"checkbox",name:"user_id",value:String(n.id),checked:t.has(n.id)||n.id===l.user.id}),e("span",{},n.username),n.is_main?e("span",{style:{color:"#ffb37a",fontSize:"11px"}}," ★ main"):null,n.id===l.user.id?e("span",{style:{color:"#5d6373",fontSize:"11px"}}," (you)"):null))),e("div",{class:"actions"},e("button",{class:"btn",type:"button",onClick:()=>a.close()},"Cancel"),e("button",{class:"btn primary",type:"submit"},s.trusted?"Update":"Trust device")))),document.body.appendChild(a),a.showModal(),a.addEventListener("close",()=>a.remove())}function J(){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 Q(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 n=await c.post("/api/admin/reseed");alert(JSON.stringify(n)),await h(),g()}},"Re-seed"),e("button",{class:"btn",onClick:async()=>{const n=await c.post("/api/admin/scrape-icons?all=1");alert(`Updated ${n.updated}, failed ${n.failed}`),await h(),g()}},"Scrape all icons")))}L();