mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add search filter state syncing
This commit is contained in:
1 parent
f8b4d8356d
commit
460ba22d51
5 files changed
+589
-61
No files matched your search
+42
-23
@@ -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")) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
+220
-7
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,20 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
id="searchActiveFilters"
|
||||
class="hidden flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface/70 px-3 py-2 text-xs text-text"
|
||||
>
|
||||
<div id="searchActiveFiltersList" class="flex flex-wrap gap-2"></div>
|
||||
<button
|
||||
id="searchActiveFiltersClear"
|
||||
type="button"
|
||||
class="btn-ghost text-xs"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="bv-stack">
|
||||
<h2 class="text-lg font-semibold uppercase tracking-wide text-muted-strong">
|
||||
Channels
|
||||
|
||||
Reference in new issue
Block a user