Move common filters to the bar and keep the drawer for advanced ones
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
+115
-117
@@ -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]
|
||||
};
|
||||
renderFilterDrawer();
|
||||
function ensureDraft() {
|
||||
if (!state.draft) {
|
||||
state.draft = { kind: state.kind, ...state.filters, authorIds: [...state.filters.authorIds] };
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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
|
||||
};
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
if (!people.childElementCount) people.append(el("span", "vk-media-status", "No posters indexed yet."));
|
||||
peopleSection.append(people);
|
||||
filterDrawer.append(peopleSection);
|
||||
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));
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user