From 49fd28756fb26c82ea1c07038a4cb2e9ea9a0eb1 Mon Sep 17 00:00:00 2001 From: Fisto Date: Wed, 16 Sep 2026 08:09:29 +0200 Subject: [PATCH] Add the Media and Bot tabs to the web UI --- CURRENT.md | 2 +- docs/webui.md | 11 ++ poc/public/index.html | 4 + poc/public/media.css | 115 +++++++++++++++++++ poc/public/media.js | 256 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 poc/public/media.css create mode 100644 poc/public/media.js diff --git a/CURRENT.md b/CURRENT.md index eae74a5..62c6d28 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -29,7 +29,7 @@ mobile draft at 390px. - [x] C3 media-scrape store and puppet walker, tests - [x] C4 slopbot backend, tests - [x] C5 daemon wiring: `scrape media` command, MediaService, `/api/media/*` - - [ ] C6 Media and Bot tabs in the Web UI + - [x] C6 Media and Bot tabs in the Web UI - [ ] C7 storage limit, warning flow, clean modal, settings - [ ] C8 MCP service hooks - [ ] C9 live verification, tuning, docs diff --git a/docs/webui.md b/docs/webui.md index 7e9a716..b93f442 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -119,6 +119,17 @@ box live in Chat, not Call, and the connection stats are not chips yet. - The last-message time comes from the channel's last message snowflake: a time today, `yesterday`, or a short date. +## Media and Bot + +- **Media** and **Bot** are scope buttons beside Guild and DM. They open overlays over the shell. + Mobile is out of scope for the media feature. +- Media lists scraped items with kind chips (All, Images, GIFs, Videos, Audio), a channel picker + with **Start links** / **Start download**, and the active scrapes with **Stop**. Images and GIFs + render from their Discord CDN URLs; videos and audio are link cards. +- Bot shows the slopbot connection state and the scrape queue. +- The views are self-contained: `poc/public/media.js` and `media.css`, over the `/api/media/*` + routes in the table below. See [media.md](media.md). + ## Takeover Takeover is a read-only switch. Off, the composer is locked (a `read-only` tag sits next to the diff --git a/poc/public/index.html b/poc/public/index.html index d855c92..84599b6 100644 --- a/poc/public/index.html +++ b/poc/public/index.html @@ -6,6 +6,7 @@ veskslop debug +
@@ -18,6 +19,8 @@
+ +
@@ -534,5 +537,6 @@ + diff --git a/poc/public/media.css b/poc/public/media.css new file mode 100644 index 0000000..136e95e --- /dev/null +++ b/poc/public/media.css @@ -0,0 +1,115 @@ +/* Media archive and slopbot views. Scoped to the media-* classes. */ + +.vk-media-overlay { + position: fixed; + inset: 0; + z-index: 90; + display: flex; + background: color-mix(in srgb, var(--color-neutral-900) 70%, transparent); +} + +.vk-media-overlay[hidden] { + display: none; +} + +.vk-media-panel { + display: flex; + flex-direction: column; + gap: 10px; + width: min(1100px, 100%); + margin: 24px auto; + padding: 14px; + overflow: hidden; + border-radius: var(--radius-lg, 12px); + background: var(--color-bg); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); +} + +.vk-media-head { + display: flex; + align-items: center; + gap: 10px; +} + +.vk-media-title { + flex: 1; + font-weight: 600; +} + +.vk-media-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.vk-media-status { + font: 400 11px/1.5 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); + white-space: pre-wrap; +} + +.vk-media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; + overflow-y: auto; + flex: 1; + align-content: start; +} + +.vk-media-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + aspect-ratio: 1; + overflow: hidden; + border-radius: var(--radius-sm, 6px); + background: var(--color-neutral-900); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); + color: var(--color-neutral-300); + text-decoration: none; +} + +.vk-media-card img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.vk-media-card-kind { + font: 600 10px/1 ui-monospace, Menlo, monospace; + letter-spacing: 0.08em; + color: var(--color-accent-400, var(--color-neutral-300)); +} + +.vk-media-card-name { + max-width: 90%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 400 11px/1 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); +} + +.vk-media-scrapes { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 140px; + overflow-y: auto; +} + +.vk-media-scrape { + display: flex; + align-items: center; + gap: 8px; + font: 400 11px/1.4 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); +} + +.vk-media-scrape button { + flex: none; +} diff --git a/poc/public/media.js b/poc/public/media.js new file mode 100644 index 0000000..4390d02 --- /dev/null +++ b/poc/public/media.js @@ -0,0 +1,256 @@ +"use strict"; + +/* + * Media archive and slopbot views. + * + * Self-contained: this file owns its overlay, so app.js does not change except + * for the two scope buttons in index.html. See docs/media.md. + */ + +(() => { + const KINDS = ["all", "image", "gif", "video", "audio"]; + const KIND_LABELS = { all: "All", image: "Images", gif: "GIFs", video: "Videos", audio: "Audio" }; + + const state = { + view: null, + kind: "all", + offset: 0, + items: [], + scrapes: [], + channels: [], + loadedChannels: false + }; + + const overlay = document.createElement("div"); + overlay.className = "vk-media-overlay"; + overlay.hidden = true; + overlay.innerHTML = ` +
+
+ Media archive + Refresh + Close +
+
+
+
+
+
+ `; + document.body.append(overlay); + + const title = overlay.querySelector("#media-title"); + const toolbar = overlay.querySelector("#media-toolbar"); + const statusLine = overlay.querySelector("#media-status"); + const scrapeList = overlay.querySelector("#media-scrapes"); + const grid = overlay.querySelector("#media-grid"); + + function setStatus(text) { + statusLine.textContent = text; + } + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function button(label, handler) { + const node = el("button", "btn btn-secondary vk-small-btn", label); + node.type = "button"; + node.addEventListener("click", handler); + return node; + } + + function show(view) { + state.view = view; + overlay.hidden = false; + document.querySelector("#scope-media")?.classList.toggle("active", view === "media"); + document.querySelector("#scope-bot")?.classList.toggle("active", view === "bot"); + document.querySelector("#scope-guild")?.classList.remove("active"); + document.querySelector("#scope-dm")?.classList.remove("active"); + render(); + } + + function hide() { + state.view = null; + overlay.hidden = true; + document.querySelector("#scope-media")?.classList.remove("active"); + document.querySelector("#scope-bot")?.classList.remove("active"); + } + + async function loadChannels() { + if (state.loadedChannels) return; + try { + const data = await window.VK.api.get("/api/state"); + state.channels = []; + for (const guild of data?.guilds ?? []) { + for (const channel of guild.channels ?? []) { + if (channel.type !== 0 && channel.type !== 5) continue; + state.channels.push({ + id: channel.id, + guildId: guild.id, + label: `${guild.name} / #${channel.name}` + }); + } + } + state.loadedChannels = true; + } catch { + state.channels = []; + } + } + + async function loadScrapes() { + try { + state.scrapes = await window.VK.api.get("/api/media/scrapes"); + state.status = await window.VK.api.get("/api/media/status"); + } catch (error) { + setStatus(`media status failed: ${error.message}`); + } + renderScrapes(); + } + + async function loadMedia(reset) { + if (reset) { + state.items = []; + state.offset = 0; + } + try { + const kind = state.kind === "all" ? "" : `&kind=${state.kind}`; + const items = await window.VK.api.get(`/api/media?limit=60&offset=${state.offset}${kind}`); + state.items = state.items.concat(items); + state.offset += items.length; + setStatus(`${state.items.length} items loaded`); + } catch (error) { + setStatus(`media load failed: ${error.message}`); + } + renderGrid(); + } + + async function startScrape(mode) { + const select = overlay.querySelector("#media-channel"); + if (!select?.value) { + setStatus("no text channel available to scrape"); + return; + } + const channel = state.channels.find(entry => entry.id === select.value); + try { + const scrape = await window.VK.api.post("/api/media/scrape", { + channelId: select.value, + guildId: channel?.guildId ?? null, + mode + }); + setStatus(`started ${scrape.id} (${mode})`); + } catch (error) { + setStatus(`start failed: ${error.message}`); + } + void loadScrapes(); + } + + function renderScrapes() { + scrapeList.replaceChildren(); + const active = state.scrapes.filter(scrape => ["queued", "running", "paused"].includes(scrape.state)); + const rows = active.length ? active : state.scrapes.slice(0, 5); + for (const scrape of rows) { + const row = el("div", "vk-media-scrape"); + row.append(el("span", null, `${scrape.channelId} · ${scrape.state} · ${scrape.pages}p · ${scrape.messages}m`)); + if (active.includes(scrape)) { + row.append( + button("Stop", async () => { + await window.VK.api.post("/api/media/scrape/stop", { id: scrape.id }); + void loadScrapes(); + }) + ); + } + scrapeList.append(row); + } + } + + function renderGrid() { + grid.replaceChildren(); + for (const item of state.items) { + const card = el("a", "vk-media-card"); + card.href = item.url; + card.target = "_blank"; + card.rel = "noreferrer"; + + if ((item.kind === "image" || item.kind === "gif") && /^https?:/.test(item.url)) { + const img = document.createElement("img"); + img.loading = "lazy"; + img.src = item.url; + img.alt = item.kind; + card.append(img); + } else { + card.append(el("span", "vk-media-card-kind", item.kind.toUpperCase())); + card.append(el("span", "vk-media-card-name", item.provider || new URL(item.url).hostname)); + } + grid.append(card); + } + } + + function renderToolbar() { + toolbar.replaceChildren(); + if (state.view === "media") { + for (const kind of KINDS) { + const chip = el("button", `vk-chip ${state.kind === kind ? "vk-chip-on" : "vk-chip-off"}`, KIND_LABELS[kind]); + chip.type = "button"; + chip.addEventListener("click", () => { + state.kind = kind; + void loadMedia(true); + renderToolbar(); + }); + toolbar.append(chip); + } + + const select = document.createElement("select"); + select.id = "media-channel"; + select.className = "vk-chip vk-chip-off"; + for (const channel of state.channels) { + const option = document.createElement("option"); + option.value = channel.id; + option.textContent = channel.label; + select.append(option); + } + toolbar.append(select); + + toolbar.append(button("Start links", () => void startScrape("links"))); + toolbar.append(button("Start download", () => void startScrape("download"))); + } else { + toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md.")); + } + } + + function render() { + title.textContent = state.view === "bot" ? "Slopbot" : "Media archive"; + toolbar.hidden = false; + grid.hidden = state.view === "bot"; + renderToolbar(); + + if (state.view === "bot") { + const bot = state.status?.slopbot; + statusLine.textContent = bot?.connected + ? `connected as ${bot.user} in ${bot.guilds.length} guild(s)` + : "slopbot is disabled or not logged in (VESKSLOP_SLOPBOT_ENABLED=1 and a token)"; + } else { + void loadChannels().then(renderToolbar); + void loadMedia(true); + } + void loadScrapes(); + } + + overlay.querySelector("#media-close").addEventListener("click", hide); + overlay.querySelector("#media-refresh").addEventListener("click", () => { + void loadScrapes(); + if (state.view === "media") void loadMedia(true); + }); + + const scopeMedia = document.querySelector("#scope-media"); + const scopeBot = document.querySelector("#scope-bot"); + scopeMedia?.addEventListener("click", () => show("media")); + scopeBot?.addEventListener("click", () => show("bot")); + document.querySelector("#scope-guild")?.addEventListener("click", hide); + document.querySelector("#scope-dm")?.addEventListener("click", hide); + + window.VK.media = { show, hide }; +})();