diff --git a/js/index.js b/js/index.js index 93299ee1..4e9026b3 100644 --- a/js/index.js +++ b/js/index.js @@ -18,10 +18,13 @@ import { } from "./themeController.js"; import { setHashView } from "./hashView.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { parseFilterQuery } from "./search/searchFilters.js"; import { - parseFilterQuery, - serializeFiltersToQuery, -} from "./search/searchFilters.js"; + buildSearchHashFromState, + getSearchFilterState, + resetSearchFilters, + setSearchFilterState, +} from "./search/searchFilterState.js"; import { prepareStaticModal, openStaticModal, @@ -49,6 +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"; validateInstanceConfig(); @@ -738,16 +742,12 @@ async function bootstrapInterface() { if (parsedFilters.errors.length > 0) { devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors); } - const params = new URLSearchParams(); - if (parsedFilters.text) { - params.set("q", parsedFilters.text); - } - const serializedFilters = serializeFiltersToQuery(parsedFilters.filters); - if (serializedFilters) { - params.set("filters", serializedFilters); - } - const queryString = params.toString(); - setHashView(queryString ? `search&${queryString}` : "search"); + const nextState = { + text: parsedFilters.text || "", + filters: parsedFilters.filters, + }; + setSearchFilterState(nextState); + setHashView(buildSearchHashFromState(nextState)); }); } @@ -848,22 +848,41 @@ async function bootstrapInterface() { if (parsedFilters.errors.length > 0) { devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors); } - const params = new URLSearchParams(); - if (parsedFilters.text) { - params.set("q", parsedFilters.text); - } - const serializedFilters = serializeFiltersToQuery(parsedFilters.filters); - if (serializedFilters) { - params.set("filters", serializedFilters); - } - const queryString = params.toString(); - setHashView(queryString ? `search&${queryString}` : "search"); + const nextState = { + text: parsedFilters.text || "", + filters: parsedFilters.filters, + }; + setSearchFilterState(nextState); + setHashView(buildSearchHashFromState(nextState)); // Close search on submit mobileSearchContainer.classList.add("hidden"); mobileSearchFab.classList.remove("hidden"); }); } + 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, + }); + }); + const sidebar = document.getElementById("sidebar"); const collapseToggle = document.getElementById("sidebarCollapseToggle"); if (sidebar && !sidebar.hasAttribute("data-footer-state")) { diff --git a/js/search/searchFilterState.js b/js/search/searchFilterState.js new file mode 100644 index 00000000..6799418c --- /dev/null +++ b/js/search/searchFilterState.js @@ -0,0 +1,112 @@ +import { + DEFAULT_FILTERS, + parseFilterQuery, + serializeFiltersToQuery, +} from "./searchFilters.js"; + +const listeners = new Set(); + +const cloneFilters = (filters = DEFAULT_FILTERS) => ({ + dateRange: { + after: filters?.dateRange?.after ?? null, + before: filters?.dateRange?.before ?? null, + }, + sort: filters?.sort ?? DEFAULT_FILTERS.sort, + authorPubkeys: Array.isArray(filters?.authorPubkeys) + ? [...filters.authorPubkeys] + : [], + tags: Array.isArray(filters?.tags) ? [...filters.tags] : [], + textScope: filters?.textScope ?? DEFAULT_FILTERS.textScope, + duration: { + minSeconds: filters?.duration?.minSeconds ?? null, + maxSeconds: filters?.duration?.maxSeconds ?? null, + }, + hasMagnet: + typeof filters?.hasMagnet === "boolean" ? filters.hasMagnet : null, + hasUrl: typeof filters?.hasUrl === "boolean" ? filters.hasUrl : null, + nsfw: filters?.nsfw ?? DEFAULT_FILTERS.nsfw, + relay: filters?.relay ?? DEFAULT_FILTERS.relay, + kind: + Number.isFinite(filters?.kind) && filters.kind !== null + ? filters.kind + : null, +}); + +let state = { + text: "", + filters: cloneFilters(DEFAULT_FILTERS), +}; + +function notify() { + const snapshot = getSearchFilterState(); + listeners.forEach((listener) => listener(snapshot)); +} + +export function getSearchFilterState() { + return { + text: state.text, + filters: cloneFilters(state.filters), + }; +} + +export function setSearchFilterState(nextState = {}, options = {}) { + state = { + text: typeof nextState.text === "string" ? nextState.text : "", + filters: cloneFilters(nextState.filters), + }; + if (options.notify !== false) { + notify(); + } +} + +export function resetSearchFilters(options = {}) { + state = { + text: state.text, + filters: cloneFilters(DEFAULT_FILTERS), + }; + if (options.notify !== false) { + notify(); + } +} + +export function subscribeSearchFilterState(listener) { + if (typeof listener !== "function") { + return () => {}; + } + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function syncSearchFilterStateFromHash(hash = "") { + const hashString = + typeof hash === "string" && hash ? hash : window?.location?.hash || ""; + const hashParams = new URLSearchParams( + hashString.split("?")[1] || hashString.slice(1), + ); + const rawQuery = hashParams.get("q") || ""; + const rawFilters = hashParams.get("filters") || ""; + const parsed = parseFilterQuery( + [rawQuery, rawFilters].filter(Boolean).join(" "), + ); + setSearchFilterState( + { + text: parsed.text || "", + filters: parsed.filters, + }, + { notify: true }, + ); + return parsed; +} + +export function buildSearchHashFromState(nextState = state) { + const params = new URLSearchParams(); + if (nextState.text) { + params.set("q", nextState.text); + } + const serialized = serializeFiltersToQuery(nextState.filters); + if (serialized) { + params.set("filters", serialized); + } + const queryString = params.toString(); + return queryString ? `search&${queryString}` : "search"; +} diff --git a/js/searchView.js b/js/searchView.js index 1e26d078..d9aac609 100644 --- a/js/searchView.js +++ b/js/searchView.js @@ -8,7 +8,14 @@ import { formatShortNpub } from "./utils/formatters.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import { devLogger } from "./utils/logger.js"; import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; -import { parseFilterQuery } from "./search/searchFilters.js"; +import { setHashView } from "./hashView.js"; +import { + buildSearchHashFromState, + getSearchFilterState, + resetSearchFilters, + setSearchFilterState, + syncSearchFilterStateFromHash, +} from "./search/searchFilterState.js"; function getApp() { return getApplication(); @@ -101,17 +108,14 @@ function renderProfileCards(profiles, container) { let currentSearchToken = 0; export async function initSearchView() { - const hashParams = new URLSearchParams(window.location.hash.split("?")[1] || window.location.hash.slice(1)); - // The hash format is #view=search&q=...&filters=... - // URLSearchParams handles 'view=search&q=...' correctly if we pass the string after # - const rawQuery = hashParams.get("q") || ""; - const rawFilters = hashParams.get("filters") || ""; - const parsedQuery = parseFilterQuery([rawQuery, rawFilters].filter(Boolean).join(" ")); + const parsedQuery = syncSearchFilterStateFromHash(window.location.hash); if (parsedQuery.errors.length > 0) { devLogger.warn("[Search] Filter parsing errors", parsedQuery.errors); } const query = parsedQuery.text || ""; + renderActiveFilters(parsedQuery.filters); + const titleEl = document.getElementById("searchTitle"); if (titleEl) { titleEl.textContent = query ? `Search Results for "${query}"` : "Search Results"; @@ -167,6 +171,215 @@ export async function initSearchView() { }); } +const formatDateLabel = (timestampSeconds) => { + if (!Number.isFinite(timestampSeconds)) return ""; + const formatter = new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return formatter.format(new Date(timestampSeconds * 1000)); +}; + +const formatDurationLabel = (seconds) => { + if (!Number.isFinite(seconds)) return ""; + if (seconds % 3600 === 0) { + return `${seconds / 3600}h`; + } + if (seconds % 60 === 0) { + return `${seconds / 60}m`; + } + return `${seconds}s`; +}; + +function buildNextFilters() { + const current = getSearchFilterState(); + const filters = current.filters || {}; + return { + ...filters, + dateRange: { ...filters.dateRange }, + duration: { ...filters.duration }, + authorPubkeys: Array.isArray(filters.authorPubkeys) + ? [...filters.authorPubkeys] + : [], + tags: Array.isArray(filters.tags) ? [...filters.tags] : [], + }; +} + +function applyFiltersAndRefresh(nextFilters) { + const current = getSearchFilterState(); + const nextState = { + text: current.text || "", + filters: nextFilters, + }; + setSearchFilterState(nextState); + setHashView(buildSearchHashFromState(nextState)); +} + +function renderActiveFilters(filters) { + const wrapper = document.getElementById("searchActiveFilters"); + const list = document.getElementById("searchActiveFiltersList"); + const clearBtn = document.getElementById("searchActiveFiltersClear"); + if (!wrapper || !list || !clearBtn) { + return; + } + + list.innerHTML = ""; + const pills = []; + + filters.authorPubkeys?.forEach((author) => { + pills.push({ + label: `Author: ${author}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.authorPubkeys = nextFilters.authorPubkeys.filter( + (value) => value !== author, + ); + applyFiltersAndRefresh(nextFilters); + }, + }); + }); + + filters.tags?.forEach((tag) => { + pills.push({ + label: `#${tag}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.tags = nextFilters.tags.filter((value) => value !== tag); + applyFiltersAndRefresh(nextFilters); + }, + }); + }); + + if (Number.isFinite(filters.kind)) { + pills.push({ + label: `Kind: ${filters.kind}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.kind = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (filters.relay) { + pills.push({ + label: `Relay: ${filters.relay}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.relay = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (Number.isFinite(filters.dateRange?.after)) { + pills.push({ + label: `After: ${formatDateLabel(filters.dateRange.after)}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.dateRange.after = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (Number.isFinite(filters.dateRange?.before)) { + pills.push({ + label: `Before: ${formatDateLabel(filters.dateRange.before)}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.dateRange.before = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (Number.isFinite(filters.duration?.minSeconds)) { + pills.push({ + label: `Min duration: ${formatDurationLabel(filters.duration.minSeconds)}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.duration.minSeconds = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (Number.isFinite(filters.duration?.maxSeconds)) { + pills.push({ + label: `Max duration: ${formatDurationLabel(filters.duration.maxSeconds)}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.duration.maxSeconds = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (filters.hasMagnet === true) { + pills.push({ + label: "Has magnet", + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.hasMagnet = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (filters.hasUrl === true) { + pills.push({ + label: "Has URL", + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.hasUrl = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (filters.nsfw && filters.nsfw !== "any") { + pills.push({ + label: `NSFW: ${filters.nsfw}`, + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.nsfw = "any"; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + + if (!pills.length) { + wrapper.classList.add("hidden"); + return; + } + + const fragment = document.createDocumentFragment(); + pills.forEach((pill) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = + "search-filter-chip inline-flex items-center gap-2 text-xs"; + const label = document.createElement("span"); + label.textContent = pill.label; + const remove = document.createElement("span"); + remove.setAttribute("aria-hidden", "true"); + remove.textContent = "✕"; + button.append(label, remove); + button.addEventListener("click", pill.onRemove); + fragment.appendChild(button); + }); + list.appendChild(fragment); + + wrapper.classList.remove("hidden"); + clearBtn.onclick = () => { + resetSearchFilters(); + const nextState = getSearchFilterState(); + setHashView(buildSearchHashFromState(nextState)); + }; +} + async function renderSearchVideos(videos, container, app) { if (!container) return; container.innerHTML = ""; diff --git a/js/ui/components/SearchFiltersPopover.js b/js/ui/components/SearchFiltersPopover.js index 20d0a209..18f3af44 100644 --- a/js/ui/components/SearchFiltersPopover.js +++ b/js/ui/components/SearchFiltersPopover.js @@ -1,4 +1,5 @@ import createPopover from "../overlay/popoverEngine.js"; +import { DEFAULT_FILTERS } from "../../search/searchFilters.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\"])"; @@ -115,6 +116,32 @@ function buildLabeledInput(doc, { id, label, type = "text", placeholder } = {}) 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; @@ -125,6 +152,8 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { return null; } + let controlState = null; + const render = ({ container, close }) => { const panel = createElement(doc, "div", { className: @@ -170,7 +199,15 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { const chipRow = createElement(doc, "div", { className: "flex flex-wrap gap-2", }); - ["24h", "7d", "30d", "90d", "All time"].forEach((label) => { + 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", @@ -178,22 +215,50 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { attrs: { type: "button", "aria-pressed": "false" }, }); chip.dataset.state = "off"; - chip.addEventListener("click", () => toggleChip(chip)); + 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 } = buildLabeledInput(doc, { + const { wrapper: startWrapper, inputEl: startInputEl } = buildLabeledInput( + doc, + { id: "search-filters-date-start", label: "Start date", type: "date", - }); - const { wrapper: endWrapper } = buildLabeledInput(doc, { + }, + ); + 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); @@ -224,11 +289,14 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { sortSection.appendChild(sortSelect); panel.appendChild(sortSection); - const { wrapper: authorWrapper } = buildLabeledInput(doc, { + const { wrapper: authorWrapper, inputEl: authorInput } = buildLabeledInput( + doc, + { id: "search-filters-author", label: "Author", placeholder: "npub1...", - }); + }, + ); panel.appendChild(authorWrapper); const tagSection = createElement(doc, "div", { @@ -247,6 +315,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { const tagList = createElement(doc, "div", { className: "flex flex-wrap gap-2", }); + const tagChips = new Map(); ["nostr", "music", "live"].forEach((tag) => { const chip = createElement(doc, "button", { className: "search-filter-chip focus-ring", @@ -255,6 +324,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { }); chip.dataset.state = "on"; chip.addEventListener("click", () => toggleChip(chip)); + tagChips.set(tag, chip); tagList.appendChild(chip); }); tagSection.append(tagInput, tagList); @@ -272,6 +342,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { const durationGrid = createElement(doc, "div", { className: "grid gap-2", }); + const durationChecks = {}; [ { id: "duration-short", label: "Short (under 5 min)" }, { id: "duration-medium", label: "Medium (5-20 min)" }, @@ -286,6 +357,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { className: "checkbox", attrs: { id, type: "checkbox" }, }); + durationChecks[id] = checkbox; row.append(checkbox, createElement(doc, "span", { text: label })); durationGrid.appendChild(row); }); @@ -320,10 +392,9 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { row.append(text, toggle); return row; }; - toggleSection.append( - toggleRow("NSFW content", "search-filters-nsfw"), - toggleRow("Followed only", "search-filters-followed"), - ); + const nsfwToggle = toggleRow("NSFW content", "search-filters-nsfw"); + const followedToggle = toggleRow("Followed only", "search-filters-followed"); + toggleSection.append(nsfwToggle, followedToggle); panel.appendChild(toggleSection); const footer = createElement(doc, "div", { @@ -339,33 +410,118 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { text: "Apply filters", attrs: { type: "button" }, }); - resetButton.addEventListener("click", () => { - const chips = panel.querySelectorAll(".search-filter-chip"); - chips.forEach((chip) => { - chip.setAttribute("aria-pressed", "false"); - chip.dataset.state = "off"; + 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"; }); - panel.querySelectorAll("input").forEach((input) => { - if (input.type === "checkbox") { - input.checked = false; - } else { - input.value = ""; + authorInput.value = safeFilters.authorPubkeys?.join(", ") || ""; + 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(", "); + } + 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; + } + }; + + 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; + const selectedTags = new Set(); + tagChips.forEach((chip, tag) => { + if (chip.getAttribute("aria-pressed") === "true") { + selectedTags.add(tag); } }); - panel.querySelectorAll("select").forEach((select) => { - select.selectedIndex = 0; - }); - panel.querySelectorAll(".switch").forEach((toggle) => { - toggle.setAttribute("aria-checked", "false"); - toggle.classList.remove("is-on"); - }); + normalizeTagValue(tagInput.value).forEach((tag) => + selectedTags.add(tag), + ); + filters.tags = Array.from(selectedTags); + 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(); + options.onApply(buildFiltersFromControls()); } close(); }); @@ -374,6 +530,10 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { trapFocus(panel); container.appendChild(panel); + + controlState = { + applyStateToControls, + }; return panel; }; @@ -385,7 +545,17 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { triggerElement.addEventListener("click", async (event) => { event.preventDefault(); - await popover.toggle(); + if (popover.isOpen()) { + popover.close(); + return; + } + popover.preload(); + if (controlState?.applyStateToControls) { + const nextState = + typeof options.getState === "function" ? options.getState() : null; + controlState.applyStateToControls(nextState?.filters || DEFAULT_FILTERS); + } + await popover.open(); }); return popover; diff --git a/views/search.html b/views/search.html index c65f5d58..8074088c 100644 --- a/views/search.html +++ b/views/search.html @@ -30,6 +30,20 @@ +