aboutsummaryrefslogtreecommitdiffstats
path: root/static
diff options
context:
space:
mode:
Diffstat (limited to 'static')
-rw-r--r--static/app.js12
-rw-r--r--static/deck.css92
-rw-r--r--static/modules/music.js63
-rw-r--r--static/modules/obs.js9
-rw-r--r--static/modules/sbbuilder.js49
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);
}
},
};