Move common filters to the bar and keep the drawer for advanced ones

This commit is contained in:
Fisto
2026-09-17 08:22:32 +02:00
parent f5f3196382
commit 42b4bfd231
5 changed files with 134 additions and 129 deletions
+5
View File
@@ -40,6 +40,11 @@ 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] C38 Filter layout swap. The common filters (filetype chips, source kind, server/DM
origin, multi-select People popover) now sit in the always-visible bar; the Advanced search
drawer holds aspect, resolution, channel, date range, cached-only, and the card-size slider.
Apply/Reset stay in the bar. Fixed a dropped `defaultFilters` helper found by the Playwright
smoke test, which passes on the new layout.
- [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,
+4 -3
View File
@@ -145,9 +145,10 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
## Web UI
- **Media** tab: filter chips (All, Images, GIFs, Videos, Audio), and filters for source (any,
attachments, stickers, embeds), source channel, poster, aspect ratio (Vertical, 1:1, 4:3, 16:9,
21:9, Other), resolution (SD, HD, FHD, QHD, 4K+, Unknown), and a posted-date range (from/to).
- **Media** tab: the bar carries the common filters — kind chips, source kind, server/DM origin,
and the multi-select People popover. The **Advanced search** drawer has aspect ratio, resolution,
source channel, posted-date range, and cached-only, with **Apply filters** / **Reset** in the
bar.
Nothing renders until **Load media** is pressed. A **Metadata** dropdown toggles which fields
show in the small banner under each tile (User, Type, Date, Source, Resolution, Length). Video
tiles carry a translucent play icon top-right. Discord serves no thumbnails for uploaded videos,
+6 -6
View File
@@ -126,12 +126,12 @@ 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. 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.
- Media lists scraped items. The bar shows the common filters: filetype chips, source kind
(attachments/stickers/embeds), origin (server channels or DMs), and a multi-select **People**
popover. The **Advanced search** drawer holds aspect ratio, resolution, source channel,
posted-date range, and **Show only cached previews**, plus the card-size slider. Filters do not
apply until **Apply filters** is pressed; **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.
+112 -114
View File
@@ -622,22 +622,6 @@
return wrap;
}
function renderToolbar() {
toolbar.replaceChildren();
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: "",
@@ -652,112 +636,151 @@
};
}
function toggleFilterDrawer() {
filterDrawer.hidden = !filterDrawer.hidden;
if (!filterDrawer.hidden) {
state.draft = {
kind: state.kind,
...state.filters,
authorIds: [...state.filters.authorIds]
function ensureDraft() {
if (!state.draft) {
state.draft = { kind: state.kind, ...state.filters, authorIds: [...state.filters.authorIds] };
}
}
function applyDraft() {
state.kind = state.draft.kind;
state.filters = {
source: state.draft.source,
channelId: state.draft.channelId,
authorIds: [...state.draft.authorIds],
origin: state.draft.origin,
cachedOnly: state.draft.cachedOnly,
aspect: state.draft.aspect,
resolution: state.draft.resolution,
after: state.draft.after,
before: state.draft.before
};
renderFilterDrawer();
filterDrawer.hidden = true;
if (state.loaded) {
void loadMedia(true);
} else {
void loadStats();
}
}
function renderFilterDrawer() {
filterDrawer.replaceChildren();
const draft = state.draft;
/** Compact multi-select for the posters, shown in the bar. */
function peopleMenu() {
const wrap = el("span", "vk-media-meta");
const panel = el("div", "vk-media-meta-menu vk-media-people-menu");
panel.hidden = true;
const section = (label) => {
const wrap = el("div", "vk-media-filter-section");
wrap.append(el("div", "vk-media-status", label));
const toggle = button(
`People${state.draft.authorIds.length ? ` (${state.draft.authorIds.length})` : ""}`,
() => {
panel.hidden = !panel.hidden;
}
);
const list = 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 = state.draft.authorIds.includes(author.id);
box.addEventListener("change", () => {
state.draft.authorIds = box.checked
? [...new Set([...state.draft.authorIds, author.id])]
: state.draft.authorIds.filter(id => id !== author.id);
panel.hidden = true;
renderToolbar();
});
row.append(box, document.createTextNode(` ${author.name} (${author.count})`));
list.append(row);
}
if (!list.childElementCount) list.append(el("span", "vk-media-status", "No posters indexed yet."));
panel.append(list);
wrap.append(toggle, panel);
return wrap;
};
}
function renderToolbar() {
toolbar.replaceChildren();
if (state.view !== "media") {
toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md."));
return;
}
ensureDraft();
// 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"}`,
`vk-chip ${state.draft.kind === kind ? "vk-chip-on" : "vk-chip-off"}`,
KIND_LABELS[kind]
);
chip.type = "button";
chip.addEventListener("click", () => {
draft.kind = kind;
renderFilterDrawer();
state.draft.kind = kind;
renderToolbar();
});
chips.append(chip);
toolbar.append(chip);
}
kinds.append(chips);
filterDrawer.append(kinds);
// Source kind
const sourceSection = section("Source kind");
sourceSection.append(
toolbar.append(
filterSelect(
[
["", "Any"],
["", "Any source"],
["attachment", "Attachments"],
["sticker", "Stickers"],
["embed", "Embeds"]
],
draft.source,
state.draft.source,
value => {
draft.source = value;
state.draft.source = value;
}
)
);
filterDrawer.append(sourceSection);
// Server or DM
const originSection = section("Source");
originSection.append(
toolbar.append(
filterSelect(
[
["", "Anywhere"],
["guild", "Server channels"],
["dm", "DMs and group chats"]
],
draft.origin,
state.draft.origin,
value => {
draft.origin = value;
state.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);
toolbar.append(peopleMenu());
toolbar.append(button("Advanced search", toggleFilterDrawer));
toolbar.append(metaMenu());
toolbar.append(el("span", "vk-media-spacer"));
toolbar.append(button("Apply filters", applyDraft));
toolbar.append(button("Reset", resetMediaView));
toolbar.append(button("History", openHistoryModal));
toolbar.append(button("New scrape…", openScrapeModal));
}
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);
function toggleFilterDrawer() {
filterDrawer.hidden = !filterDrawer.hidden;
if (!filterDrawer.hidden) renderFilterDrawer();
}
const advancedBody = el("div", "vk-media-filter-section");
advancedBody.append(
/** The drawer holds the less common filters; the basic ones live in the bar. */
function renderFilterDrawer() {
ensureDraft();
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;
};
const filters = section("Filters");
filters.append(
filterSelect(
[
["", "Any ratio"],
@@ -810,9 +833,9 @@
channelSelect.addEventListener("change", () => {
draft.channelId = channelSelect.value;
});
advancedBody.append(channelSelect);
filters.append(channelSelect);
advancedBody.append(
filters.append(
el("span", "vk-media-status", "From"),
dateInput(draft.after, value => {
draft.after = value;
@@ -831,12 +854,10 @@
draft.cachedOnly = cachedBox.checked;
});
cachedRow.append(cachedBox, document.createTextNode(" Show only cached previews"));
advancedBody.append(cachedRow);
filters.append(cachedRow);
filterDrawer.append(filters);
advanced.append(advancedBody);
filterDrawer.append(advanced);
// Zoom
// Zoom applies live; it is not part of the filter draft.
const zoomSection = section("Card size");
const zoom = document.createElement("input");
zoom.type = "range";
@@ -852,34 +873,9 @@
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(
el("span", "vk-media-status", "Press Apply filters in the bar to run the search.")
);
filterDrawer.append(actions);
}
/** Card width from the zoom slider: 2/3 current, current, up to a full row. */
@@ -918,9 +914,11 @@
state.prefetched = null;
state.items = [];
state.offset = 0;
state.draft = null;
filterDrawer.hidden = true;
applyZoom();
renderGrid();
renderToolbar();
void loadStats();
}
+4 -3
View File
@@ -85,11 +85,12 @@ try {
step("broken images", broken);
check("no broken images", broken === 0, broken);
await page.getByRole("button", { name: /^People/ }).click();
step("people checkboxes", await page.locator(".vk-media-people input").count());
await page.getByRole("button", { name: /^People/ }).click();
await page.getByRole("button", { name: "Advanced search" }).click();
await page.waitForSelector(".vk-media-drawer:not([hidden])", { timeout: 5000 });
step("people checkboxes", await page.locator(".vk-media-people input").count());
await page.locator(".vk-media-advanced summary").click();
await page.locator(".vk-media-check", { hasText: "Show only cached previews" }).locator("input").check();
await page.getByRole("button", { name: "Apply filters" }).click();
await page.waitForTimeout(2500);