Add links-mode embed thumbnails, item metadata, and media filters
This commit is contained in:
+5
-1
@@ -30,7 +30,11 @@ async function request(path, options) {
|
||||
} catch {
|
||||
throw new Error(`request failed (${response.status})`);
|
||||
}
|
||||
if (!body.ok) throw new Error(body.error ?? `request failed (${response.status})`);
|
||||
if (!body.ok) {
|
||||
const error = new Error(body.error ?? `request failed (${response.status})`);
|
||||
if (body.code) error.code = body.code;
|
||||
throw error;
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,9 +202,87 @@
|
||||
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.vk-media-modal-box.vk-media-wide {
|
||||
width: min(760px, 94vw);
|
||||
height: min(80vh, 720px);
|
||||
}
|
||||
|
||||
.vk-media-modal-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
font: 400 11px/1.5 ui-monospace, Menlo, monospace;
|
||||
color: var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.vk-media-wide .vk-media-modal-list {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vk-media-history-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.vk-media-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vk-media-tree-head {
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--color-neutral-900);
|
||||
color: var(--color-neutral-200);
|
||||
font: 500 12px/1.2 inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vk-media-tree-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin: 4px 0 4px 14px;
|
||||
}
|
||||
|
||||
.vk-media-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vk-media-channel-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: 400 11px/1.4 ui-monospace, Menlo, monospace;
|
||||
color: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
@keyframes vk-media-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.vk-media-pulse {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-400, #4f8cff);
|
||||
animation: vk-media-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
+328
-29
@@ -18,8 +18,19 @@
|
||||
items: [],
|
||||
scrapes: [],
|
||||
channels: [],
|
||||
guilds: [],
|
||||
dms: [],
|
||||
loadedChannels: false,
|
||||
filters: { source: "", channelId: "" }
|
||||
authors: null,
|
||||
filters: {
|
||||
source: "",
|
||||
channelId: "",
|
||||
authorId: "",
|
||||
aspect: "",
|
||||
resolution: "",
|
||||
after: "",
|
||||
before: ""
|
||||
}
|
||||
};
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
@@ -95,8 +106,9 @@
|
||||
if (state.loadedChannels) return;
|
||||
try {
|
||||
const data = await window.VK.api.get("/api/state");
|
||||
state.guilds = data?.guilds ?? [];
|
||||
state.channels = [];
|
||||
for (const guild of data?.guilds ?? []) {
|
||||
for (const guild of state.guilds) {
|
||||
for (const channel of guild.channels ?? []) {
|
||||
if (channel.type !== 0 && channel.type !== 5) continue;
|
||||
state.channels.push({
|
||||
@@ -109,8 +121,9 @@
|
||||
|
||||
// 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 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)
|
||||
@@ -122,15 +135,25 @@
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// no cached DMs yet
|
||||
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");
|
||||
@@ -151,8 +174,13 @@
|
||||
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}`
|
||||
`/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;
|
||||
@@ -163,22 +191,27 @@
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
async function startScrape(mode) {
|
||||
const select = overlay.querySelector("#media-channel");
|
||||
if (!select?.value) {
|
||||
setStatus("no text channel available to scrape");
|
||||
async function startScrape(channelId, guildId, mode) {
|
||||
if (!channelId) {
|
||||
setStatus("no channel selected");
|
||||
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
|
||||
channelId,
|
||||
guildId: guildId ?? null,
|
||||
mode,
|
||||
backend: guildId ? "auto" : "puppet"
|
||||
});
|
||||
setStatus(`started ${scrape.id} (${mode})`);
|
||||
scrapeModal.hidden = true;
|
||||
} catch (error) {
|
||||
setStatus(`start failed: ${error.message}`);
|
||||
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();
|
||||
}
|
||||
@@ -189,7 +222,23 @@
|
||||
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`));
|
||||
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 () => {
|
||||
@@ -197,7 +246,10 @@
|
||||
void loadScrapes();
|
||||
})
|
||||
);
|
||||
} else {
|
||||
row.append(el("span", "vk-media-status", durationLabel(scrape)));
|
||||
}
|
||||
|
||||
scrapeList.append(row);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +263,11 @@
|
||||
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";
|
||||
@@ -225,6 +282,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
@@ -239,19 +319,8 @@
|
||||
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")));
|
||||
toolbar.append(button("New scrape…", openScrapeModal));
|
||||
toolbar.append(button("History", openHistoryModal));
|
||||
|
||||
const sourceSelect = document.createElement("select");
|
||||
sourceSelect.className = "vk-chip vk-chip-off";
|
||||
@@ -296,6 +365,69 @@
|
||||
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."));
|
||||
}
|
||||
@@ -314,6 +446,7 @@
|
||||
: "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();
|
||||
@@ -332,6 +465,172 @@
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user