diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/app.js | 12 | ||||
| -rw-r--r-- | static/deck.css | 92 | ||||
| -rw-r--r-- | static/modules/music.js | 63 | ||||
| -rw-r--r-- | static/modules/obs.js | 9 | ||||
| -rw-r--r-- | static/modules/sbbuilder.js | 49 |
5 files changed, 172 insertions, 53 deletions
diff --git a/static/app.js b/static/app.js index c6fb06e..c7730e3 100644 --- a/static/app.js +++ b/static/app.js @@ -47,13 +47,15 @@ for (const node of loadLayout()) { function header(title) { const head = document.createElement("div"); head.className = "panel-head"; + const dot = document.createElement("span"); + dot.className = "panel-dot"; const h = document.createElement("h2"); h.textContent = title; const drag = document.createElement("button"); drag.className = "panel-drag"; - drag.textContent = "≡"; + drag.textContent = "⠿"; drag.setAttribute("aria-label", "Move panel"); - head.append(h, drag); + head.append(dot, h, drag); return head; } @@ -70,11 +72,13 @@ function makeCard(name, title) { } const content = document.createElement("div"); content.className = "grid-stack-item-content panel"; - content.append(header(title)); + const body = document.createElement("div"); + body.className = "panel-body"; + content.append(header(title), body); item.append(content); gridEl.append(item); grid.makeWidget(item); - return content; + return body; } async function mountModule(name) { diff --git a/static/deck.css b/static/deck.css index d6b62dc..5c4af84 100644 --- a/static/deck.css +++ b/static/deck.css @@ -22,34 +22,102 @@ body { .grid-stack { min-height: 100dvh; } -.panel { +.panel { min-height: 0; min-width: 0; } + +.grid-stack-item-content.panel { + position: absolute; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + border-radius: 16px; background: var(--udt-surface0); border: 1px solid var(--udt-surface1); - border-radius: 16px; - padding: 16px; - min-height: 0; - min-width: 0; + box-shadow: 0 10px 30px rgba(0, 0, 0, .18); } -.grid-stack-item-content.panel { position: absolute; } - -.panel-head { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; } +.panel-head { + flex: none; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--udt-mantle); + border-bottom: 1px solid var(--udt-surface1); +} +.panel-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--udt-accent); + flex: none; +} .panel .panel-head h2 { - margin: 0; flex: 1; + margin: 0; font-size: 13px; - font-weight: 500; - color: var(--udt-subtext0); + font-weight: 600; + letter-spacing: .02em; + color: var(--udt-text); } .panel-drag { font: inherit; color: var(--udt-overlay0); background: transparent; border: 0; - padding: 2px 8px; + padding: 2px 6px; + border-radius: 6px; cursor: grab; touch-action: none; } +.panel-drag:hover { color: var(--udt-subtext0); background: var(--udt-surface1); } +.panel-body { flex: 1; min-height: 0; overflow: auto; padding: 14px; } + +.section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--udt-overlay0); + margin: 12px 0 6px; +} +.section-label:first-child { margin-top: 0; } + +.music .top { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; } +.music .art { + width: 64px; + height: 64px; + border-radius: 10px; + object-fit: cover; + background: var(--udt-surface1); + flex: none; +} +.music .art.empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--udt-overlay0); + font-size: 22px; +} +.music .meta { min-width: 0; } +.music .title { + font-size: 15px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.music .artist, .music .album { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.music .album { font-size: 12px; color: var(--udt-overlay0); } +.music .seek { display: flex; align-items: center; gap: 10px; margin: 6px 0 12px; } +.music .seek input { flex: 1; accent-color: var(--udt-accent); } +.music .time { font-size: 12px; color: var(--udt-subtext0); white-space: nowrap; } +.music .controls { display: flex; flex-wrap: wrap; gap: 8px; } + +.sbb .sbb-summary { margin-bottom: 8px; font-size: 12px; } +.sbb .row.job .kind { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.grid-stack-item .ui-resizable-se { width: 22px; height: 22px; right: 2px; bottom: 2px; opacity: .45; } +.grid-stack-item:hover .ui-resizable-se { opacity: 1; } .num { font-family: "Inconsolata Nerd Font Mono", ui-monospace, monospace; diff --git a/static/modules/music.js b/static/modules/music.js index de9df7f..e4388f7 100644 --- a/static/modules/music.js +++ b/static/modules/music.js @@ -1,9 +1,14 @@ -async function post(action) { +function clock(s) { + s = Math.max(0, Math.floor(s || 0)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} + +async function post(action, body = {}) { try { await fetch(`/api/music/${action}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: "{}", + body: JSON.stringify(body), }); } catch { // the next poll shows the real state @@ -22,27 +27,51 @@ export default { interval: 2, mount(el) { - const box = document.createElement("div"); - box.className = "music"; - box.innerHTML = ` - <div class="now"><b class="title">—</b><span class="artist muted"></span></div> - <div class="row controls"></div> - <div class="row vol"></div>`; - box.querySelector(".controls").append( - button("⏮", "prev"), button("⏯", "play"), button("⏭", "next")); - box.querySelector(".vol").append( + el.innerHTML = ` + <div class="top"> + <div class="art empty">♪</div> + <div class="meta"> + <div class="title">Nothing playing</div> + <div class="artist muted"></div> + <div class="album muted"></div> + </div> + </div> + <div class="seek"> + <input type="range" min="0" max="1" step="1" value="0" aria-label="Seek"> + <span class="time num">0:00</span> + </div> + <div class="controls"></div>`; + el.querySelector(".controls").append( + button("⏮", "prev"), button("⏯", "play"), button("⏭", "next"), button("Vol−", "voldown"), button("Mute", "mute"), button("Vol+", "volup")); - el.append(box); + const seek = el.querySelector(".seek input"); + seek.addEventListener("pointerdown", () => { el._dragging = true; }); + seek.addEventListener("pointerup", () => { el._dragging = false; }); + seek.addEventListener("change", () => post("seek", { position: Number(seek.value) })); }, update(st, el) { - const box = el.querySelector(".music"); - box.querySelector(".title").textContent = st.title || "Nothing playing"; - box.querySelector(".artist").textContent = st.artist || ""; - box.querySelector(".controls button:nth-child(2)").textContent = + if (st.art) { + let img = el.querySelector("img.art"); + if (!img) { + img = document.createElement("img"); + img.className = "art"; + img.alt = ""; + el.querySelector(".art").replaceWith(img); + } + if (img.getAttribute("src") !== st.art) img.src = st.art; + } + el.querySelector(".title").textContent = st.title || "Nothing playing"; + el.querySelector(".artist").textContent = st.artist || ""; + el.querySelector(".album").textContent = st.album || ""; + el.querySelector(".controls button:nth-child(2)").textContent = st.status === "Playing" ? "⏸" : "▶"; - const mute = box.querySelector(".vol button:nth-child(2)"); + const mute = el.querySelector(".controls button:nth-child(5)"); mute.classList.toggle("on", Boolean(st.volume?.muted)); mute.textContent = st.volume?.muted ? "Unmute" : "Mute"; + const seek = el.querySelector(".seek input"); + seek.max = Math.max(1, Math.floor(st.length || 0)); + if (!el._dragging) seek.value = Math.min(Number(seek.max), Math.floor(st.position || 0)); + el.querySelector(".time").textContent = `${clock(st.position)} / ${clock(st.length)}`; }, }; diff --git a/static/modules/obs.js b/static/modules/obs.js index 9c5c34c..7270f87 100644 --- a/static/modules/obs.js +++ b/static/modules/obs.js @@ -27,9 +27,9 @@ export default { wrap.className = "obs"; wrap.innerHTML = ` <div class="row obs-controls"></div> - <div class="obs-scenes"></div> - <div class="obs-audio"></div> - <div class="obs-sources"></div>`; + <div class="section-label">Scenes</div><div class="obs-scenes"></div> + <div class="section-label">Audio</div><div class="obs-audio"></div> + <div class="section-label">Sources</div><div class="obs-sources"></div>`; el.append(wrap); }, @@ -39,6 +39,9 @@ export default { const audio = el.querySelector(".obs-audio"); const sources = el.querySelector(".obs-sources"); + el.querySelectorAll(".section-label").forEach((label) => { + label.style.display = state.connected ? "" : "none"; + }); if (!state.connected) { controls.textContent = state.error ? `OBS offline: ${state.error}` : "OBS offline"; scenes.textContent = audio.textContent = sources.textContent = ""; diff --git a/static/modules/sbbuilder.js b/static/modules/sbbuilder.js index 16745cb..56802a9 100644 --- a/static/modules/sbbuilder.js +++ b/static/modules/sbbuilder.js @@ -5,39 +5,54 @@ function chipClass(status) { return "muted"; } +function when(ts) { + if (!ts) return ""; + return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function summary(jobs) { + const running = jobs.filter((j) => ["running", "busy", "starting"].includes(j.status)).length; + const failed = jobs.filter((j) => ["failed", "lost", "killed"].includes(j.status)).length; + const parts = []; + if (running) parts.push(`${running} running`); + if (failed) parts.push(`${failed} failed`); + if (!parts.length) parts.push(jobs.length ? `${jobs.length} recent` : "idle"); + return parts.join(" · "); +} + export default { - title: "Builds", + title: "Build VM", interval: 5, mount(el) { - const box = document.createElement("div"); - box.className = "sbb"; - el.append(box); + el.innerHTML = `<div class="sbb-summary muted"></div><div class="sbb-list"></div>`; }, update(state, el) { - const box = el.querySelector(".sbb"); - box.textContent = ""; + const sum = el.querySelector(".sbb-summary"); + const list = el.querySelector(".sbb-list"); if (state.offline) { - box.textContent = "build VM unreachable"; + sum.textContent = ""; + list.textContent = "build VM unreachable"; return; } const jobs = state.jobs ?? []; - if (!jobs.length) { - box.textContent = "No jobs"; - return; - } + sum.textContent = summary(jobs); + list.textContent = ""; for (const job of jobs.slice(0, 8)) { const row = document.createElement("div"); - row.className = "row"; - const id = document.createElement("span"); - id.className = "num id"; - id.textContent = job.id; + row.className = "row job"; + const kind = document.createElement("span"); + kind.className = "kind"; + kind.textContent = job.kind || "job"; + const time = document.createElement("span"); + time.className = "muted num"; + time.textContent = when(job.created); const status = document.createElement("span"); status.className = chipClass(job.status); status.textContent = job.status; - row.append(id, status); - box.append(row); + row.append(kind, time, status); + list.append(row); } }, }; |
