mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
cedd79fb49
commit
f53bfbeccf
5 files changed
+598
-889
No files matched your search
@@ -0,0 +1,174 @@
|
||||
<div
|
||||
id="searchFilterModal"
|
||||
class="bv-modal hidden p-0 sm:p-0 lg:p-8 lg:items-start lg:justify-center overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="searchFilterModalTitle"
|
||||
data-ds="new"
|
||||
>
|
||||
<div class="bv-modal-backdrop" data-dismiss></div>
|
||||
|
||||
<div
|
||||
class="modal-sheet w-full h-full max-w-none rounded-none border-0 lg:max-h-modal-shell lg:max-w-4xl lg:rounded-modal-xl lg:border lg:h-auto flex min-h-0 flex-col overflow-hidden"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h2 id="searchFilterModalTitle" class="text-xl font-bold text-text">
|
||||
Search Filters
|
||||
</h2>
|
||||
<button id="closeSearchFilterModal" class="modal-close">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body flex-1 overflow-y-auto">
|
||||
<form id="searchFilterForm" class="space-y-6">
|
||||
<!-- Basic Filters Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Column 1: Date Range -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Date Range</h3>
|
||||
<div class="flex flex-wrap gap-2" id="searchFilterDateChips">
|
||||
<button type="button" class="search-filter-chip focus-ring" data-days="1">24h</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-days="7">7d</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-days="30">30d</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-days="90">90d</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-days="all">All time</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<label for="searchFilterDateStart" class="text-xs text-muted">Start</label>
|
||||
<input type="date" id="searchFilterDateStart" class="input w-full text-xs" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="searchFilterDateEnd" class="text-xs text-muted">End</label>
|
||||
<input type="date" id="searchFilterDateEnd" class="input w-full text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Sort & Author -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="searchFilterSort" class="text-xs font-semibold uppercase tracking-wide text-muted">Sort By</label>
|
||||
<select id="searchFilterSort" class="select w-full">
|
||||
<option value="recent">Most recent</option>
|
||||
<option value="views">Most viewed</option>
|
||||
<option value="trending">Trending</option>
|
||||
<option value="longest">Longest</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="searchFilterAuthor" class="text-xs font-semibold uppercase tracking-wide text-muted">Author (npub)</label>
|
||||
<input type="text" id="searchFilterAuthor" class="input w-full" placeholder="npub1..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Quick Toggles (was advanced) -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Content</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-text">NSFW Content</span>
|
||||
<button type="button" role="switch" aria-checked="false" id="searchFilterNsfwToggle" class="switch">
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reserved for "Followed only" later -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Advanced Toggle -->
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
id="searchFilterAdvancedToggle"
|
||||
class="btn-ghost w-full justify-between text-sm font-medium"
|
||||
aria-expanded="false"
|
||||
aria-controls="searchFilterAdvancedPanel"
|
||||
>
|
||||
<span>Advanced filters</span>
|
||||
<span class="text-muted transition-transform duration-200" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Panel (Hidden by default) -->
|
||||
<div id="searchFilterAdvancedPanel" class="hidden space-y-6 pt-2 border-t border-border-subtle">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Advanced: Tags -->
|
||||
<div class="md:col-span-2 space-y-3">
|
||||
<label for="searchFilterTags" class="text-xs font-semibold uppercase tracking-wide text-muted">Tags</label>
|
||||
<input type="text" id="searchFilterTags" class="input w-full" placeholder="Add tag or keyword (comma separated)" />
|
||||
<div class="flex flex-wrap gap-2" id="searchFilterTagChips">
|
||||
<button type="button" class="search-filter-chip focus-ring" data-tag="nostr">#nostr</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-tag="music">#music</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-tag="live">#live</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-tag="gaming">#gaming</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-tag="tech">#tech</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced: Duration -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Duration</h3>
|
||||
<div class="grid gap-2">
|
||||
<label class="flex items-center gap-3 text-sm text-text cursor-pointer">
|
||||
<input type="checkbox" class="checkbox" id="searchFilterDurationShort" />
|
||||
<span>Short (under 5 min)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-text cursor-pointer">
|
||||
<input type="checkbox" class="checkbox" id="searchFilterDurationMedium" />
|
||||
<span>Medium (5-20 min)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-text cursor-pointer">
|
||||
<input type="checkbox" class="checkbox" id="searchFilterDurationLong" />
|
||||
<span>Long (20+ min)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced: Has -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Must Have</h3>
|
||||
<div class="flex flex-wrap gap-2" id="searchFilterHasChips">
|
||||
<button type="button" class="search-filter-chip focus-ring" data-has="magnet">Magnet Link</button>
|
||||
<button type="button" class="search-filter-chip focus-ring" data-has="url">Web URL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer flex-row items-center justify-between border-t border-border-subtle bg-surface-alt/50">
|
||||
<button type="button" id="searchFilterResetBtn" class="btn-ghost text-sm">
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" id="searchFilterApplyBtn" class="btn text-sm">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+8
-19
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
127.0.0.1 - - [23/Jan/2026 18:34:01] "GET /js/ui/components/SearchFiltersPopover.js HTTP/1.1" 200 -
|
||||
Reference in new issue
Block a user