From af07facbb5047a0ba1eafc8ff32e0b7adfaca92c Mon Sep 17 00:00:00 2001 From: Fisto Date: Wed, 16 Sep 2026 08:50:53 +0200 Subject: [PATCH] Add the Bot settings group with limit, usage bar, and clean modal --- CURRENT.md | 2 +- docs/media.md | 7 +- docs/webui.md | 3 + poc/public/index.html | 43 +++++++++++ poc/public/media.css | 91 ++++++++++++++++++++++ poc/public/media.js | 173 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 314 insertions(+), 5 deletions(-) diff --git a/CURRENT.md b/CURRENT.md index 61ea660..dbc062e 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -31,7 +31,7 @@ mobile draft at 390px. - [x] C5 daemon wiring: `scrape media` command, MediaService, `/api/media/*` - [x] C6 Media and Bot tabs in the Web UI - [x] C7a storage limits, retention, warning/confirm flow, storage and clean API - - [ ] C7b Settings group: limit field with info hover, usage bar, clean modal + - [x] C7b Settings group: limit field with info hover, usage bar, clean modal - [ ] C8 MCP service hooks - [ ] C9 live verification, tuning, docs - Flextalk voice profile, end to end. The profile window is a movable panel that recenters on each diff --git a/docs/media.md b/docs/media.md index 65e30f6..f567e73 100644 --- a/docs/media.md +++ b/docs/media.md @@ -1,8 +1,8 @@ # Media archive Status: design approved 2026-09-16. The engine, the slopbot backend, the `scrape media` command, -the `/api/media/*` routes, and storage limits with retention are implemented (C3-C7a). The Media -and Bot tabs are C6; the Settings group is C7b. Checkpoints live in [CURRENT.md](../CURRENT.md). +the `/api/media/*` routes, storage limits with retention, and the Settings group are implemented +(C3-C7b). The Media and Bot tabs are C6. Checkpoints live in [CURRENT.md](../CURRENT.md). The media archive saves the media history of a channel. A trusted user runs `scrape media` in the channel. The daemon walks the history through a scrape backend and indexes every image, GIF, @@ -125,7 +125,8 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun database overhead. - Settings gains one collapsible group. Its name is **Bot** while the bot path is active, and **Media** if the bot path is removed. It holds the limit field with the info tooltip, the usage - bar, and the clean button. + bar (segmented by image, GIF, video, audio, and other), and the clean button with its 5 second + countdown. - Mobile is out of scope for now. ## MCP diff --git a/docs/webui.md b/docs/webui.md index 41fea4d..3f18854 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -171,6 +171,9 @@ The Settings button opens a slide-over: the bot's devices. Clicking **Client audio** in the header does the same: it asks for the permission, unlocks the audio context in the same click, and then starts listening. A second click stops it. +- **Bot** — slopbot connection state, the media storage limit in GiB with an info tooltip (5% overrun + and the warning rule), the usage bar segmented by image, GIF, video, audio, and other, and + **Clean media…** with a 5 second confirmation countdown. See [media.md](media.md). - **Server** — a collapsible group with: - **Core process** — state, last exit, output, Start / Stop / Restart. - **Account login** — token file state, QR login, captcha. diff --git a/poc/public/index.html b/poc/public/index.html index 84599b6..6f1280f 100644 --- a/poc/public/index.html +++ b/poc/public/index.html @@ -294,6 +294,49 @@
+
+ + + Bot + + +
+
+
+ Media storage + + + + +
+
+
+ Slopbot + checking… +
+
+ + Limit (GiB) + i + + + + +
+
+ Used + — +
+
+
+
+
+
+
diff --git a/poc/public/media.css b/poc/public/media.css index 136e95e..237d970 100644 --- a/poc/public/media.css +++ b/poc/public/media.css @@ -113,3 +113,94 @@ .vk-media-scrape button { flex: none; } + +.vk-info { + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + margin-left: 4px; + border-radius: 50%; + background: var(--color-neutral-800); + color: var(--color-neutral-300); + font: 600 9px/1 ui-monospace, Menlo, monospace; + cursor: help; +} + +.vk-media-input { + width: 90px; + padding: 3px 6px; + border: none; + border-radius: var(--radius-sm, 6px); + background: var(--color-neutral-900); + color: var(--color-neutral-200); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); + font: 400 11px/1 ui-monospace, Menlo, monospace; +} + +.vk-media-usage { + display: flex; + height: 10px; + margin-top: 8px; + overflow: hidden; + border-radius: 5px; + background: var(--color-neutral-900); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); +} + +.vk-media-usage-seg { + height: 100%; +} + +.vk-seg-image { + background: #4f8cff; +} + +.vk-seg-gif { + background: #8a6cff; +} + +.vk-seg-video { + background: #ff7a45; +} + +.vk-seg-audio { + background: #2fbf71; +} + +.vk-seg-other { + background: #6b7280; +} + +.vk-media-modal { + position: fixed; + inset: 0; + z-index: 95; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--color-neutral-900) 70%, transparent); +} + +.vk-media-modal[hidden] { + display: none; +} + +.vk-media-modal-box { + display: flex; + flex-direction: column; + gap: 10px; + width: min(520px, 92vw); + padding: 14px; + border-radius: var(--radius-lg, 12px); + background: var(--color-bg); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); +} + +.vk-media-modal-list { + max-height: 220px; + overflow-y: auto; + font: 400 11px/1.5 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); +} diff --git a/poc/public/media.js b/poc/public/media.js index 4390d02..0975d02 100644 --- a/poc/public/media.js +++ b/poc/public/media.js @@ -252,5 +252,176 @@ document.querySelector("#scope-guild")?.addEventListener("click", hide); document.querySelector("#scope-dm")?.addEventListener("click", hide); - window.VK.media = { show, hide }; + // ---- Settings: media storage ---- + + function formatBytes(bytes) { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`; + if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MiB`; + return `${bytes} B`; + } + + function renderUsageBar(status) { + const bar = document.querySelector("#media-usage-bar"); + if (!bar || !status) return; + + bar.replaceChildren(); + const limit = Math.max(status.limitBytes, 1); + const measured = + (status.byKind?.image ?? 0) + + (status.byKind?.gif ?? 0) + + (status.byKind?.video ?? 0) + + (status.byKind?.audio ?? 0); + const other = Math.max(0, status.usedBytes - measured); + + for (const [kind, bytes] of [ + ["image", status.byKind?.image ?? 0], + ["gif", status.byKind?.gif ?? 0], + ["video", status.byKind?.video ?? 0], + ["audio", status.byKind?.audio ?? 0], + ["other", other] + ]) { + if (bytes <= 0) continue; + const segment = el("div", `vk-media-usage-seg vk-seg-${kind}`); + segment.style.width = `${Math.min(100, (bytes / limit) * 100)}%`; + segment.title = `${kind}: ${formatBytes(bytes)}`; + bar.append(segment); + } + } + + async function loadMediaSettings() { + try { + const [storage, status] = await Promise.all([ + window.VK.api.get("/api/media/storage"), + window.VK.api.get("/api/media/status") + ]); + state.storage = storage; + + const bot = status?.slopbot; + const slopbotRow = document.querySelector("#media-slopbot"); + if (slopbotRow) slopbotRow.textContent = bot?.connected ? `connected as ${bot.user}` : "disabled"; + + const limit = document.querySelector("#media-limit"); + if (limit && document.activeElement !== limit) { + limit.value = (storage.limitBytes / 1024 ** 3).toFixed(2); + } + + const used = document.querySelector("#media-used"); + if (used) { + used.textContent = `${formatBytes(storage.usedBytes)} of ${formatBytes(storage.limitBytes)}${ + storage.nearLimit ? " (near limit)" : "" + }`; + } + + renderUsageBar(storage); + } catch (error) { + const used = document.querySelector("#media-used"); + if (used) used.textContent = `unavailable: ${error.message}`; + } + } + + document.querySelector("#media-save")?.addEventListener("click", async () => { + const input = document.querySelector("#media-limit"); + const gib = Number(input?.value); + if (!Number.isFinite(gib) || gib <= 0) return; + + try { + await window.VK.api.post("/api/preferences", { media: { limitBytes: Math.round(gib * 1024 ** 3) } }); + setStatus("media limit saved"); + const save = document.querySelector("#media-save"); + if (save) save.disabled = true; + } catch (error) { + setStatus(`save failed: ${error.message}`); + } + void loadMediaSettings(); + }); + + document.querySelector("#media-limit")?.addEventListener("input", () => { + const save = document.querySelector("#media-save"); + if (save) save.disabled = false; + }); + + const cleanModal = document.createElement("div"); + cleanModal.className = "vk-media-modal"; + cleanModal.hidden = true; + cleanModal.innerHTML = ` +
+ Delete media scrapes? +
+
+
+ + +
+
+ `; + document.body.append(cleanModal); + + let cleanTimer = null; + + function openCleanModal() { + const scrapes = state.storage?.scrapes ?? []; + const list = cleanModal.querySelector("#clean-list"); + const note = cleanModal.querySelector("#clean-note"); + const confirm = cleanModal.querySelector("#clean-confirm"); + + list.replaceChildren(); + for (const scrape of scrapes) { + list.append(el("div", null, `${scrape.id} · ${scrape.state} · ${formatBytes(scrape.bytes)}`)); + } + + const bytes = scrapes.reduce((sum, scrape) => sum + scrape.bytes, 0); + note.textContent = scrapes.length + ? `Everything above will be deleted permanently, including files and databases (${formatBytes(bytes)}). This cannot be undone.` + : "No scrapes to delete."; + + confirm.disabled = true; + let left = 5; + confirm.textContent = `Delete (${left})`; + cleanModal.hidden = false; + + clearInterval(cleanTimer); + if (!scrapes.length) { + confirm.textContent = "Delete"; + return; + } + + cleanTimer = setInterval(() => { + left--; + if (left <= 0) { + clearInterval(cleanTimer); + confirm.disabled = false; + confirm.textContent = "Delete"; + return; + } + confirm.textContent = `Delete (${left})`; + }, 1000); + } + + cleanModal.querySelector("#clean-cancel").addEventListener("click", () => { + clearInterval(cleanTimer); + cleanModal.hidden = true; + }); + + cleanModal.querySelector("#clean-confirm").addEventListener("click", async () => { + const ids = (state.storage?.scrapes ?? []).map(scrape => scrape.id); + try { + await window.VK.api.post("/api/media/clean", { ids }); + } catch (error) { + setStatus(`clean failed: ${error.message}`); + } + cleanModal.hidden = true; + void loadMediaSettings(); + void loadScrapes(); + }); + + document.querySelector("#media-clean")?.addEventListener("click", openCleanModal); + + const settingsOverlay = document.querySelector("#settings-overlay"); + if (settingsOverlay) { + new MutationObserver(() => { + if (!settingsOverlay.hidden) void loadMediaSettings(); + }).observe(settingsOverlay, { attributes: true, attributeFilter: ["hidden"] }); + } + + window.VK.media = { show, hide, loadMediaSettings }; })();