Add the advanced search drawer, adaptive paging, and zoom

This commit is contained in:
Fisto
2026-09-17 07:57:26 +02:00
parent dea1724eb3
commit 5319a028e2
9 changed files with 492 additions and 159 deletions
+36
View File
@@ -136,6 +136,42 @@
cursor: pointer;
}
.vk-media-drawer {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 46vh;
overflow-y: auto;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
background: var(--color-neutral-900);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
}
.vk-media-filter-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.vk-media-people {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 150px;
overflow-y: auto;
}
.vk-media-advanced summary {
cursor: pointer;
font: 400 11px/1.4 ui-monospace, Menlo, monospace;
color: var(--color-neutral-300);
}
.vk-media-zoom {
width: 100%;
}
.vk-media-load-more {
grid-column: 1 / -1;
justify-self: center;
+361 -144
View File
@@ -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");