Add custom download destinations, confirmations, and the bot error feed

This commit is contained in:
Fisto
2026-09-16 11:53:59 +02:00
parent 973b9cff92
commit 42fd52b96b
14 changed files with 471 additions and 62 deletions
+189 -21
View File
@@ -22,6 +22,7 @@
dms: [],
loadedChannels: false,
authors: null,
errors: [],
filters: {
source: "",
channelId: "",
@@ -92,6 +93,13 @@
document.querySelector("#scope-bot")?.classList.toggle("active", view === "bot");
document.querySelector("#scope-guild")?.classList.remove("active");
document.querySelector("#scope-dm")?.classList.remove("active");
if (view === "bot") {
localStorage.setItem("vk-media-errors-seen", String(errorCount));
updateErrorBadge();
void refreshErrors();
}
render();
}
@@ -191,7 +199,7 @@
renderGrid();
}
async function startScrape(channelId, guildId, mode) {
async function startScrape(channelId, guildId, mode, downloadDir) {
if (!channelId) {
setStatus("no channel selected");
return;
@@ -202,7 +210,8 @@
channelId,
guildId: guildId ?? null,
mode,
backend: guildId ? "auto" : "puppet"
backend: guildId ? "auto" : "puppet",
downloadDir: downloadDir ?? null
});
setStatus(`started ${scrape.id} (${mode})`);
scrapeModal.hidden = true;
@@ -219,8 +228,8 @@
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) {
for (const scrape of active) {
const row = el("div", "vk-media-scrape");
const media = scrape.media ?? {};
@@ -239,21 +248,79 @@
)
);
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)));
}
row.append(
button("Stop", async () => {
await window.VK.api.post("/api/media/scrape/stop", { id: scrape.id });
void loadScrapes();
})
);
scrapeList.append(row);
}
}
// ---- Bot errors ----
let errorCount = 0;
function seenErrorCount() {
return Number(localStorage.getItem("vk-media-errors-seen") ?? 0);
}
function updateErrorBadge() {
const badge = document.querySelector("#scope-bot-badge");
if (!badge) return;
const unseen = Math.max(0, errorCount - seenErrorCount());
badge.textContent = String(unseen);
badge.hidden = unseen === 0;
}
function renderErrors() {
scrapeList.replaceChildren();
const head = el("div", "vk-media-scrape");
head.append(el("span", null, `${(state.errors ?? []).length} error(s)`));
head.append(
button("Clear errors", async () => {
try {
await window.VK.api.post("/api/media/errors/clear", {});
} catch {
// the list stays until the next refresh
}
state.errors = [];
errorCount = 0;
localStorage.setItem("vk-media-errors-seen", "0");
updateErrorBadge();
renderErrors();
})
);
scrapeList.append(head);
for (const entry of state.errors ?? []) {
scrapeList.append(
el(
"div",
"vk-media-scrape vk-media-error",
`${new Date(entry.at).toLocaleTimeString()} · ${entry.source} · ${entry.message}`
)
);
}
if (!(state.errors ?? []).length) scrapeList.append(el("div", "vk-media-scrape", "No errors."));
}
async function refreshErrors() {
try {
state.errors = await window.VK.api.get("/api/media/errors");
} catch {
state.errors = [];
}
errorCount = (state.errors ?? []).length;
updateErrorBadge();
if (state.view === "bot") renderErrors();
}
function renderGrid() {
grid.replaceChildren();
for (const item of state.items) {
@@ -319,8 +386,7 @@
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";
@@ -428,6 +494,10 @@
void loadMedia(true);
})
);
toolbar.append(el("span", "vk-media-spacer"));
toolbar.append(button("History", openHistoryModal));
toolbar.append(button("New scrape…", openScrapeModal));
} else {
toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md."));
}
@@ -444,12 +514,14 @@
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)";
void loadScrapes().then(renderScrapes);
void refreshErrors();
} else {
void loadChannels().then(renderToolbar);
void loadAuthors().then(renderToolbar);
void loadMedia(true);
void loadScrapes();
}
void loadScrapes();
}
overlay.querySelector("#media-close").addEventListener("click", hide);
@@ -465,6 +537,16 @@
document.querySelector("#scope-guild")?.addEventListener("click", hide);
document.querySelector("#scope-dm")?.addEventListener("click", hide);
const errorBadge = el("span", "vk-media-badge");
errorBadge.id = "scope-bot-badge";
errorBadge.hidden = true;
scopeBot?.append(errorBadge);
void refreshErrors();
setInterval(() => {
if (state.view !== "bot") void refreshErrors();
}, 15000);
// ---- Scrape popup: server or group-chat trees ----
const scrapeModal = document.createElement("div");
@@ -472,7 +554,11 @@
scrapeModal.hidden = true;
scrapeModal.innerHTML = `
<div class="vk-media-modal-box vk-media-wide">
<strong>New scrape</strong>
<div class="vk-media-head">
<strong>New scrape</strong>
<span class="vk-media-spacer"></span>
<button id="scrape-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
</div>
<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>
@@ -491,8 +577,8 @@
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")));
row.append(button("Links", () => askScrape(channelId, guildId, "links", label)));
row.append(button("Download", () => askScrape(channelId, guildId, "download", label)));
return row;
}
@@ -558,6 +644,78 @@
scrapeModal.querySelector("#scrape-cancel").addEventListener("click", () => {
scrapeModal.hidden = true;
});
scrapeModal.querySelector("#scrape-x").addEventListener("click", () => {
scrapeModal.hidden = true;
});
scrapeModal.addEventListener("click", event => {
if (event.target === scrapeModal) scrapeModal.hidden = true;
});
// ---- Confirmation popup for Links / Download ----
const confirmModal = document.createElement("div");
confirmModal.className = "vk-media-modal";
confirmModal.hidden = true;
confirmModal.innerHTML = `
<div class="vk-media-modal-box">
<strong id="confirm-title">Start scrape?</strong>
<div id="confirm-note" class="vk-media-status"></div>
<label id="confirm-dest-row" class="vk-media-check" hidden>
<input id="confirm-custom-dest" type="checkbox" /> Custom download destination
</label>
<input
id="confirm-dest"
class="vk-media-input vk-media-dest"
type="text"
placeholder="/path/to/download/"
hidden
/>
<div class="vk-media-toolbar">
<button id="confirm-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="confirm-start" class="btn btn-primary vk-small-btn" type="button">Start</button>
</div>
</div>
`;
document.body.append(confirmModal);
let confirmAction = null;
function askScrape(channelId, guildId, mode, label) {
const isDownload = mode === "download";
const destRow = confirmModal.querySelector("#confirm-dest-row");
const destInput = confirmModal.querySelector("#confirm-dest");
const checkbox = confirmModal.querySelector("#confirm-custom-dest");
confirmModal.querySelector("#confirm-title").textContent =
isDownload ? "Download scrape?" : "Links scrape?";
confirmModal.querySelector("#confirm-note").textContent = isDownload
? `${label} — downloads files${guildId ? "" : " with the puppet"}.`
: `${label} — indexes links and caches previews${guildId ? "" : " with the puppet"}.`;
destRow.hidden = !isDownload;
destInput.hidden = true;
destInput.value = "";
checkbox.checked = false;
checkbox.onchange = () => {
destInput.hidden = !checkbox.checked;
};
confirmAction = () =>
startScrape(channelId, guildId, mode, isDownload && checkbox.checked ? destInput.value.trim() : null);
confirmModal.hidden = false;
}
confirmModal.querySelector("#confirm-cancel").addEventListener("click", () => {
confirmModal.hidden = true;
});
confirmModal.querySelector("#confirm-start").addEventListener("click", () => {
const action = confirmAction;
confirmModal.hidden = true;
void action?.();
});
confirmModal.addEventListener("click", event => {
if (event.target === confirmModal) confirmModal.hidden = true;
});
// ---- History popup ----
@@ -566,7 +724,11 @@
historyModal.hidden = true;
historyModal.innerHTML = `
<div class="vk-media-modal-box vk-media-wide">
<strong>Scrape history</strong>
<div class="vk-media-head">
<strong>Scrape history</strong>
<span class="vk-media-spacer"></span>
<button id="history-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
</div>
<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>
@@ -630,6 +792,12 @@
historyModal.querySelector("#history-close").addEventListener("click", () => {
historyModal.hidden = true;
});
historyModal.querySelector("#history-x").addEventListener("click", () => {
historyModal.hidden = true;
});
historyModal.addEventListener("click", event => {
if (event.target === historyModal) historyModal.hidden = true;
});
// ---- Settings: media storage ----