"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 source = item.localUrl || item.url; const card = el("a", "vk-media-card"); card.href = source; card.target = "_blank"; card.rel = "noreferrer"; if ((item.kind === "image" || item.kind === "gif") && /^(https?:|\/)/.test(source)) { const img = document.createElement("img"); img.loading = "lazy"; img.src = source; 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); // ---- 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 }; })();