818 lines
31 KiB
JavaScript
818 lines
31 KiB
JavaScript
"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: [],
|
|
guilds: [],
|
|
dms: [],
|
|
loadedChannels: false,
|
|
authors: null,
|
|
filters: {
|
|
source: "",
|
|
channelId: "",
|
|
authorId: "",
|
|
aspect: "",
|
|
resolution: "",
|
|
after: "",
|
|
before: ""
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
/** 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.guilds = data?.guilds ?? [];
|
|
state.channels = [];
|
|
for (const guild of state.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 dmData = await window.VK.api.get("/api/dms?limit=50");
|
|
state.dms = dmData?.channels ?? [];
|
|
for (const dm of state.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 {
|
|
state.dms = [];
|
|
}
|
|
|
|
state.loadedChannels = true;
|
|
} catch {
|
|
state.guilds = [];
|
|
state.channels = [];
|
|
}
|
|
}
|
|
|
|
async function loadAuthors() {
|
|
if (state.authors) return;
|
|
try {
|
|
state.authors = await window.VK.api.get("/api/media/authors");
|
|
} catch {
|
|
state.authors = [];
|
|
}
|
|
}
|
|
|
|
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 author = state.filters.authorId ? `&authorId=${state.filters.authorId}` : "";
|
|
const aspect = state.filters.aspect ? `&aspect=${state.filters.aspect}` : "";
|
|
const resolution = state.filters.resolution ? `&resolution=${state.filters.resolution}` : "";
|
|
const after = state.filters.after ? `&after=${state.filters.after}T00:00:00.000Z` : "";
|
|
const before = state.filters.before ? `&before=${state.filters.before}T23:59:59.999Z` : "";
|
|
const items = await window.VK.api.get(
|
|
`/api/media?limit=60&offset=${state.offset}${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`
|
|
);
|
|
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(channelId, guildId, mode) {
|
|
if (!channelId) {
|
|
setStatus("no channel selected");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const scrape = await window.VK.api.post("/api/media/scrape", {
|
|
channelId,
|
|
guildId: guildId ?? null,
|
|
mode,
|
|
backend: guildId ? "auto" : "puppet"
|
|
});
|
|
setStatus(`started ${scrape.id} (${mode})`);
|
|
scrapeModal.hidden = true;
|
|
} catch (error) {
|
|
if (error.code === "MEDIA_LIMIT" || String(error.message).includes("MEDIA_LIMIT")) {
|
|
setStatus(`near the storage limit: ${error.message}`);
|
|
} else {
|
|
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");
|
|
const media = scrape.media ?? {};
|
|
|
|
if (scrape.state === "running" || scrape.state === "queued") {
|
|
row.append(el("span", "vk-media-pulse"));
|
|
}
|
|
|
|
row.append(
|
|
el(
|
|
"span",
|
|
null,
|
|
`${channelLabel(scrape.channelId)} · ${scrape.state} · ${scrape.pages}p · ${scrape.messages}m · ` +
|
|
`img ${media.image ?? 0} gif ${media.gif ?? 0} vid ${media.video ?? 0} aud ${media.audio ?? 0}` +
|
|
(scrape.downloaded ? ` · files ${scrape.downloaded}` : "") +
|
|
(scrape.previewed ? ` · previews ${scrape.previewed}` : "")
|
|
)
|
|
);
|
|
|
|
if (active.includes(scrape)) {
|
|
row.append(
|
|
button("Stop", async () => {
|
|
await window.VK.api.post("/api/media/scrape/stop", { id: scrape.id });
|
|
void loadScrapes();
|
|
})
|
|
);
|
|
} else {
|
|
row.append(el("span", "vk-media-status", durationLabel(scrape)));
|
|
}
|
|
|
|
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";
|
|
|
|
const postedBy = [item.authorName, item.postedAt ? new Date(item.postedAt).toLocaleString() : null]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
if (postedBy) card.title = postedBy;
|
|
|
|
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 filterSelect(options, value, onChange) {
|
|
const select = document.createElement("select");
|
|
select.className = "vk-chip vk-chip-off";
|
|
for (const [optionValue, label] of options) {
|
|
const option = document.createElement("option");
|
|
option.value = optionValue;
|
|
option.textContent = label;
|
|
select.append(option);
|
|
}
|
|
select.value = value;
|
|
select.addEventListener("change", () => onChange(select.value));
|
|
return select;
|
|
}
|
|
|
|
function dateInput(value, onChange) {
|
|
const input = document.createElement("input");
|
|
input.type = "date";
|
|
input.className = "vk-media-input";
|
|
input.value = value ?? "";
|
|
input.addEventListener("change", () => onChange(input.value));
|
|
return input;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
toolbar.append(button("New scrape…", openScrapeModal));
|
|
toolbar.append(button("History", openHistoryModal));
|
|
|
|
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);
|
|
|
|
const authorOptions = [["", "Any user"]].concat(
|
|
(state.authors ?? [])
|
|
.filter(author => author.id)
|
|
.map(author => [author.id, `${author.name} (${author.count})`])
|
|
);
|
|
toolbar.append(
|
|
filterSelect(authorOptions, state.filters.authorId, value => {
|
|
state.filters.authorId = value;
|
|
void loadMedia(true);
|
|
})
|
|
);
|
|
|
|
toolbar.append(
|
|
filterSelect(
|
|
[
|
|
["", "Any ratio"],
|
|
["vertical", "Vertical"],
|
|
["square", "1:1"],
|
|
["wide43", "4:3"],
|
|
["wide169", "16:9"],
|
|
["ultrawide", "21:9"],
|
|
["other", "Other"]
|
|
],
|
|
state.filters.aspect,
|
|
value => {
|
|
state.filters.aspect = value;
|
|
void loadMedia(true);
|
|
}
|
|
)
|
|
);
|
|
|
|
toolbar.append(
|
|
filterSelect(
|
|
[
|
|
["", "Any quality"],
|
|
["sd", "SD (<=480p)"],
|
|
["hd", "HD (720p)"],
|
|
["fhd", "FHD (1080p)"],
|
|
["qhd", "QHD (1440p)"],
|
|
["uhd", "4K+"],
|
|
["unknown", "Unknown"]
|
|
],
|
|
state.filters.resolution,
|
|
value => {
|
|
state.filters.resolution = value;
|
|
void loadMedia(true);
|
|
}
|
|
)
|
|
);
|
|
|
|
toolbar.append(
|
|
el("span", "vk-media-status", "From"),
|
|
dateInput(state.filters.after, value => {
|
|
state.filters.after = value;
|
|
void loadMedia(true);
|
|
}),
|
|
el("span", "vk-media-status", "To"),
|
|
dateInput(state.filters.before, value => {
|
|
state.filters.before = value;
|
|
void loadMedia(true);
|
|
})
|
|
);
|
|
} 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 loadAuthors().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);
|
|
|
|
// ---- Scrape popup: server or group-chat trees ----
|
|
|
|
const scrapeModal = document.createElement("div");
|
|
scrapeModal.className = "vk-media-modal";
|
|
scrapeModal.hidden = true;
|
|
scrapeModal.innerHTML = `
|
|
<div class="vk-media-modal-box vk-media-wide">
|
|
<strong>New scrape</strong>
|
|
<div class="vk-media-toolbar">
|
|
<button id="scrape-tab-server" class="vk-chip vk-chip-on" type="button">Server</button>
|
|
<button id="scrape-tab-group" class="vk-chip vk-chip-off" type="button">Group chat</button>
|
|
<span id="scrape-hint" class="vk-media-status"></span>
|
|
</div>
|
|
<div id="scrape-tree" class="vk-media-tree"></div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="scrape-cancel" class="btn btn-secondary vk-small-btn" type="button">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(scrapeModal);
|
|
|
|
let scrapeTab = "server";
|
|
|
|
function channelRow(channelId, guildId, label) {
|
|
const row = el("div", "vk-media-channel");
|
|
row.append(el("span", "vk-media-channel-name", label));
|
|
row.append(button("Links", () => void startScrape(channelId, guildId, "links")));
|
|
row.append(button("Download", () => void startScrape(channelId, guildId, "download")));
|
|
return row;
|
|
}
|
|
|
|
function renderScrapeTree() {
|
|
const tree = scrapeModal.querySelector("#scrape-tree");
|
|
const hint = scrapeModal.querySelector("#scrape-hint");
|
|
tree.replaceChildren();
|
|
|
|
if (scrapeTab === "group") {
|
|
const groups = state.dms.filter(dm => dm.type === 3);
|
|
hint.textContent = groups.length ? "Group chats use the puppet account." : "No cached group chats yet.";
|
|
for (const dm of groups) {
|
|
const names = (dm.recipients ?? [])
|
|
.map(recipient => recipient.username)
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
tree.append(channelRow(dm.id, null, dm.name || names || dm.id));
|
|
}
|
|
return;
|
|
}
|
|
|
|
hint.textContent = state.guilds.length ? "" : "No guilds cached yet.";
|
|
for (const guild of state.guilds) {
|
|
const group = el("div", "vk-media-tree-guild");
|
|
const head = el("button", "vk-media-tree-head", `> ${guild.name}`);
|
|
head.type = "button";
|
|
const children = el("div", "vk-media-tree-children");
|
|
children.hidden = true;
|
|
|
|
head.addEventListener("click", () => {
|
|
children.hidden = !children.hidden;
|
|
head.textContent = `${children.hidden ? ">" : "v"} ${guild.name}`;
|
|
});
|
|
|
|
group.append(head, children);
|
|
for (const channel of guild.channels ?? []) {
|
|
if (channel.type !== 0 && channel.type !== 5) continue;
|
|
children.append(channelRow(channel.id, guild.id, `#${channel.name}`));
|
|
}
|
|
tree.append(group);
|
|
}
|
|
}
|
|
|
|
function openScrapeModal() {
|
|
renderScrapeTree();
|
|
scrapeModal.hidden = false;
|
|
void loadChannels().then(renderScrapeTree);
|
|
}
|
|
|
|
for (const [tab, id] of [
|
|
["server", "#scrape-tab-server"],
|
|
["group", "#scrape-tab-group"]
|
|
]) {
|
|
scrapeModal.querySelector(id).addEventListener("click", () => {
|
|
scrapeTab = tab;
|
|
scrapeModal.querySelector("#scrape-tab-server").className =
|
|
tab === "server" ? "vk-chip vk-chip-on" : "vk-chip vk-chip-off";
|
|
scrapeModal.querySelector("#scrape-tab-group").className =
|
|
tab === "group" ? "vk-chip vk-chip-on" : "vk-chip vk-chip-off";
|
|
renderScrapeTree();
|
|
});
|
|
}
|
|
scrapeModal.querySelector("#scrape-cancel").addEventListener("click", () => {
|
|
scrapeModal.hidden = true;
|
|
});
|
|
|
|
// ---- History popup ----
|
|
|
|
const historyModal = document.createElement("div");
|
|
historyModal.className = "vk-media-modal";
|
|
historyModal.hidden = true;
|
|
historyModal.innerHTML = `
|
|
<div class="vk-media-modal-box vk-media-wide">
|
|
<strong>Scrape history</strong>
|
|
<div id="history-list" class="vk-media-modal-list"></div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="history-close" class="btn btn-secondary vk-small-btn" type="button">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(historyModal);
|
|
|
|
function durationLabel(scrape) {
|
|
const start = new Date(scrape.createdAt).getTime();
|
|
const end = scrape.finishedAt ? new Date(scrape.finishedAt).getTime() : Date.now();
|
|
const seconds = Math.max(0, Math.round((end - start) / 1000));
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
}
|
|
|
|
function channelLabel(channelId) {
|
|
return state.channels.find(channel => channel.id === channelId)?.label ?? channelId;
|
|
}
|
|
|
|
async function openHistoryModal() {
|
|
const list = historyModal.querySelector("#history-list");
|
|
list.replaceChildren(el("div", null, "loading..."));
|
|
historyModal.hidden = false;
|
|
|
|
try {
|
|
const [scrapes, storage] = await Promise.all([
|
|
window.VK.api.get("/api/media/scrapes"),
|
|
window.VK.api.get("/api/media/storage")
|
|
]);
|
|
await loadChannels();
|
|
|
|
list.replaceChildren();
|
|
const bytesById = new Map((storage?.scrapes ?? []).map(entry => [entry.id, entry.bytes]));
|
|
|
|
for (const scrape of scrapes) {
|
|
const media = scrape.media ?? {};
|
|
const row = el("div", "vk-media-history-row");
|
|
row.append(el("strong", null, channelLabel(scrape.channelId)));
|
|
row.append(
|
|
el(
|
|
"span",
|
|
null,
|
|
`${scrape.state} · started ${new Date(scrape.createdAt).toLocaleString()} · ` +
|
|
`took ${durationLabel(scrape)} · ${scrape.pages}p/${scrape.messages}m · ` +
|
|
`img ${media.image ?? 0} gif ${media.gif ?? 0} vid ${media.video ?? 0} aud ${media.audio ?? 0} · ` +
|
|
`${formatBytes(bytesById.get(scrape.id) ?? 0)} · files ${scrape.downloaded ?? 0} · previews ${scrape.previewed ?? 0}`
|
|
)
|
|
);
|
|
list.append(row);
|
|
}
|
|
|
|
if (!scrapes.length) list.append(el("div", null, "No scrapes yet."));
|
|
} catch (error) {
|
|
list.replaceChildren(el("div", null, `history failed: ${error.message}`));
|
|
}
|
|
}
|
|
|
|
historyModal.querySelector("#history-close").addEventListener("click", () => {
|
|
historyModal.hidden = true;
|
|
});
|
|
|
|
// ---- 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 = `
|
|
<div class="vk-media-modal-box">
|
|
<strong>Delete media scrapes?</strong>
|
|
<div id="clean-note" class="vk-media-status"></div>
|
|
<div id="clean-list" class="vk-media-modal-list"></div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="clean-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
|
|
<button id="clean-confirm" class="btn btn-primary vk-small-btn" type="button" disabled>Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 };
|
|
})();
|