From f53bfbeccf864993c70431261877c47f4022465d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:41:10 +0000 Subject: [PATCH] Replace search filter popover with responsive modal Replaces the existing search filter popover with a new responsive modal component to improve UI/UX on both desktop and mobile devices. - Adds `components/search-filter-modal.html` implementing a responsive grid layout. - Adds `js/ui/components/SearchFilterModal.js` to manage filter state and modal interactions. - Updates `js/index.js` to bootstrap the new modal and bind trigger events. - Removes obsolete `js/ui/components/SearchFiltersPopover.js`. - Keeps advanced filters hidden by default behind a toggle. Signed-off-by: Jules --- components/search-filter-modal.html | 174 +++++ js/index.js | 27 +- js/ui/components/SearchFilterModal.js | 416 +++++++++++ js/ui/components/SearchFiltersPopover.js | 869 ----------------------- server.log | 1 - 5 files changed, 598 insertions(+), 889 deletions(-) create mode 100644 components/search-filter-modal.html create mode 100644 js/ui/components/SearchFilterModal.js delete mode 100644 js/ui/components/SearchFiltersPopover.js delete mode 100644 server.log diff --git a/components/search-filter-modal.html b/components/search-filter-modal.html new file mode 100644 index 00000000..64651640 --- /dev/null +++ b/components/search-filter-modal.html @@ -0,0 +1,174 @@ + diff --git a/js/index.js b/js/index.js index 18aa6d1a..c15bf1ce 100644 --- a/js/index.js +++ b/js/index.js @@ -52,7 +52,7 @@ import { nostrClient } from "./nostrClientFacade.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; import createApplication from "./bootstrap.js"; -import attachSearchFiltersPopover from "./ui/components/SearchFiltersPopover.js"; +import SearchFilterModal from "./ui/components/SearchFilterModal.js"; validateInstanceConfig(); @@ -615,6 +615,7 @@ async function bootstrapInterface() { loadModal("components/general-feedback-form.html"), loadModal("components/feature-request-form.html"), loadModal("components/bug-fix-form.html"), + loadModal("components/search-filter-modal.html"), ]); devLogger.log("Modals loaded."); @@ -1197,26 +1198,14 @@ async function bootstrapInterface() { }); } + SearchFilterModal.init(); + const searchFilterButtons = document.querySelectorAll(".header-search__filter"); - const applySearchFilters = (filters) => { - const currentState = getSearchFilterState(); - const nextState = { - text: currentState.text || "", - filters, - }; - setSearchFilterState(nextState); - setHashView(buildSearchHashFromState(nextState)); - }; - const clearSearchFilters = () => { - resetSearchFilters(); - const nextState = getSearchFilterState(); - setHashView(buildSearchHashFromState(nextState)); - }; searchFilterButtons.forEach((button) => { - attachSearchFiltersPopover(button, { - getState: getSearchFilterState, - onApply: applySearchFilters, - onReset: clearSearchFilters, + button.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + SearchFilterModal.open(); }); }); diff --git a/js/ui/components/SearchFilterModal.js b/js/ui/components/SearchFilterModal.js new file mode 100644 index 00000000..e69048af --- /dev/null +++ b/js/ui/components/SearchFilterModal.js @@ -0,0 +1,416 @@ +import { + prepareStaticModal, + openStaticModal, + closeStaticModal, +} from "./staticModalAccessibility.js"; +import { + getSearchFilterState, + setSearchFilterState, + buildSearchHashFromState, + resetSearchFilters, +} from "../../search/searchFilterState.js"; +import { DEFAULT_FILTERS } from "../../search/searchFilters.js"; +import { setHashView } from "../../hashView.js"; + +const MODAL_ID = "searchFilterModal"; + +let isInitialized = false; + +// DOM Elements +let modal; +let form; +let dateStartInput; +let dateEndInput; +let sortSelect; +let authorInput; +let nsfwToggle; +let advancedToggleBtn; +let advancedPanel; +let advancedChevron; +let tagsInput; +let durationShortCheckbox; +let durationMediumCheckbox; +let durationLongCheckbox; +let dateChipsContainer; +let tagChipsContainer; +let hasChipsContainer; + +function getElement(id) { + return document.getElementById(id); +} + +function init() { + if (isInitialized) return; + + modal = getElement(MODAL_ID); + if (!modal) return; // Modal HTML not loaded yet + + form = getElement("searchFilterForm"); + dateStartInput = getElement("searchFilterDateStart"); + dateEndInput = getElement("searchFilterDateEnd"); + sortSelect = getElement("searchFilterSort"); + authorInput = getElement("searchFilterAuthor"); + nsfwToggle = getElement("searchFilterNsfwToggle"); + + advancedToggleBtn = getElement("searchFilterAdvancedToggle"); + advancedPanel = getElement("searchFilterAdvancedPanel"); + advancedChevron = advancedToggleBtn.querySelector("span:last-child"); // The chevron + + tagsInput = getElement("searchFilterTags"); + durationShortCheckbox = getElement("searchFilterDurationShort"); + durationMediumCheckbox = getElement("searchFilterDurationMedium"); + durationLongCheckbox = getElement("searchFilterDurationLong"); + + dateChipsContainer = getElement("searchFilterDateChips"); + tagChipsContainer = getElement("searchFilterTagChips"); + hasChipsContainer = getElement("searchFilterHasChips"); + + // Event Listeners + + // Close + const closeBtn = getElement("closeSearchFilterModal"); + if (closeBtn) { + closeBtn.addEventListener("click", close); + } + + // Apply + const applyBtn = getElement("searchFilterApplyBtn"); + if (applyBtn) { + applyBtn.addEventListener("click", () => { + applyFilters(); + close(); + }); + } + + // Reset + const resetBtn = getElement("searchFilterResetBtn"); + if (resetBtn) { + resetBtn.addEventListener("click", () => { + syncStateToControls(DEFAULT_FILTERS); + }); + } + + // Advanced Toggle + if (advancedToggleBtn) { + advancedToggleBtn.addEventListener("click", () => toggleAdvanced()); + } + + // Date Chips + if (dateChipsContainer) { + dateChipsContainer.addEventListener("click", (e) => { + const btn = e.target.closest("button"); + if (!btn) return; + + const days = btn.dataset.days; + handleDateChip(days); + updateChipVisuals(dateChipsContainer, btn); + }); + } + + // Tag Chips + if (tagChipsContainer) { + tagChipsContainer.addEventListener("click", (e) => { + const btn = e.target.closest("button"); + if (!btn) return; + toggleChipState(btn); + }); + } + + // Has Chips + if (hasChipsContainer) { + hasChipsContainer.addEventListener("click", (e) => { + const btn = e.target.closest("button"); + if (!btn) return; + toggleChipState(btn); + }); + } + + // NSFW Toggle + if (nsfwToggle) { + nsfwToggle.addEventListener("click", () => { + const isChecked = nsfwToggle.getAttribute("aria-checked") === "true"; + nsfwToggle.setAttribute("aria-checked", !isChecked); + nsfwToggle.classList.toggle("is-on", !isChecked); + }); + } + + prepareStaticModal({ id: MODAL_ID }); + isInitialized = true; +} + +function open() { + if (!isInitialized) init(); + + const currentState = getSearchFilterState(); + syncStateToControls(currentState.filters || DEFAULT_FILTERS); + + openStaticModal(modal); +} + +function close() { + closeStaticModal(modal); +} + +function toggleAdvanced(forceState = null) { + const isCurrentlyExpanded = advancedToggleBtn.getAttribute("aria-expanded") === "true"; + const newState = forceState !== null ? forceState : !isCurrentlyExpanded; + + advancedToggleBtn.setAttribute("aria-expanded", newState); + + if (newState) { + advancedPanel.classList.remove("hidden"); + advancedChevron.style.transform = "rotate(180deg)"; + } else { + advancedPanel.classList.add("hidden"); + advancedChevron.style.transform = "rotate(0deg)"; + } +} + +// Helpers for Date +const parseDateInputValue = (value) => { + if (!value) return null; + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return null; + return Math.floor(parsed / 1000); +}; + +const formatDateInputValue = (timestampSeconds) => { + if (!Number.isFinite(timestampSeconds)) return ""; + const date = new Date(timestampSeconds * 1000); + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +function handleDateChip(days) { + if (days === "all") { + dateStartInput.value = ""; + dateEndInput.value = ""; + return; + } + + const numDays = parseInt(days, 10); + if (isNaN(numDays)) return; + + const now = new Date(); + const start = new Date(); + start.setDate(now.getDate() - numDays); + + dateStartInput.value = formatDateInputValue(Math.floor(start.getTime() / 1000)); + dateEndInput.value = formatDateInputValue(Math.floor(now.getTime() / 1000)); +} + +// Helpers for Chips +function toggleChipState(btn) { + const isPressed = btn.getAttribute("aria-pressed") === "true"; + btn.setAttribute("aria-pressed", !isPressed); + btn.dataset.state = !isPressed ? "on" : "off"; +} + +function updateChipVisuals(container, activeBtn) { + // For exclusive selection (like date ranges typically are treated in this UI) + const buttons = container.querySelectorAll("button"); + buttons.forEach(btn => { + const isActive = btn === activeBtn; + btn.setAttribute("aria-pressed", isActive); + btn.dataset.state = isActive ? "on" : "off"; + }); +} + +function syncStateToControls(filters) { + const safeFilters = filters || DEFAULT_FILTERS; + + // Date + dateStartInput.value = formatDateInputValue(safeFilters.dateRange?.after); + dateEndInput.value = formatDateInputValue(safeFilters.dateRange?.before); + + // Logic to highlight correct date chip if it matches + // (Simplified: just highlight 'All Time' if no dates, otherwise clear chips) + const hasDateRange = Number.isFinite(safeFilters.dateRange?.after) || Number.isFinite(safeFilters.dateRange?.before); + const dateBtns = dateChipsContainer.querySelectorAll("button"); + dateBtns.forEach(btn => { + if (btn.dataset.days === "all") { + const isActive = !hasDateRange; + btn.setAttribute("aria-pressed", isActive); + btn.dataset.state = isActive ? "on" : "off"; + } else { + // We could try to calculate if range matches 24h/7d etc, but for now just clear them if custom dates are set + btn.setAttribute("aria-pressed", "false"); + btn.dataset.state = "off"; + } + }); + + // Sort + sortSelect.value = safeFilters.sort || "relevance"; + + // Author + authorInput.value = safeFilters.authorPubkeys?.join(", ") || ""; + + // Tags + tagsInput.value = ""; + // We separate tags that are in chips vs typed + const activeTags = new Set(safeFilters.tags || []); + const tagBtns = tagChipsContainer.querySelectorAll("button"); + const chippedTags = new Set(); + + tagBtns.forEach(btn => { + const tag = btn.dataset.tag; + chippedTags.add(tag); + const isActive = activeTags.has(tag); + btn.setAttribute("aria-pressed", isActive); + btn.dataset.state = isActive ? "on" : "off"; + }); + + // Remaining tags go to input + const otherTags = (safeFilters.tags || []).filter(t => !chippedTags.has(t)); + if (otherTags.length) { + tagsInput.value = otherTags.join(", "); + } + + // Duration + const minSec = safeFilters.duration?.minSeconds; + const maxSec = safeFilters.duration?.maxSeconds; + + durationShortCheckbox.checked = false; + durationMediumCheckbox.checked = false; + durationLongCheckbox.checked = false; + + if (minSec === null && maxSec === 300) durationShortCheckbox.checked = true; + if (minSec === 300 && maxSec === 1200) durationMediumCheckbox.checked = true; + if (minSec === 1200 && maxSec === null) durationLongCheckbox.checked = true; + + // Has + const hasBtns = hasChipsContainer.querySelectorAll("button"); + hasBtns.forEach(btn => { + const type = btn.dataset.has; + let isActive = false; + if (type === "magnet") isActive = safeFilters.hasMagnet === true; + if (type === "url") isActive = safeFilters.hasUrl === true; + + btn.setAttribute("aria-pressed", isActive); + btn.dataset.state = isActive ? "on" : "off"; + }); + + // NSFW + const nsfwIsOn = safeFilters.nsfw === "true" || safeFilters.nsfw === "only"; + nsfwToggle.setAttribute("aria-checked", nsfwIsOn); + nsfwToggle.classList.toggle("is-on", nsfwIsOn); + + // Auto-expand Advanced if needed + const hasAdvancedFilters = + (safeFilters.tags && safeFilters.tags.length > 0) || + Number.isFinite(minSec) || + Number.isFinite(maxSec) || + safeFilters.hasMagnet === true || + safeFilters.hasUrl === true; + // Note: NSFW is moved to main view in our design, so it doesn't trigger advanced + + toggleAdvanced(hasAdvancedFilters); +} + +function applyFilters() { + const filters = { + ...DEFAULT_FILTERS, + dateRange: { ...DEFAULT_FILTERS.dateRange }, + duration: { ...DEFAULT_FILTERS.duration }, + }; + + // Date + filters.dateRange.after = parseDateInputValue(dateStartInput.value); + filters.dateRange.before = parseDateInputValue(dateEndInput.value); + + // Sort + filters.sort = sortSelect.value; + + // Author + const authors = authorInput.value.split(/[, ]+/).map(s => s.trim()).filter(Boolean); + filters.authorPubkeys = authors; + + // Tags + const selectedTags = new Set(); + const tagBtns = tagChipsContainer.querySelectorAll("button[aria-pressed='true']"); + tagBtns.forEach(btn => selectedTags.add(btn.dataset.tag)); + + const inputTags = tagsInput.value.split(/[, ]+/).map(s => s.trim().replace(/^#/, "")).filter(Boolean); + inputTags.forEach(t => selectedTags.add(t)); + + filters.tags = Array.from(selectedTags); + + // Duration + // (Simplified logic: taking the last checked one or union? The original popover logic allowed multiple? + // Let's look at the original logic in SearchFiltersPopover.js: + // It collected ranges into an array and took min of mins and max of maxes. + // Effectively treating them as OR if multiple selected (e.g. Short OR Medium -> 0 to 1200). + const ranges = []; + if (durationShortCheckbox.checked) ranges.push({ min: null, max: 300 }); + if (durationMediumCheckbox.checked) ranges.push({ min: 300, max: 1200 }); + if (durationLongCheckbox.checked) ranges.push({ min: 1200, max: null }); + + if (ranges.length) { + const mins = ranges.map(r => r.min).filter(v => v !== null); + const maxes = ranges.map(r => r.max).filter(v => v !== null); + + // If any range has min=null (Short), the overall min is null. + // If any range has max=null (Long), the overall max is null. + // Actually, logic: + // Short (0-300) + Medium (300-1200) = 0-1200. + // Short + Long = 0-300 AND 1200+. Currently filter schema supports min/max seconds as a single range. + // It doesn't support disjoint ranges. + // The original logic: + // filters.duration.minSeconds = mins.length ? Math.min(...mins) : null; + // filters.duration.maxSeconds = maxes.length ? Math.max(...maxes) : null; + // This implies union of ranges IF they are contiguous. If disjoint (Short + Long), it creates 0 to infinity (min=0, max=null is wrong, max of maxes? Long has max=null). + // Let's stick to original logic. + // min: Math.min of all explicit mins. If Short is there, min is null. + // max: Math.max of all explicit maxes. If Long is there, max is null. + + const hasNullMin = ranges.some(r => r.min === null); + const hasNullMax = ranges.some(r => r.max === null); + + filters.duration.minSeconds = hasNullMin ? null : Math.min(...mins); + filters.duration.maxSeconds = hasNullMax ? null : Math.max(...maxes); + } + + // Has + const hasBtns = hasChipsContainer.querySelectorAll("button[aria-pressed='true']"); + hasBtns.forEach(btn => { + if (btn.dataset.has === "magnet") filters.hasMagnet = true; + if (btn.dataset.has === "url") filters.hasUrl = true; + }); + + // NSFW + const nsfwChecked = nsfwToggle.getAttribute("aria-checked") === "true"; + if (nsfwChecked) { + filters.nsfw = "true"; + } else { + filters.nsfw = "any"; // or "false"? DEFAULT is "any". + // Wait, original popover logic: + // if (nsfwSwitch?.getAttribute("aria-checked") === "true") { filters.nsfw = "true"; } + // else { ... it defaults to cloneDefaultFilters which is 'any' } + // Wait, DEFAULT_FILTERS.nsfw is 'any'. + // If switch is off, it remains 'any' (showing sensitive content if user allows?). + // Usually NSFW toggle means "Show me NSFW". + // If off, it might mean "Filter out NSFW" or "Show Any". + // Let's assume 'any' means standard behavior (hide usually unless safe mode off?). + // Actually searchFilters.js says: + // filters.nsfw = "any" (default). + // So if I don't touch it, it's 'any'. + // If I toggle it ON, it becomes 'true'. + } + + // Update State + const currentState = getSearchFilterState(); + const nextState = { + text: currentState.text || "", + filters, + }; + setSearchFilterState(nextState); + setHashView(buildSearchHashFromState(nextState)); +} + +export default { + init, + open, + close +}; diff --git a/js/ui/components/SearchFiltersPopover.js b/js/ui/components/SearchFiltersPopover.js deleted file mode 100644 index 5ad2a29e..00000000 --- a/js/ui/components/SearchFiltersPopover.js +++ /dev/null @@ -1,869 +0,0 @@ -import createPopover from "../overlay/popoverEngine.js"; -import { - DEFAULT_FILTERS, - parseFilterQuery, -} from "../../search/searchFilters.js"; -import { subscribeSearchFacetCounts } from "../../search/searchFacetState.js"; -import { FEATURE_SEARCH_FILTERS } from "../../constants.js"; - -const FOCUSABLE_SELECTOR = - "a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])"; - -const SEARCH_PRESET_STORAGE_KEY = "bitvid:searchPresets"; -let searchFilterPanelIdCounter = 0; - -function resolveDocument(documentRef) { - if (documentRef && documentRef.nodeType === 9) { - return documentRef; - } - if (typeof document !== "undefined" && document?.nodeType === 9) { - return document; - } - return null; -} - -function buildFilterId(prefix) { - searchFilterPanelIdCounter += 1; - return `${prefix}-${searchFilterPanelIdCounter}`; -} - -function createElement(doc, tagName, { className, text, attrs } = {}) { - const el = doc.createElement(tagName); - if (className) { - el.className = className; - } - if (text) { - el.textContent = text; - } - if (attrs) { - Object.entries(attrs).forEach(([key, value]) => { - if (value === undefined || value === null || value === "") { - return; - } - el.setAttribute(key, value); - }); - } - return el; -} - -function collectFocusable(panel) { - if (!panel) { - return []; - } - return Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - (el) => !el.hasAttribute("disabled") && el.getAttribute("aria-hidden") !== "true", - ); -} - -function isEditableTarget(target) { - if (!target || target.nodeType !== 1) { - return false; - } - const element = target; - const tagName = element.tagName?.toLowerCase?.() || ""; - if (tagName === "input" || tagName === "textarea" || tagName === "select") { - return true; - } - if (element.isContentEditable) { - return true; - } - if (typeof element.closest === "function") { - return Boolean(element.closest("input, textarea, select, [contenteditable=\"true\"]")); - } - return false; -} - -function isElementVisible(element) { - if (!element) { - return false; - } - if (element.getClientRects().length === 0) { - return false; - } - return true; -} - -function enableArrowNavigation(listElement, { selector = "button" } = {}) { - if (!listElement) { - return () => {}; - } - - const handler = (event) => { - if ( - event.key !== "ArrowLeft" && - event.key !== "ArrowRight" && - event.key !== "ArrowUp" && - event.key !== "ArrowDown" - ) { - return; - } - const target = event.target; - if (!target || !listElement.contains(target)) { - return; - } - const items = Array.from(listElement.querySelectorAll(selector)).filter( - (item) => - item instanceof HTMLElement && - !item.hasAttribute("disabled") && - item.getAttribute("aria-hidden") !== "true", - ); - if (!items.length) { - return; - } - const index = items.indexOf(target); - if (index < 0) { - return; - } - event.preventDefault(); - const direction = - event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1; - const nextIndex = (index + direction + items.length) % items.length; - const nextItem = items[nextIndex]; - if (nextItem && typeof nextItem.focus === "function") { - nextItem.focus(); - } - }; - - listElement.addEventListener("keydown", handler); - return () => listElement.removeEventListener("keydown", handler); -} - -function trapFocus(panel) { - if (!panel) { - return () => {}; - } - - const handler = (event) => { - if (event.key !== "Tab") { - return; - } - const focusable = collectFocusable(panel); - if (!focusable.length) { - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = panel.ownerDocument.activeElement; - const isShift = event.shiftKey; - - if (isShift) { - if (active === first || !panel.contains(active)) { - event.preventDefault(); - last.focus(); - } - return; - } - - if (active === last) { - event.preventDefault(); - first.focus(); - } - }; - - panel.addEventListener("keydown", handler); - return () => panel.removeEventListener("keydown", handler); -} - -function toggleChip(button) { - if (!button) { - return; - } - const isPressed = button.getAttribute("aria-pressed") === "true"; - const nextState = isPressed ? "false" : "true"; - button.setAttribute("aria-pressed", nextState); - button.dataset.state = nextState === "true" ? "on" : "off"; -} - -function toggleSwitch(button) { - if (!button) { - return; - } - const isChecked = button.getAttribute("aria-checked") === "true"; - const nextState = isChecked ? "false" : "true"; - button.setAttribute("aria-checked", nextState); - button.classList.toggle("is-on", nextState === "true"); -} - -function buildLabeledInput(doc, { id, label, type = "text", placeholder } = {}) { - const wrapper = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - const labelEl = createElement(doc, "label", { - className: "text-xs font-semibold uppercase tracking-wide text-muted", - text: label, - attrs: { for: id }, - }); - const inputEl = createElement(doc, "input", { - className: "input w-full", - attrs: { id, type, placeholder }, - }); - wrapper.append(labelEl, inputEl); - return { wrapper, inputEl }; -} - -const parseDateInputValue = (value) => { - if (!value) return null; - const parsed = Date.parse(value); - if (Number.isNaN(parsed)) { - return null; - } - return Math.floor(parsed / 1000); -}; - -const formatDateInputValue = (timestampSeconds) => { - if (!Number.isFinite(timestampSeconds)) { - return ""; - } - const date = new Date(timestampSeconds * 1000); - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -const normalizeTagValue = (value) => - value - .split(/[, ]+/) - .map((tag) => tag.trim().replace(/^#/, "")) - .filter(Boolean); - -export function attachSearchFiltersPopover(triggerElement, options = {}) { - if (!triggerElement) { - return null; - } - - const doc = resolveDocument(options.document || triggerElement.ownerDocument); - if (!doc) { - return null; - } - - let controlState = null; - let suggestedFacetRenderer = null; - let suggestedFacetUnsubscribe = null; - - const render = ({ container, close }) => { - const headingId = buildFilterId("search-filters-heading"); - const advancedPanelId = buildFilterId("search-filters-advanced"); - const panel = createElement(doc, "div", { - className: - "popover-panel search-filters-panel w-80 max-w-full space-y-5 md:w-96", - attrs: { "aria-labelledby": headingId }, - }); - panel.dataset.popoverVariant = "filters"; - - const header = createElement(doc, "div", { - className: "flex items-start justify-between gap-3", - }); - const heading = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - heading.append( - createElement(doc, "p", { - className: "text-xs font-semibold uppercase tracking-wide text-muted", - text: "Refine results", - }), - createElement(doc, "h3", { - className: "text-base font-semibold text-text-strong", - text: "Search filters", - attrs: { id: headingId }, - }), - ); - const closeButton = createElement(doc, "button", { - className: "btn-ghost h-9 w-9 shrink-0", - text: "✕", - attrs: { type: "button", "aria-label": "Close filters" }, - }); - closeButton.addEventListener("click", () => close()); - header.append(heading, closeButton); - panel.appendChild(header); - - const dateSection = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - dateSection.appendChild( - createElement(doc, "p", { - className: "text-xs font-semibold uppercase tracking-wide text-muted", - text: "Date range", - }), - ); - const chipRow = createElement(doc, "div", { - className: "flex flex-wrap gap-2", - }); - enableArrowNavigation(chipRow); - const dateChips = []; - const dateChipValues = [ - { label: "24h", days: 1 }, - { label: "7d", days: 7 }, - { label: "30d", days: 30 }, - { label: "90d", days: 90 }, - { label: "All time", days: null }, - ]; - dateChipValues.forEach(({ label, days }) => { - const chip = createElement(doc, "button", { - className: - "search-filter-chip focus-ring", - text: label, - attrs: { type: "button", "aria-pressed": "false" }, - }); - chip.dataset.state = "off"; - chip.addEventListener("click", () => { - dateChips.forEach((entry) => { - entry.button.setAttribute("aria-pressed", "false"); - entry.button.dataset.state = "off"; - }); - chip.setAttribute("aria-pressed", "true"); - chip.dataset.state = "on"; - if (days === null) { - startInputEl.value = ""; - endInputEl.value = ""; - return; - } - const now = new Date(); - const start = new Date(); - start.setDate(now.getDate() - days); - startInputEl.value = formatDateInputValue( - Math.floor(start.getTime() / 1000), - ); - endInputEl.value = formatDateInputValue( - Math.floor(now.getTime() / 1000), - ); - }); - dateChips.push({ button: chip, days }); - chipRow.appendChild(chip); - }); - const dateInputs = createElement(doc, "div", { - className: "grid gap-3 sm:grid-cols-2", - }); - const { wrapper: startWrapper, inputEl: startInputEl } = buildLabeledInput( - doc, - { - id: "search-filters-date-start", - label: "Start date", - type: "date", - }, - ); - const { wrapper: endWrapper, inputEl: endInputEl } = buildLabeledInput( - doc, - { - id: "search-filters-date-end", - label: "End date", - type: "date", - }, - ); - dateInputs.append(startWrapper, endWrapper); - dateSection.append(chipRow, dateInputs); - panel.appendChild(dateSection); - - const sortSection = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - const sortHeadingId = buildFilterId("search-filters-sort-heading"); - const sortDescriptionId = buildFilterId("search-filters-sort-description"); - sortSection.appendChild( - createElement(doc, "p", { - className: "text-xs font-semibold uppercase tracking-wide text-muted", - text: "Sort", - attrs: { id: sortHeadingId }, - }), - ); - sortSection.appendChild( - createElement(doc, "p", { - className: "text-xs text-muted", - text: "Choose the order for search results.", - attrs: { id: sortDescriptionId }, - }), - ); - const sortSelect = createElement(doc, "select", { - className: "select w-full", - attrs: { - "aria-labelledby": sortHeadingId, - "aria-describedby": sortDescriptionId, - }, - }); - [ - { label: "Most recent", value: "recent" }, - { label: "Most viewed", value: "views" }, - { label: "Trending", value: "trending" }, - { label: "Longest", value: "longest" }, - ].forEach(({ label, value }) => { - const option = createElement(doc, "option", { text: label }); - option.value = value; - sortSelect.appendChild(option); - }); - sortSection.appendChild(sortSelect); - panel.appendChild(sortSection); - - const { wrapper: authorWrapper, inputEl: authorInput } = buildLabeledInput( - doc, - { - id: "search-filters-author", - label: "Author", - placeholder: "npub1...", - }, - ); - panel.appendChild(authorWrapper); - - let advancedToggleButton = null; - let advancedChevron = null; - let advancedContent = null; - let setAdvancedOpen = null; - let tagSection = null; - let tagInput = null; - let tagChips = null; - let durationSection = null; - let durationChecks = null; - let hasSection = null; - let hasChips = null; - let toggleSection = null; - let nsfwToggle = null; - let followedToggle = null; - - if (FEATURE_SEARCH_FILTERS) { - const advancedSection = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - advancedToggleButton = createElement(doc, "button", { - className: - "btn-ghost flex w-full items-center justify-between text-sm font-medium", - attrs: { - type: "button", - "aria-expanded": "false", - "aria-controls": advancedPanelId, - }, - }); - advancedChevron = createElement(doc, "span", { - className: "text-muted", - text: "▾", - attrs: { "aria-hidden": "true" }, - }); - advancedToggleButton.append( - createElement(doc, "span", { text: "Advanced filters" }), - advancedChevron, - ); - advancedContent = createElement(doc, "div", { - className: "search-filters-advanced hidden grid gap-5 md:grid-cols-2", - attrs: { id: advancedPanelId }, - }); - - setAdvancedOpen = (isOpen) => { - if (!advancedContent || !advancedToggleButton) { - return; - } - advancedContent.classList.toggle("hidden", !isOpen); - advancedToggleButton.setAttribute( - "aria-expanded", - isOpen ? "true" : "false", - ); - if (advancedChevron) { - advancedChevron.textContent = isOpen ? "▴" : "▾"; - } - panel.classList.toggle("is-advanced-open", isOpen); - }; - - advancedToggleButton.addEventListener("click", () => { - const isExpanded = - advancedToggleButton.getAttribute("aria-expanded") === "true"; - setAdvancedOpen(!isExpanded); - }); - - tagSection = createElement(doc, "div", { - className: "bv-stack bv-stack--tight md:col-span-2", - }); - tagSection.appendChild( - createElement(doc, "p", { - className: "text-xs font-semibold uppercase tracking-wide text-muted", - text: "Tags", - }), - ); - tagInput = createElement(doc, "input", { - className: "input w-full", - attrs: { type: "text", placeholder: "Add tag or keyword" }, - }); - const tagList = createElement(doc, "div", { - className: "flex flex-wrap gap-2", - }); - enableArrowNavigation(tagList); - tagChips = new Map(); - ["nostr", "music", "live"].forEach((tag) => { - const chip = createElement(doc, "button", { - className: "search-filter-chip focus-ring", - text: `#${tag}`, - attrs: { type: "button", "aria-pressed": "true" }, - }); - chip.dataset.state = "on"; - chip.addEventListener("click", () => toggleChip(chip)); - tagChips.set(tag, chip); - tagList.appendChild(chip); - }); - tagSection.append(tagInput, tagList); - advancedContent.appendChild(tagSection); - - durationSection = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - durationSection.appendChild( - createElement(doc, "p", { - className: "text-xs font-semibold uppercase tracking-wide text-muted", - text: "Duration", - }), - ); - const durationGrid = createElement(doc, "div", { - className: "grid gap-2", - }); - durationChecks = {}; - [ - { id: "duration-short", label: "Short (under 5 min)" }, - { id: "duration-medium", label: "Medium (5-20 min)" }, - { id: "duration-long", label: "Long (20+ min)" }, - { id: "duration-live", label: "Live sessions" }, - ].forEach(({ id, label }) => { - const row = createElement(doc, "label", { - className: "flex items-center gap-3 text-sm text-text", - attrs: { for: id }, - }); - const checkbox = createElement(doc, "input", { - className: "checkbox", - attrs: { id, type: "checkbox" }, - }); - durationChecks[id] = checkbox; - row.append(checkbox, createElement(doc, "span", { text: label })); - durationGrid.appendChild(row); - }); - durationSection.appendChild(durationGrid); - advancedContent.appendChild(durationSection); - - hasSection = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - hasSection.appendChild( - createElement(doc, "p", { - className: "text-xs font-semibold uppercase tracking-wide text-muted", - text: "Has", - }), - ); - const hasChipRow = createElement(doc, "div", { - className: "flex flex-wrap gap-2", - }); - enableArrowNavigation(hasChipRow); - hasChips = new Map(); - [ - { key: "magnet", label: "Magnet" }, - { key: "url", label: "URL" }, - { key: "transcript", label: "Transcript" }, - ].forEach(({ key, label }) => { - const chip = createElement(doc, "button", { - className: "search-filter-chip focus-ring", - text: label, - attrs: { type: "button", "aria-pressed": "false" }, - }); - chip.dataset.state = "off"; - chip.addEventListener("click", () => toggleChip(chip)); - hasChips.set(key, chip); - hasChipRow.appendChild(chip); - }); - hasSection.appendChild(hasChipRow); - advancedContent.appendChild(hasSection); - - toggleSection = createElement(doc, "div", { - className: "grid gap-3 md:col-span-2", - }); - const toggleRow = (label, id) => { - const row = createElement(doc, "div", { - className: "flex items-center justify-between gap-3", - }); - const text = createElement(doc, "div", { - className: "bv-stack bv-stack--tight", - }); - text.append( - createElement(doc, "p", { - className: "text-sm font-medium text-text", - text: label, - }), - createElement(doc, "p", { - className: "text-xs text-muted", - text: `Toggle ${label.toLowerCase()}`, - }), - ); - const toggle = createElement(doc, "button", { - className: "switch", - attrs: { type: "button", role: "switch", "aria-checked": "false", id }, - }); - toggle.addEventListener("click", () => toggleSwitch(toggle)); - row.append(text, toggle); - return row; - }; - nsfwToggle = toggleRow("NSFW content", "search-filters-nsfw"); - followedToggle = toggleRow("Followed only", "search-filters-followed"); - toggleSection.append(nsfwToggle, followedToggle); - advancedContent.appendChild(toggleSection); - - advancedSection.append(advancedToggleButton, advancedContent); - panel.appendChild(advancedSection); - } - - const footer = createElement(doc, "div", { - className: "flex items-center justify-between gap-3 border-t border-border/60 pt-4", - }); - const resetButton = createElement(doc, "button", { - className: "btn-ghost text-sm", - text: "Reset", - attrs: { type: "button" }, - }); - const applyButton = createElement(doc, "button", { - className: "btn text-sm", - text: "Apply filters", - attrs: { type: "button" }, - }); - const applyStateToControls = (filters = DEFAULT_FILTERS) => { - const safeFilters = filters || DEFAULT_FILTERS; - startInputEl.value = formatDateInputValue(safeFilters.dateRange?.after); - endInputEl.value = formatDateInputValue(safeFilters.dateRange?.before); - const hasDateRange = - Number.isFinite(safeFilters.dateRange?.after) || - Number.isFinite(safeFilters.dateRange?.before); - dateChips.forEach(({ button, days }) => { - const isAllTime = days === null && !hasDateRange; - button.setAttribute("aria-pressed", isAllTime ? "true" : "false"); - button.dataset.state = isAllTime ? "on" : "off"; - }); - authorInput.value = safeFilters.authorPubkeys?.join(", ") || ""; - if (tagInput && tagChips) { - tagInput.value = ""; - tagChips.forEach((chip, tag) => { - const isActive = safeFilters.tags?.includes(tag); - chip.setAttribute("aria-pressed", isActive ? "true" : "false"); - chip.dataset.state = isActive ? "on" : "off"; - }); - const extraTags = (safeFilters.tags || []).filter( - (tag) => !tagChips.has(tag), - ); - if (extraTags.length) { - tagInput.value = extraTags.join(", "); - } - } - if (durationChecks) { - Object.values(durationChecks).forEach((checkbox) => { - checkbox.checked = false; - }); - const minSeconds = safeFilters.duration?.minSeconds ?? null; - const maxSeconds = safeFilters.duration?.maxSeconds ?? null; - if (minSeconds === null && maxSeconds === 300) { - durationChecks["duration-short"].checked = true; - } else if (minSeconds === 300 && maxSeconds === 1200) { - durationChecks["duration-medium"].checked = true; - } else if (minSeconds === 1200 && maxSeconds === null) { - durationChecks["duration-long"].checked = true; - } - } - const nsfwSwitch = nsfwToggle?.querySelector(".switch"); - if (nsfwSwitch) { - const isOn = safeFilters.nsfw === "true" || safeFilters.nsfw === "only"; - nsfwSwitch.setAttribute("aria-checked", isOn ? "true" : "false"); - nsfwSwitch.classList.toggle("is-on", isOn); - } - const followedSwitch = followedToggle?.querySelector(".switch"); - if (followedSwitch) { - followedSwitch.setAttribute("aria-checked", "false"); - followedSwitch.classList.remove("is-on"); - } - if (sortSelect) { - sortSelect.selectedIndex = 0; - } - if (setAdvancedOpen) { - const hasAdvanced = - (safeFilters.tags && safeFilters.tags.length > 0) || - Number.isFinite(safeFilters.duration?.minSeconds) || - Number.isFinite(safeFilters.duration?.maxSeconds) || - safeFilters.hasMagnet === true || - safeFilters.hasUrl === true || - (safeFilters.nsfw && safeFilters.nsfw !== "any"); - setAdvancedOpen(hasAdvanced); - } - }; - - const buildFiltersFromControls = () => { - const filters = { - ...DEFAULT_FILTERS, - dateRange: { ...DEFAULT_FILTERS.dateRange }, - duration: { ...DEFAULT_FILTERS.duration }, - }; - filters.dateRange.after = parseDateInputValue(startInputEl.value); - filters.dateRange.before = parseDateInputValue(endInputEl.value); - const authorValues = authorInput.value - .split(/[, ]+/) - .map((value) => value.trim()) - .filter(Boolean); - filters.authorPubkeys = authorValues; - if (tagChips && tagInput) { - const selectedTags = new Set(); - tagChips.forEach((chip, tag) => { - if (chip.getAttribute("aria-pressed") === "true") { - selectedTags.add(tag); - } - }); - normalizeTagValue(tagInput.value).forEach((tag) => - selectedTags.add(tag), - ); - filters.tags = Array.from(selectedTags); - } - if (durationChecks) { - const ranges = []; - if (durationChecks["duration-short"].checked) { - ranges.push({ min: null, max: 300 }); - } - if (durationChecks["duration-medium"].checked) { - ranges.push({ min: 300, max: 1200 }); - } - if (durationChecks["duration-long"].checked) { - ranges.push({ min: 1200, max: null }); - } - if (ranges.length) { - const mins = ranges - .map((range) => range.min) - .filter((value) => value !== null); - const maxes = ranges - .map((range) => range.max) - .filter((value) => value !== null); - filters.duration.minSeconds = mins.length ? Math.min(...mins) : null; - filters.duration.maxSeconds = maxes.length ? Math.max(...maxes) : null; - } - } - const nsfwSwitch = nsfwToggle?.querySelector(".switch"); - if (nsfwSwitch?.getAttribute("aria-checked") === "true") { - filters.nsfw = "true"; - } - return filters; - }; - - resetButton.addEventListener("click", () => { - applyStateToControls(DEFAULT_FILTERS); - if (typeof options.onReset === "function") { - options.onReset(); - } - }); - applyButton.addEventListener("click", () => { - if (typeof options.onApply === "function") { - options.onApply(buildFiltersFromControls()); - } - close(); - }); - footer.append(resetButton, applyButton); - panel.appendChild(footer); - - trapFocus(panel); - container.appendChild(panel); - - controlState = { - applyStateToControls, - }; - return panel; - }; - - const popover = createPopover(triggerElement, render, { - placement: options.placement || "bottom-start", - document: doc, - restoreFocusOnClose: true, - }); - - if (!suggestedFacetUnsubscribe) { - suggestedFacetUnsubscribe = subscribeSearchFacetCounts((next) => { - if (typeof suggestedFacetRenderer === "function") { - suggestedFacetRenderer(next); - } - }); - } - const originalDestroy = popover.destroy?.bind(popover); - popover.destroy = (...args) => { - if (suggestedFacetUnsubscribe) { - suggestedFacetUnsubscribe(); - suggestedFacetUnsubscribe = null; - } - suggestedFacetRenderer = null; - if (originalDestroy) { - return originalDestroy(...args); - } - return undefined; - }; - - const syncFromQueryInput = (rawQuery) => { - if (typeof rawQuery !== "string") { - return { filters: DEFAULT_FILTERS, text: "", tokens: [], errors: [] }; - } - const parsed = parseFilterQuery(rawQuery); - if (controlState?.applyStateToControls) { - controlState.applyStateToControls(parsed.filters || DEFAULT_FILTERS); - } - if (controlState?.setParseErrors) { - controlState.setParseErrors(parsed.errors || []); - } - if (typeof options.onQueryParsed === "function") { - options.onQueryParsed(parsed); - } - return parsed; - }; - - const openPopover = async () => { - if (popover.isOpen()) { - return; - } - popover.preload(); - if (controlState?.applyStateToControls) { - const nextState = - typeof options.getState === "function" ? options.getState() : null; - controlState.applyStateToControls(nextState?.filters || DEFAULT_FILTERS); - } - await popover.open(); - }; - - triggerElement.addEventListener("click", async (event) => { - event.preventDefault(); - if (popover.isOpen()) { - popover.close(); - return; - } - await openPopover(); - }); - - const shortcutHandler = (event) => { - if ( - event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey - ) { - return; - } - if (isEditableTarget(event.target)) { - return; - } - if ((event.key === "f" || event.key === "F") && isElementVisible(triggerElement)) { - event.preventDefault(); - event.stopImmediatePropagation(); - void openPopover(); - } - if (event.key === "Escape" && popover.isOpen()) { - event.preventDefault(); - event.stopImmediatePropagation(); - popover.close(); - } - }; - - doc.addEventListener("keydown", shortcutHandler); - - popover.syncFromQueryInput = syncFromQueryInput; - - const originalDestroyPopover = popover.destroy?.bind(popover); - popover.destroy = (...args) => { - doc.removeEventListener("keydown", shortcutHandler); - if (originalDestroyPopover) { - return originalDestroyPopover(...args); - } - return undefined; - }; - - return popover; -} - -export default attachSearchFiltersPopover; diff --git a/server.log b/server.log deleted file mode 100644 index 40bff257..00000000 --- a/server.log +++ /dev/null @@ -1 +0,0 @@ -127.0.0.1 - - [23/Jan/2026 18:34:01] "GET /js/ui/components/SearchFiltersPopover.js HTTP/1.1" 200 -