Add the advanced search drawer, adaptive paging, and zoom
This commit is contained in:
+361
-144
@@ -31,15 +31,11 @@
|
||||
localStorage.getItem("vk-media-meta-fields") ??
|
||||
'["user","type","date","source","resolution"]'
|
||||
),
|
||||
filters: {
|
||||
source: "",
|
||||
channelId: "",
|
||||
authorId: "",
|
||||
aspect: "",
|
||||
resolution: "",
|
||||
after: "",
|
||||
before: ""
|
||||
}
|
||||
filters: defaultFilters(),
|
||||
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
|
||||
draft: null,
|
||||
prefetched: null,
|
||||
pageSize: 60
|
||||
};
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
@@ -66,6 +62,10 @@
|
||||
const scrapeList = overlay.querySelector("#media-scrapes");
|
||||
const grid = overlay.querySelector("#media-grid");
|
||||
|
||||
const filterDrawer = el("div", "vk-media-drawer");
|
||||
filterDrawer.hidden = true;
|
||||
toolbar.after(filterDrawer);
|
||||
|
||||
function setStatus(text) {
|
||||
statusLine.textContent = text;
|
||||
}
|
||||
@@ -102,6 +102,8 @@
|
||||
document.querySelector("#scope-guild")?.classList.remove("active");
|
||||
document.querySelector("#scope-dm")?.classList.remove("active");
|
||||
|
||||
if (view === "media") applyZoom();
|
||||
|
||||
if (view === "bot") {
|
||||
localStorage.setItem("vk-media-errors-seen", String(errorCount));
|
||||
updateErrorBadge();
|
||||
@@ -188,12 +190,16 @@
|
||||
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 authors = state.filters.authorIds.length
|
||||
? `authorId=${state.filters.authorIds.join(",")}&`
|
||||
: "";
|
||||
const origin = state.filters.origin ? `origin=${state.filters.origin}&` : "";
|
||||
const cached = state.filters.cachedOnly ? "cachedOnly=1&" : "";
|
||||
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&` : "";
|
||||
return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`;
|
||||
return `${kind}${source}${channel}${authors}${origin}${cached}${aspect}${resolution}${after}${before}`;
|
||||
}
|
||||
|
||||
function kindTotals(field) {
|
||||
@@ -233,8 +239,6 @@
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
async function loadMedia(reset) {
|
||||
if (!state.loaded || state.loading) return;
|
||||
|
||||
@@ -243,15 +247,16 @@
|
||||
state.items = [];
|
||||
state.offset = 0;
|
||||
state.more = false;
|
||||
state.prefetched = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await window.VK.api.get(
|
||||
`/api/media?limit=${PAGE_SIZE}&offset=${state.offset}&${filterQuery()}`
|
||||
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
||||
);
|
||||
state.items = state.items.concat(items);
|
||||
state.offset += items.length;
|
||||
state.more = items.length >= PAGE_SIZE;
|
||||
state.more = items.length >= state.pageSize;
|
||||
} catch (error) {
|
||||
setStatus(`media load failed: ${error.message}`);
|
||||
} finally {
|
||||
@@ -260,6 +265,32 @@
|
||||
|
||||
renderGrid();
|
||||
void loadStats();
|
||||
void prefetchNext();
|
||||
}
|
||||
|
||||
/** Warm the next page's JSON and preview images before the user reaches them. */
|
||||
async function prefetchNext() {
|
||||
if (!state.loaded || !state.more || state.prefetched || state.loading) return;
|
||||
|
||||
try {
|
||||
const items = await window.VK.api.get(
|
||||
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
||||
);
|
||||
if (!items.length) {
|
||||
state.more = false;
|
||||
return;
|
||||
}
|
||||
|
||||
state.prefetched = items;
|
||||
for (const item of items) {
|
||||
const src = item.thumbUrl || item.localUrl;
|
||||
if (!src) continue;
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
}
|
||||
} catch {
|
||||
// Preloading is best effort; the scroll handler falls back to a request.
|
||||
}
|
||||
}
|
||||
|
||||
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
|
||||
@@ -593,134 +624,304 @@
|
||||
|
||||
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 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);
|
||||
})
|
||||
);
|
||||
|
||||
toolbar.append(metaMenu());
|
||||
toolbar.append(el("span", "vk-media-spacer"));
|
||||
toolbar.append(button("History", openHistoryModal));
|
||||
toolbar.append(button("New scrape…", openScrapeModal));
|
||||
} else {
|
||||
if (state.view !== "media") {
|
||||
toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md."));
|
||||
return;
|
||||
}
|
||||
|
||||
toolbar.append(button("Advanced search", toggleFilterDrawer));
|
||||
toolbar.append(metaMenu());
|
||||
toolbar.append(el("span", "vk-media-spacer"));
|
||||
toolbar.append(button("History", openHistoryModal));
|
||||
toolbar.append(button("New scrape…", openScrapeModal));
|
||||
}
|
||||
|
||||
// ---- Advanced search drawer ----
|
||||
|
||||
function defaultFilters() {
|
||||
return {
|
||||
source: "",
|
||||
channelId: "",
|
||||
authorIds: [],
|
||||
origin: "",
|
||||
cachedOnly: false,
|
||||
aspect: "",
|
||||
resolution: "",
|
||||
after: "",
|
||||
before: ""
|
||||
};
|
||||
}
|
||||
|
||||
function toggleFilterDrawer() {
|
||||
filterDrawer.hidden = !filterDrawer.hidden;
|
||||
if (!filterDrawer.hidden) {
|
||||
state.draft = {
|
||||
kind: state.kind,
|
||||
...state.filters,
|
||||
authorIds: [...state.filters.authorIds]
|
||||
};
|
||||
renderFilterDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFilterDrawer() {
|
||||
filterDrawer.replaceChildren();
|
||||
const draft = state.draft;
|
||||
|
||||
const section = (label) => {
|
||||
const wrap = el("div", "vk-media-filter-section");
|
||||
wrap.append(el("div", "vk-media-status", label));
|
||||
return wrap;
|
||||
};
|
||||
|
||||
// Filetype
|
||||
const kinds = section("Filetype");
|
||||
const chips = el("div", "vk-media-toolbar");
|
||||
for (const kind of KINDS) {
|
||||
const chip = el(
|
||||
"button",
|
||||
`vk-chip ${draft.kind === kind ? "vk-chip-on" : "vk-chip-off"}`,
|
||||
KIND_LABELS[kind]
|
||||
);
|
||||
chip.type = "button";
|
||||
chip.addEventListener("click", () => {
|
||||
draft.kind = kind;
|
||||
renderFilterDrawer();
|
||||
});
|
||||
chips.append(chip);
|
||||
}
|
||||
kinds.append(chips);
|
||||
filterDrawer.append(kinds);
|
||||
|
||||
// Source kind
|
||||
const sourceSection = section("Source kind");
|
||||
sourceSection.append(
|
||||
filterSelect(
|
||||
[
|
||||
["", "Any"],
|
||||
["attachment", "Attachments"],
|
||||
["sticker", "Stickers"],
|
||||
["embed", "Embeds"]
|
||||
],
|
||||
draft.source,
|
||||
value => {
|
||||
draft.source = value;
|
||||
}
|
||||
)
|
||||
);
|
||||
filterDrawer.append(sourceSection);
|
||||
|
||||
// Server or DM
|
||||
const originSection = section("Source");
|
||||
originSection.append(
|
||||
filterSelect(
|
||||
[
|
||||
["", "Anywhere"],
|
||||
["guild", "Server channels"],
|
||||
["dm", "DMs and group chats"]
|
||||
],
|
||||
draft.origin,
|
||||
value => {
|
||||
draft.origin = value;
|
||||
}
|
||||
)
|
||||
);
|
||||
filterDrawer.append(originSection);
|
||||
|
||||
// People (multi-select)
|
||||
const peopleSection = section("People");
|
||||
const people = el("div", "vk-media-people");
|
||||
for (const author of state.authors ?? []) {
|
||||
if (!author.id) continue;
|
||||
const row = el("label", "vk-media-check");
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.checked = draft.authorIds.includes(author.id);
|
||||
box.addEventListener("change", () => {
|
||||
draft.authorIds = box.checked
|
||||
? [...new Set([...draft.authorIds, author.id])]
|
||||
: draft.authorIds.filter(id => id !== author.id);
|
||||
});
|
||||
row.append(box, document.createTextNode(` ${author.name} (${author.count})`));
|
||||
people.append(row);
|
||||
}
|
||||
if (!people.childElementCount) people.append(el("span", "vk-media-status", "No posters indexed yet."));
|
||||
peopleSection.append(people);
|
||||
filterDrawer.append(peopleSection);
|
||||
|
||||
// Advanced
|
||||
const advanced = document.createElement("details");
|
||||
advanced.className = "vk-media-advanced";
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "Advanced";
|
||||
advanced.append(summary);
|
||||
|
||||
const advancedBody = el("div", "vk-media-filter-section");
|
||||
advancedBody.append(
|
||||
filterSelect(
|
||||
[
|
||||
["", "Any ratio"],
|
||||
["vertical", "Vertical"],
|
||||
["square", "1:1"],
|
||||
["wide43", "4:3"],
|
||||
["wide169", "16:9"],
|
||||
["ultrawide", "21:9"],
|
||||
["other", "Other"]
|
||||
],
|
||||
draft.aspect,
|
||||
value => {
|
||||
draft.aspect = value;
|
||||
}
|
||||
),
|
||||
filterSelect(
|
||||
[
|
||||
["", "Any quality"],
|
||||
["sd", "SD (<=480p)"],
|
||||
["hd", "HD (720p)"],
|
||||
["fhd", "FHD (1080p)"],
|
||||
["qhd", "QHD (1440p)"],
|
||||
["uhd", "4K+"],
|
||||
["unknown", "Unknown"]
|
||||
],
|
||||
draft.resolution,
|
||||
value => {
|
||||
draft.resolution = value;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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 = draft.channelId;
|
||||
channelSelect.addEventListener("change", () => {
|
||||
draft.channelId = channelSelect.value;
|
||||
});
|
||||
advancedBody.append(channelSelect);
|
||||
|
||||
advancedBody.append(
|
||||
el("span", "vk-media-status", "From"),
|
||||
dateInput(draft.after, value => {
|
||||
draft.after = value;
|
||||
}),
|
||||
el("span", "vk-media-status", "To"),
|
||||
dateInput(draft.before, value => {
|
||||
draft.before = value;
|
||||
})
|
||||
);
|
||||
|
||||
const cachedRow = el("label", "vk-media-check");
|
||||
const cachedBox = document.createElement("input");
|
||||
cachedBox.type = "checkbox";
|
||||
cachedBox.checked = draft.cachedOnly;
|
||||
cachedBox.addEventListener("change", () => {
|
||||
draft.cachedOnly = cachedBox.checked;
|
||||
});
|
||||
cachedRow.append(cachedBox, document.createTextNode(" Show only cached previews"));
|
||||
advancedBody.append(cachedRow);
|
||||
|
||||
advanced.append(advancedBody);
|
||||
filterDrawer.append(advanced);
|
||||
|
||||
// Zoom
|
||||
const zoomSection = section("Card size");
|
||||
const zoom = document.createElement("input");
|
||||
zoom.type = "range";
|
||||
zoom.min = "0";
|
||||
zoom.max = "100";
|
||||
zoom.value = String(state.zoom);
|
||||
zoom.className = "vk-media-zoom";
|
||||
zoom.addEventListener("input", () => {
|
||||
state.zoom = Number(zoom.value);
|
||||
localStorage.setItem("vk-media-zoom", String(state.zoom));
|
||||
applyZoom();
|
||||
});
|
||||
zoomSection.append(zoom);
|
||||
filterDrawer.append(zoomSection);
|
||||
|
||||
// Actions
|
||||
const actions = el("div", "vk-media-toolbar");
|
||||
actions.append(
|
||||
button("Apply filters", () => {
|
||||
state.kind = draft.kind;
|
||||
state.filters = {
|
||||
source: draft.source,
|
||||
channelId: draft.channelId,
|
||||
authorIds: [...draft.authorIds],
|
||||
origin: draft.origin,
|
||||
cachedOnly: draft.cachedOnly,
|
||||
aspect: draft.aspect,
|
||||
resolution: draft.resolution,
|
||||
after: draft.after,
|
||||
before: draft.before
|
||||
};
|
||||
filterDrawer.hidden = true;
|
||||
if (state.loaded) {
|
||||
void loadMedia(true);
|
||||
} else {
|
||||
void loadStats();
|
||||
}
|
||||
}),
|
||||
button("Reset", () => {
|
||||
resetMediaView();
|
||||
})
|
||||
);
|
||||
filterDrawer.append(actions);
|
||||
}
|
||||
|
||||
/** Card width from the zoom slider: 2/3 current, current, up to a full row. */
|
||||
function cardWidth() {
|
||||
const gridWidth = grid.clientWidth || 900;
|
||||
const zoom = Math.max(0, Math.min(100, state.zoom));
|
||||
if (zoom <= 50) return Math.round(100 + (zoom / 50) * 50);
|
||||
return Math.round(150 + ((zoom - 50) / 50) * Math.max(0, gridWidth - 150));
|
||||
}
|
||||
|
||||
/** Items per page: full rows, about 1.5 screens tall. */
|
||||
function computePageSize() {
|
||||
const width = cardWidth();
|
||||
const columns = Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8)));
|
||||
const rowHeight = width * 1.16 + 30;
|
||||
const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (rowHeight + 8)));
|
||||
return Math.max(columns, columns * Math.max(1, Math.ceil(rows * 1.5)));
|
||||
}
|
||||
|
||||
function applyZoom() {
|
||||
const width = cardWidth();
|
||||
const gridWidth = grid.clientWidth || 900;
|
||||
grid.style.gridTemplateColumns =
|
||||
width >= gridWidth - 24 ? "1fr" : `repeat(auto-fill, minmax(${width}px, 1fr))`;
|
||||
state.pageSize = computePageSize();
|
||||
}
|
||||
|
||||
function resetMediaView() {
|
||||
state.kind = "all";
|
||||
state.filters = defaultFilters();
|
||||
state.zoom = 50;
|
||||
localStorage.setItem("vk-media-zoom", "50");
|
||||
state.loaded = false;
|
||||
state.loading = false;
|
||||
state.more = false;
|
||||
state.prefetched = null;
|
||||
state.items = [];
|
||||
state.offset = 0;
|
||||
filterDrawer.hidden = true;
|
||||
applyZoom();
|
||||
renderGrid();
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -776,8 +977,22 @@
|
||||
|
||||
// Infinite scroll: fetch the next page before the grid bottom is reached.
|
||||
grid.addEventListener("scroll", () => {
|
||||
if (state.loading || !state.more) return;
|
||||
if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 240) void loadMedia(false);
|
||||
if (state.loading || !state.loaded) return;
|
||||
if (grid.scrollTop + grid.clientHeight < grid.scrollHeight - 240) return;
|
||||
|
||||
if (state.prefetched) {
|
||||
const items = state.prefetched;
|
||||
state.prefetched = null;
|
||||
state.items = state.items.concat(items);
|
||||
state.offset += items.length;
|
||||
state.more = items.length >= state.pageSize;
|
||||
renderGrid();
|
||||
void loadStats();
|
||||
void prefetchNext();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.more) void loadMedia(false);
|
||||
});
|
||||
|
||||
// ---- Scrape popup: server or group-chat trees ----
|
||||
@@ -1460,7 +1675,9 @@
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (state.view) layoutTop();
|
||||
if (!state.view) return;
|
||||
layoutTop();
|
||||
if (state.view === "media") applyZoom();
|
||||
});
|
||||
|
||||
const callStrip = document.querySelector("#call-strip");
|
||||
|
||||
Reference in New Issue
Block a user