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
|
- [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
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user