Add the Media and Bot tabs to the web UI
This commit is contained in:
+1
-1
@@ -29,7 +29,7 @@ mobile draft at 390px.
|
|||||||
- [x] C3 media-scrape store and puppet walker, tests
|
- [x] C3 media-scrape store and puppet walker, tests
|
||||||
- [x] C4 slopbot backend, tests
|
- [x] C4 slopbot backend, tests
|
||||||
- [x] C5 daemon wiring: `scrape media` command, MediaService, `/api/media/*`
|
- [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
|
- [ ] C7 storage limit, warning flow, clean modal, settings
|
||||||
- [ ] C8 MCP service hooks
|
- [ ] C8 MCP service hooks
|
||||||
- [ ] C9 live verification, tuning, docs
|
- [ ] C9 live verification, tuning, docs
|
||||||
|
|||||||
@@ -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`,
|
- The last-message time comes from the channel's last message snowflake: a time today, `yesterday`,
|
||||||
or a short date.
|
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
|
||||||
|
|
||||||
Takeover is a read-only switch. Off, the composer is locked (a `read-only` tag sits next to the
|
Takeover is a read-only switch. Off, the composer is locked (a `read-only` tag sits next to the
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>veskslop debug</title>
|
<title>veskslop debug</title>
|
||||||
<link rel="stylesheet" href="nocturne.css" />
|
<link rel="stylesheet" href="nocturne.css" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<link rel="stylesheet" href="media.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="vk-app">
|
<div class="vk-app">
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
<div class="vk-scope">
|
<div class="vk-scope">
|
||||||
<button id="scope-guild" class="vk-scope-btn active" type="button">Guild</button>
|
<button id="scope-guild" class="vk-scope-btn active" type="button">Guild</button>
|
||||||
<button id="scope-dm" class="vk-scope-btn" type="button">DM</button>
|
<button id="scope-dm" class="vk-scope-btn" type="button">DM</button>
|
||||||
|
<button id="scope-media" class="vk-scope-btn" type="button">Media</button>
|
||||||
|
<button id="scope-bot" class="vk-scope-btn" type="button">Bot</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vk-guild-picker">
|
<div class="vk-guild-picker">
|
||||||
@@ -534,5 +537,6 @@
|
|||||||
<script src="icons.js"></script>
|
<script src="icons.js"></script>
|
||||||
<script src="api.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
<script src="media.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="vk-media-panel">
|
||||||
|
<div class="vk-media-head">
|
||||||
|
<span id="media-title" class="vk-media-title">Media archive</span>
|
||||||
|
<span id="media-refresh" class="btn btn-secondary vk-small-btn">Refresh</span>
|
||||||
|
<span id="media-close" class="btn btn-secondary vk-small-btn">Close</span>
|
||||||
|
</div>
|
||||||
|
<div id="media-toolbar" class="vk-media-toolbar"></div>
|
||||||
|
<div id="media-status" class="vk-media-status"></div>
|
||||||
|
<div id="media-scrapes" class="vk-media-scrapes"></div>
|
||||||
|
<div id="media-grid" class="vk-media-grid"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user