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 - [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`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `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 - [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 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, (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 ## Web UI
- **Media** tab: filter chips (All, Images, GIFs, Videos, Audio), and filters for source (any, - **Media** tab: the bar carries the common filters — kind chips, source kind, server/DM origin,
attachments, stickers, embeds), source channel, poster, aspect ratio (Vertical, 1:1, 4:3, 16:9, and the multi-select People popover. The **Advanced search** drawer has aspect ratio, resolution,
21:9, Other), resolution (SD, HD, FHD, QHD, 4K+, Unknown), and a posted-date range (from/to). 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 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 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, 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 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 opens (for example **Clean media…**) render above Settings. Mobile is out of scope for the media
feature. feature.
- Media lists scraped items. Filters live in an **Advanced search** drawer and do not apply until - Media lists scraped items. The bar shows the common filters: filetype chips, source kind
**Apply filters** is pressed: filetype chips, source kind (attachments/stickers/embeds), origin (attachments/stickers/embeds), origin (server channels or DMs), and a multi-select **People**
(server channels or DMs), a multi-select **People** list, and an **Advanced** section with aspect popover. The **Advanced search** drawer holds aspect ratio, resolution, source channel,
ratio, resolution, source channel, posted-date range, and **Show only cached previews**. posted-date range, and **Show only cached previews**, plus the card-size slider. Filters do not
**Reset** clears every filter and the zoom, unloads the grid, and brings back the initial apply until **Apply filters** is pressed; **Reset** clears every filter and the zoom, unloads the
Load media prompt. 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 - 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, 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. preloading the next page's JSON and preview images just before they are needed.
+115 -117
View File
@@ -622,22 +622,6 @@
return wrap; 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() { function defaultFilters() {
return { return {
source: "", source: "",
@@ -652,112 +636,151 @@
}; };
} }
function toggleFilterDrawer() { function ensureDraft() {
filterDrawer.hidden = !filterDrawer.hidden; if (!state.draft) {
if (!filterDrawer.hidden) { state.draft = { kind: state.kind, ...state.filters, authorIds: [...state.filters.authorIds] };
state.draft = {
kind: state.kind,
...state.filters,
authorIds: [...state.filters.authorIds]
};
renderFilterDrawer();
} }
} }
function renderFilterDrawer() { function applyDraft() {
filterDrawer.replaceChildren(); state.kind = state.draft.kind;
const draft = state.draft; state.filters = {
source: state.draft.source,
const section = (label) => { channelId: state.draft.channelId,
const wrap = el("div", "vk-media-filter-section"); authorIds: [...state.draft.authorIds],
wrap.append(el("div", "vk-media-status", label)); origin: state.draft.origin,
return wrap; cachedOnly: state.draft.cachedOnly,
aspect: state.draft.aspect,
resolution: state.draft.resolution,
after: state.draft.after,
before: state.draft.before
}; };
filterDrawer.hidden = true;
if (state.loaded) {
void loadMedia(true);
} else {
void loadStats();
}
}
/** 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 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) { for (const kind of KINDS) {
const chip = el( const chip = el(
"button", "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] KIND_LABELS[kind]
); );
chip.type = "button"; chip.type = "button";
chip.addEventListener("click", () => { chip.addEventListener("click", () => {
draft.kind = kind; state.draft.kind = kind;
renderFilterDrawer(); renderToolbar();
}); });
chips.append(chip); toolbar.append(chip);
} }
kinds.append(chips);
filterDrawer.append(kinds);
// Source kind toolbar.append(
const sourceSection = section("Source kind");
sourceSection.append(
filterSelect( filterSelect(
[ [
["", "Any"], ["", "Any source"],
["attachment", "Attachments"], ["attachment", "Attachments"],
["sticker", "Stickers"], ["sticker", "Stickers"],
["embed", "Embeds"] ["embed", "Embeds"]
], ],
draft.source, state.draft.source,
value => { value => {
draft.source = value; state.draft.source = value;
} }
) )
); );
filterDrawer.append(sourceSection);
// Server or DM toolbar.append(
const originSection = section("Source");
originSection.append(
filterSelect( filterSelect(
[ [
["", "Anywhere"], ["", "Anywhere"],
["guild", "Server channels"], ["guild", "Server channels"],
["dm", "DMs and group chats"] ["dm", "DMs and group chats"]
], ],
draft.origin, state.draft.origin,
value => { value => {
draft.origin = value; state.draft.origin = value;
} }
) )
); );
filterDrawer.append(originSection);
// People (multi-select) toolbar.append(peopleMenu());
const peopleSection = section("People"); toolbar.append(button("Advanced search", toggleFilterDrawer));
const people = el("div", "vk-media-people"); toolbar.append(metaMenu());
for (const author of state.authors ?? []) { toolbar.append(el("span", "vk-media-spacer"));
if (!author.id) continue; toolbar.append(button("Apply filters", applyDraft));
const row = el("label", "vk-media-check"); toolbar.append(button("Reset", resetMediaView));
const box = document.createElement("input"); toolbar.append(button("History", openHistoryModal));
box.type = "checkbox"; toolbar.append(button("New scrape…", openScrapeModal));
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 function toggleFilterDrawer() {
const advanced = document.createElement("details"); filterDrawer.hidden = !filterDrawer.hidden;
advanced.className = "vk-media-advanced"; if (!filterDrawer.hidden) renderFilterDrawer();
const summary = document.createElement("summary"); }
summary.textContent = "Advanced";
advanced.append(summary);
const advancedBody = el("div", "vk-media-filter-section"); /** The drawer holds the less common filters; the basic ones live in the bar. */
advancedBody.append( 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( filterSelect(
[ [
["", "Any ratio"], ["", "Any ratio"],
@@ -810,9 +833,9 @@
channelSelect.addEventListener("change", () => { channelSelect.addEventListener("change", () => {
draft.channelId = channelSelect.value; draft.channelId = channelSelect.value;
}); });
advancedBody.append(channelSelect); filters.append(channelSelect);
advancedBody.append( filters.append(
el("span", "vk-media-status", "From"), el("span", "vk-media-status", "From"),
dateInput(draft.after, value => { dateInput(draft.after, value => {
draft.after = value; draft.after = value;
@@ -831,12 +854,10 @@
draft.cachedOnly = cachedBox.checked; draft.cachedOnly = cachedBox.checked;
}); });
cachedRow.append(cachedBox, document.createTextNode(" Show only cached previews")); cachedRow.append(cachedBox, document.createTextNode(" Show only cached previews"));
advancedBody.append(cachedRow); filters.append(cachedRow);
filterDrawer.append(filters);
advanced.append(advancedBody); // Zoom applies live; it is not part of the filter draft.
filterDrawer.append(advanced);
// Zoom
const zoomSection = section("Card size"); const zoomSection = section("Card size");
const zoom = document.createElement("input"); const zoom = document.createElement("input");
zoom.type = "range"; zoom.type = "range";
@@ -852,34 +873,9 @@
zoomSection.append(zoom); zoomSection.append(zoom);
filterDrawer.append(zoomSection); filterDrawer.append(zoomSection);
// Actions filterDrawer.append(
const actions = el("div", "vk-media-toolbar"); el("span", "vk-media-status", "Press Apply filters in the bar to run the search.")
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. */ /** Card width from the zoom slider: 2/3 current, current, up to a full row. */
@@ -918,9 +914,11 @@
state.prefetched = null; state.prefetched = null;
state.items = []; state.items = [];
state.offset = 0; state.offset = 0;
state.draft = null;
filterDrawer.hidden = true; filterDrawer.hidden = true;
applyZoom(); applyZoom();
renderGrid(); renderGrid();
renderToolbar();
void loadStats(); void loadStats();
} }
+4 -3
View File
@@ -85,11 +85,12 @@ try {
step("broken images", broken); step("broken images", broken);
check("no broken images", broken === 0, 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.getByRole("button", { name: "Advanced search" }).click();
await page.waitForSelector(".vk-media-drawer:not([hidden])", { timeout: 5000 }); 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.locator(".vk-media-check", { hasText: "Show only cached previews" }).locator("input").check();
await page.getByRole("button", { name: "Apply filters" }).click(); await page.getByRole("button", { name: "Apply filters" }).click();
await page.waitForTimeout(2500); await page.waitForTimeout(2500);