Add the advanced search drawer, adaptive paging, and zoom
This commit is contained in:
@@ -40,6 +40,14 @@ mobile draft at 390px.
|
||||
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
||||
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
||||
- [x] C37 Advanced search drawer, adaptive paging, and zoom. Filters moved into an **Advanced
|
||||
search** drawer with Apply/Reset (no auto-update): filetype chips, source kind, origin
|
||||
(server/DM), multi-select People, and an Advanced section (aspect, resolution, channel, dates,
|
||||
**cachedOnly**). A **Card size** slider scales tiles from 100 px (2/3 of current) to one image
|
||||
per row; page size = full rows about 1.5 screens tall and follows zoom/resize. Infinite scroll
|
||||
preloads the next page's JSON and previews. Backend gained `authorIds`, `origin`, `cachedOnly`
|
||||
on `/api/media`, `/api/media/stats`, and MCP `query_media`. Verified: `origin=dm` and
|
||||
`cachedOnly=1` return the expected rows.
|
||||
- [x] C36 Daemon restart, preview diagnostics, browse-time caching, and rate-limit pauses.
|
||||
Settings → Server has a **Restart daemon** button (`POST /api/daemon/restart`: re-execs the
|
||||
daemon, keeps the client running). The preview pass logs every failure with scrape/item/kind/
|
||||
|
||||
+4
-4
@@ -23,10 +23,10 @@ interface MediaService {
|
||||
}
|
||||
```
|
||||
|
||||
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `authorId`, `aspect`,
|
||||
`resolution`, `after`, `before`, `limit`, `offset`. The `aspect` and `resolution` values are the
|
||||
preset keys from `filter-presets.ts`; `after`/`before` are ISO times on `posted_at`. Planned:
|
||||
`guildId`, `text`.
|
||||
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `authorIds`, `origin`
|
||||
(`guild`/`dm`), `cachedOnly`, `aspect`, `resolution`, `after`, `before`, `limit`, `offset`. The
|
||||
`aspect` and `resolution` values are the preset keys from `filter-presets.ts`; `after`/`before` are
|
||||
ISO times on `posted_at`. Planned: `guildId`, `text`.
|
||||
|
||||
`StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`,
|
||||
`scrapes: [{ id, bytes, state, createdAt }]`.
|
||||
|
||||
+12
-5
@@ -126,10 +126,17 @@ box live in Chat, not Call, and the connection stats are not chips yet.
|
||||
is visible, stay on screen). Settings slides over the media view, and dialogs that Settings
|
||||
opens (for example **Clean media…**) render above Settings. Mobile is out of scope for the media
|
||||
feature.
|
||||
- Media lists scraped items with kind chips (All, Images, GIFs, Videos, Audio) and filters for
|
||||
source (any, attachments, stickers, embeds), source channel, poster, aspect ratio, resolution,
|
||||
and a posted-date range (from/to). **New scrape…** opens a popup with **Server** and **Group
|
||||
chat** tabs: servers expand into their text channels, each channel (and each group chat) has
|
||||
- Media lists scraped items. Filters live in an **Advanced search** drawer and do not apply until
|
||||
**Apply filters** is pressed: filetype chips, source kind (attachments/stickers/embeds), origin
|
||||
(server channels or DMs), a multi-select **People** list, and an **Advanced** section with aspect
|
||||
ratio, resolution, source channel, posted-date range, and **Show only cached previews**.
|
||||
**Reset** clears every filter and the zoom, unloads the grid, and brings back the initial
|
||||
Load media prompt.
|
||||
- A **Card size** slider scales tiles from two-thirds of the old size up to one image per row; the
|
||||
page size follows the layout — full rows about 1.5 screens tall — and the grid infinite-scrolls,
|
||||
preloading the next page's JSON and preview images just before they are needed.
|
||||
**New scrape…** opens a popup with **Server** and **Group chat**
|
||||
tabs: servers expand into their text channels, each channel (and each group chat) has
|
||||
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
|
||||
each scrape with start time, duration, pages, messages, media counts, bytes, files, and previews.
|
||||
The scrape strip shows live progress with a pulse and **Stop**. Images and GIFs render from the
|
||||
@@ -258,7 +265,7 @@ replaceable.
|
||||
| POST | `/api/audio/stop` | none | Stop speech and clear the queue |
|
||||
| POST | `/api/audio/capture` | `start` | Start or stop capture |
|
||||
| GET | `/api/transcript` | `limit?`, `sessionId?` | Sessions and captured segments |
|
||||
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?`, `aspect?`, `resolution?`, `after?`, `before?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `aspect` and `resolution` are the preset keys; `after`/`before` are ISO times on `posted_at`. Items carry `authorName`, `postedAt`, `width`, `height`, `aspectRatio`, `contentType`, `durationSecs`, `previewError`; `localUrl`/`thumbUrl` when a file or preview exists |
|
||||
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?` (comma list), `origin?` (`guild`/`dm`), `cachedOnly?`, `aspect?`, `resolution?`, `after?`, `before?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `aspect` and `resolution` are the preset keys; `after`/`before` are ISO times on `posted_at`. Items carry `authorName`, `postedAt`, `width`, `height`, `aspectRatio`, `contentType`, `durationSecs`, `previewError`, `animUrl`; `localUrl`/`thumbUrl` when a file or preview exists |
|
||||
| GET | `/api/media/:scrapeId/:itemId/file` | none | The downloaded file, content-typed from its extension |
|
||||
| GET | `/api/media/:scrapeId/:itemId/thumb` | none | The cached preview (type from `preview_ext`) |
|
||||
| GET | `/api/media/:scrapeId/:itemId/stream` | none | Playback/full-view source: the local file, or the remote URL refreshed from the source message. Supports `Range`. Non-media upstream responses (provider pages) return `415` |
|
||||
|
||||
@@ -106,6 +106,8 @@ export interface MediaFilterOptions {
|
||||
kind?: MediaKind;
|
||||
source?: string;
|
||||
authorId?: string;
|
||||
authorIds?: string[];
|
||||
cachedOnly?: boolean;
|
||||
aspect?: string;
|
||||
resolution?: string;
|
||||
after?: string;
|
||||
@@ -484,6 +486,13 @@ export class ScrapeStore {
|
||||
conditions.push("author_id = ?");
|
||||
values.push(options.authorId);
|
||||
}
|
||||
if (options.authorIds?.length) {
|
||||
conditions.push(`author_id IN (${options.authorIds.map(() => "?").join(", ")})`);
|
||||
values.push(...options.authorIds);
|
||||
}
|
||||
if (options.cachedOnly) {
|
||||
conditions.push("preview_hash IS NOT NULL");
|
||||
}
|
||||
if (options.after) {
|
||||
conditions.push("posted_at >= ?");
|
||||
values.push(options.after);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -880,7 +880,9 @@ function buildServer(): McpServer {
|
||||
.describe("Where the item came from"),
|
||||
scrapeId: z.string().optional().describe("Restrict to one scrape"),
|
||||
channelId: z.string().optional().describe("Restrict to one source channel"),
|
||||
authorId: z.string().optional().describe("Restrict to one poster"),
|
||||
authorIds: z.array(z.string()).optional().describe("Restrict to these posters"),
|
||||
origin: z.enum(["dm", "guild"]).optional().describe("Server channels or DMs"),
|
||||
cachedOnly: z.boolean().optional().describe("Only items with a cached preview"),
|
||||
aspect: z
|
||||
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
|
||||
.optional()
|
||||
@@ -895,14 +897,30 @@ function buildServer(): McpServer {
|
||||
offset: z.number().int().min(0).optional()
|
||||
}
|
||||
},
|
||||
async ({ kind, source, scrapeId, channelId, authorId, aspect, resolution, after, before, limit, offset }) =>
|
||||
async ({
|
||||
kind,
|
||||
source,
|
||||
scrapeId,
|
||||
channelId,
|
||||
authorIds,
|
||||
origin,
|
||||
cachedOnly,
|
||||
aspect,
|
||||
resolution,
|
||||
after,
|
||||
before,
|
||||
limit,
|
||||
offset
|
||||
}) =>
|
||||
jsonText(
|
||||
media.queryMedia({
|
||||
kind,
|
||||
source,
|
||||
scrapeId,
|
||||
channelId,
|
||||
authorId,
|
||||
authorIds,
|
||||
origin,
|
||||
cachedOnly,
|
||||
aspect,
|
||||
resolution,
|
||||
after,
|
||||
|
||||
@@ -635,6 +635,9 @@ export class MediaService {
|
||||
source?: string;
|
||||
channelId?: string;
|
||||
authorId?: string;
|
||||
authorIds?: string[];
|
||||
origin?: "dm" | "guild";
|
||||
cachedOnly?: boolean;
|
||||
aspect?: string;
|
||||
resolution?: string;
|
||||
after?: string;
|
||||
@@ -751,6 +754,9 @@ export class MediaService {
|
||||
scrapeId?: string;
|
||||
channelId?: string;
|
||||
authorId?: string;
|
||||
authorIds?: string[];
|
||||
origin?: "dm" | "guild";
|
||||
cachedOnly?: boolean;
|
||||
aspect?: string;
|
||||
resolution?: string;
|
||||
after?: string;
|
||||
@@ -762,6 +768,8 @@ export class MediaService {
|
||||
if (meta.state === "deleted") continue;
|
||||
if (options.scrapeId && meta.id !== options.scrapeId) continue;
|
||||
if (options.channelId && meta.channelId !== options.channelId) continue;
|
||||
if (options.origin === "dm" && meta.guildId) continue;
|
||||
if (options.origin === "guild" && !meta.guildId) continue;
|
||||
|
||||
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
|
||||
try {
|
||||
@@ -772,6 +780,8 @@ export class MediaService {
|
||||
kind: options.kind,
|
||||
source: options.source,
|
||||
authorId: options.authorId,
|
||||
authorIds: options.authorIds,
|
||||
cachedOnly: options.cachedOnly,
|
||||
aspect: options.aspect,
|
||||
resolution: options.resolution,
|
||||
after: options.after,
|
||||
@@ -820,6 +830,9 @@ export class MediaService {
|
||||
scrapeId?: string;
|
||||
channelId?: string;
|
||||
authorId?: string;
|
||||
authorIds?: string[];
|
||||
origin?: "dm" | "guild";
|
||||
cachedOnly?: boolean;
|
||||
aspect?: string;
|
||||
resolution?: string;
|
||||
after?: string;
|
||||
|
||||
+28
-3
@@ -882,7 +882,17 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
? String(req.query.source)
|
||||
: undefined;
|
||||
const channelId = req.query.channelId ? String(req.query.channelId) : undefined;
|
||||
const authorId = req.query.authorId ? String(req.query.authorId) : undefined;
|
||||
const authorIds = req.query.authorId
|
||||
? String(req.query.authorId)
|
||||
.split(",")
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const origin =
|
||||
req.query.origin === "dm" || req.query.origin === "guild"
|
||||
? (String(req.query.origin) as "dm" | "guild")
|
||||
: undefined;
|
||||
const cachedOnly = req.query.cachedOnly === "1" || req.query.cachedOnly === "true";
|
||||
const aspect = req.query.aspect ? String(req.query.aspect) : undefined;
|
||||
const resolution = req.query.resolution ? String(req.query.resolution) : undefined;
|
||||
const after = req.query.after ? String(req.query.after) : undefined;
|
||||
@@ -896,7 +906,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
kind,
|
||||
source,
|
||||
channelId,
|
||||
authorId,
|
||||
authorIds,
|
||||
origin,
|
||||
cachedOnly,
|
||||
aspect,
|
||||
resolution,
|
||||
after,
|
||||
@@ -922,13 +934,26 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
? (String(req.query.kind) as "image" | "gif" | "video" | "audio")
|
||||
: undefined;
|
||||
|
||||
const authorIds = req.query.authorId
|
||||
? String(req.query.authorId)
|
||||
.split(",")
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const origin =
|
||||
req.query.origin === "dm" || req.query.origin === "guild"
|
||||
? (String(req.query.origin) as "dm" | "guild")
|
||||
: undefined;
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
result: deps.media.stats({
|
||||
kind,
|
||||
source: req.query.source ? String(req.query.source) : undefined,
|
||||
channelId: req.query.channelId ? String(req.query.channelId) : undefined,
|
||||
authorId: req.query.authorId ? String(req.query.authorId) : undefined,
|
||||
authorIds,
|
||||
origin,
|
||||
cachedOnly: req.query.cachedOnly === "1" || req.query.cachedOnly === "true",
|
||||
aspect: req.query.aspect ? String(req.query.aspect) : undefined,
|
||||
resolution: req.query.resolution ? String(req.query.resolution) : undefined,
|
||||
after: req.query.after ? String(req.query.after) : undefined,
|
||||
|
||||
Reference in New Issue
Block a user