"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,
filters: { source: "", channelId: "" }
};
const overlay = document.createElement("div");
overlay.className = "vk-media-overlay";
overlay.hidden = true;
overlay.innerHTML = `
`;
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;
}
/** Start the view below the header and the call strip, filling the rest. */
function layoutTop() {
const header = document.querySelector(".vk-header");
const callStrip = document.querySelector("#call-strip");
let top = header ? header.getBoundingClientRect().bottom : 0;
if (callStrip && !callStrip.hidden) top = Math.max(top, callStrip.getBoundingClientRect().bottom);
overlay.style.setProperty("--media-top", `${Math.round(top)}px`);
}
function show(view) {
state.view = view;
layoutTop();
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}`
});
}
}
// DMs and group DMs: the bot cannot see them, so they always use the puppet.
try {
const dms = await window.VK.api.get("/api/dms?limit=50");
for (const dm of dms ?? []) {
const names = (dm.recipients ?? [])
.map(recipient => recipient.username)
.filter(Boolean)
.join(", ");
state.channels.push({
id: dm.id,
guildId: null,
label: `${dm.name || names || "DM"} (${dm.type === 3 ? "group DM" : "DM"})`
});
}
} catch {
// no cached DMs yet
}
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();
if (state.view === "media") renderToolbar();
}
async function loadMedia(reset) {
if (reset) {
state.items = [];
state.offset = 0;
}
try {
const kind = state.kind === "all" ? "" : `&kind=${state.kind}`;
const source = state.filters.source ? `&source=${state.filters.source}` : "";
const channel = state.filters.channelId ? `&channelId=${state.filters.channelId}` : "";
const items = await window.VK.api.get(
`/api/media?limit=60&offset=${state.offset}${kind}${source}${channel}`
);
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 = item.thumbUrl || 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")));
const sourceSelect = document.createElement("select");
sourceSelect.className = "vk-chip vk-chip-off";
for (const [value, label] of [
["", "Any source"],
["attachment", "Attachments"],
["sticker", "Stickers"],
["embed", "Embeds"]
]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
sourceSelect.append(option);
}
sourceSelect.value = state.filters.source;
sourceSelect.addEventListener("change", () => {
state.filters.source = sourceSelect.value;
void loadMedia(true);
});
toolbar.append(sourceSelect);
const channelSelect = document.createElement("select");
channelSelect.className = "vk-chip vk-chip-off";
const anyChannel = document.createElement("option");
anyChannel.value = "";
anyChannel.textContent = "Any channel";
channelSelect.append(anyChannel);
const seen = new Set();
for (const scrape of state.scrapes) {
if (seen.has(scrape.channelId)) continue;
seen.add(scrape.channelId);
const option = document.createElement("option");
option.value = scrape.channelId;
option.textContent =
state.channels.find(channel => channel.id === scrape.channelId)?.label ?? scrape.channelId;
channelSelect.append(option);
}
channelSelect.value = state.filters.channelId;
channelSelect.addEventListener("change", () => {
state.filters.channelId = channelSelect.value;
void loadMedia(true);
});
toolbar.append(channelSelect);
} 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 = `
`;
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);
window.addEventListener("resize", () => {
if (state.view) layoutTop();
});
const callStrip = document.querySelector("#call-strip");
if (callStrip) {
new MutationObserver(() => {
if (state.view) layoutTop();
}).observe(callStrip, { attributes: true, attributeFilter: ["hidden"] });
}
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 };
})();