diff --git a/CURRENT.md b/CURRENT.md index 5449418..e78d5a6 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -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///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, diff --git a/docs/media.md b/docs/media.md index 9d63418..8c9b09e 100644 --- a/docs/media.md +++ b/docs/media.md @@ -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, diff --git a/docs/webui.md b/docs/webui.md index 1f61d86..5f4d34a 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -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. diff --git a/poc/public/media.js b/poc/public/media.js index ba72182..984dbc3 100644 --- a/poc/public/media.js +++ b/poc/public/media.js @@ -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(); } diff --git a/poc/test/media-ui-smoke.mjs b/poc/test/media-ui-smoke.mjs index 861df37..60578f7 100644 --- a/poc/test/media-ui-smoke.mjs +++ b/poc/test/media-ui-smoke.mjs @@ -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);