mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Enhance search query parsing and filters
This commit is contained in:
1 parent
9774c4e3eb
commit
19aa272bae
6 files changed
+351
-43
No files matched your search
+14
@@ -215,6 +215,13 @@
|
||||
></span>
|
||||
</button>
|
||||
</form>
|
||||
<p
|
||||
id="headerSearchMessage"
|
||||
class="mt-1 text-xs text-warning hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-search-parse-message
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Buttons on the far right -->
|
||||
@@ -413,6 +420,13 @@
|
||||
></span>
|
||||
</button>
|
||||
</form>
|
||||
<p
|
||||
id="mobileSearchMessage"
|
||||
class="mt-2 text-xs text-warning hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-search-parse-message
|
||||
></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+100
-9
@@ -18,7 +18,10 @@ 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";
|
||||
import {
|
||||
buildSearchHashFromState,
|
||||
getSearchFilterState,
|
||||
@@ -703,7 +706,49 @@ async function bootstrapInterface() {
|
||||
label: "Sidebar Tip Jar link",
|
||||
});
|
||||
|
||||
const buildSearchQueryString = (text, filters) => {
|
||||
const serializedFilters = serializeFiltersToQuery(filters);
|
||||
return [text, serializedFilters].filter(Boolean).join(" ").trim();
|
||||
};
|
||||
|
||||
const updateSearchParseMessage = (messageEl, errors = []) => {
|
||||
if (!(messageEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (!errors?.length) {
|
||||
messageEl.textContent = "";
|
||||
messageEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
const message = errors
|
||||
.map((error) =>
|
||||
error?.token ? `${error.token} (${error.message})` : error?.message,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
messageEl.textContent = message || "Some filters could not be parsed.";
|
||||
messageEl.classList.remove("hidden");
|
||||
};
|
||||
|
||||
const updateSearchInputsValue = (value, sourceInput = null) => {
|
||||
const inputs = [
|
||||
document.getElementById("headerSearchInput"),
|
||||
document.getElementById("mobileSearchInput"),
|
||||
];
|
||||
inputs.forEach((input) => {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
if (sourceInput && input === sourceInput) {
|
||||
input.value = value;
|
||||
return;
|
||||
}
|
||||
input.value = value;
|
||||
});
|
||||
};
|
||||
|
||||
const headerSearchForm = document.getElementById("headerSearchForm");
|
||||
const headerSearchMessage = document.getElementById("headerSearchMessage");
|
||||
if (headerSearchForm) {
|
||||
headerSearchForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -742,12 +787,19 @@ async function bootstrapInterface() {
|
||||
if (parsedFilters.errors.length > 0) {
|
||||
devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors);
|
||||
}
|
||||
updateSearchParseMessage(headerSearchMessage, parsedFilters.errors);
|
||||
const nextState = {
|
||||
text: parsedFilters.text || "",
|
||||
filters: parsedFilters.filters,
|
||||
};
|
||||
setSearchFilterState(nextState);
|
||||
setHashView(buildSearchHashFromState(nextState));
|
||||
if (parsedFilters.errors.length === 0) {
|
||||
updateSearchInputsValue(
|
||||
buildSearchQueryString(parsedFilters.text, parsedFilters.filters),
|
||||
input,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -758,6 +810,7 @@ async function bootstrapInterface() {
|
||||
);
|
||||
const mobileSearchForm = document.getElementById("mobileSearchForm");
|
||||
const mobileSearchInput = document.getElementById("mobileSearchInput");
|
||||
const mobileSearchMessage = document.getElementById("mobileSearchMessage");
|
||||
|
||||
if (mobileSearchFab && mobileSearchContainer) {
|
||||
const openMobileSearch = () => {
|
||||
@@ -848,12 +901,19 @@ async function bootstrapInterface() {
|
||||
if (parsedFilters.errors.length > 0) {
|
||||
devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors);
|
||||
}
|
||||
updateSearchParseMessage(mobileSearchMessage, parsedFilters.errors);
|
||||
const nextState = {
|
||||
text: parsedFilters.text || "",
|
||||
filters: parsedFilters.filters,
|
||||
};
|
||||
setSearchFilterState(nextState);
|
||||
setHashView(buildSearchHashFromState(nextState));
|
||||
if (parsedFilters.errors.length === 0) {
|
||||
updateSearchInputsValue(
|
||||
buildSearchQueryString(parsedFilters.text, parsedFilters.filters),
|
||||
mobileSearchInput,
|
||||
);
|
||||
}
|
||||
// Close search on submit
|
||||
mobileSearchContainer.classList.add("hidden");
|
||||
mobileSearchFab.classList.remove("hidden");
|
||||
@@ -861,26 +921,57 @@ async function bootstrapInterface() {
|
||||
}
|
||||
|
||||
const searchFilterButtons = document.querySelectorAll(".header-search__filter");
|
||||
const applySearchFilters = (filters) => {
|
||||
const currentState = getSearchFilterState();
|
||||
const applySearchFilters = (filters, context = {}) => {
|
||||
const rawQuery = context.input?.value || "";
|
||||
const parsed = parseFilterQuery(rawQuery);
|
||||
updateSearchParseMessage(context.messageEl, parsed.errors);
|
||||
const nextState = {
|
||||
text: currentState.text || "",
|
||||
text: parsed.text || "",
|
||||
filters,
|
||||
};
|
||||
setSearchFilterState(nextState);
|
||||
setHashView(buildSearchHashFromState(nextState));
|
||||
const nextQuery = buildSearchQueryString(nextState.text, nextState.filters);
|
||||
updateSearchInputsValue(nextQuery, context.input || null);
|
||||
const refreshed = parseFilterQuery(nextQuery);
|
||||
updateSearchParseMessage(context.messageEl, refreshed.errors);
|
||||
};
|
||||
const clearSearchFilters = () => {
|
||||
resetSearchFilters();
|
||||
const clearSearchFilters = (context = {}) => {
|
||||
const rawQuery = context.input?.value || "";
|
||||
const parsed = parseFilterQuery(rawQuery);
|
||||
resetSearchFilters({ notify: false });
|
||||
const nextState = getSearchFilterState();
|
||||
nextState.text = parsed.text || "";
|
||||
setSearchFilterState(nextState);
|
||||
setHashView(buildSearchHashFromState(nextState));
|
||||
updateSearchInputsValue(nextState.text, context.input || null);
|
||||
updateSearchParseMessage(context.messageEl, []);
|
||||
};
|
||||
searchFilterButtons.forEach((button) => {
|
||||
attachSearchFiltersPopover(button, {
|
||||
const form = button.closest("form");
|
||||
const input = form?.querySelector("input[type=\"search\"]") || null;
|
||||
const messageEl = form?.parentElement?.querySelector(
|
||||
"[data-search-parse-message]",
|
||||
);
|
||||
const popover = attachSearchFiltersPopover(button, {
|
||||
getState: getSearchFilterState,
|
||||
onApply: applySearchFilters,
|
||||
onReset: clearSearchFilters,
|
||||
getQueryInputValue: () => (input?.value ? input.value : ""),
|
||||
onQueryParsed: (parsed) => {
|
||||
updateSearchParseMessage(messageEl, parsed.errors);
|
||||
},
|
||||
onApply: (filters) =>
|
||||
applySearchFilters(filters, { input, messageEl }),
|
||||
onReset: () => clearSearchFilters({ input, messageEl }),
|
||||
});
|
||||
if (input && popover) {
|
||||
input.addEventListener("input", () => {
|
||||
if (!popover.isOpen()) {
|
||||
return;
|
||||
}
|
||||
const parsed = popover.syncFromQueryInput(input.value || "");
|
||||
updateSearchParseMessage(messageEl, parsed.errors);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
|
||||
@@ -24,6 +24,8 @@ const cloneFilters = (filters = DEFAULT_FILTERS) => ({
|
||||
hasMagnet:
|
||||
typeof filters?.hasMagnet === "boolean" ? filters.hasMagnet : null,
|
||||
hasUrl: typeof filters?.hasUrl === "boolean" ? filters.hasUrl : null,
|
||||
hasTranscript:
|
||||
typeof filters?.hasTranscript === "boolean" ? filters.hasTranscript : null,
|
||||
nsfw: filters?.nsfw ?? DEFAULT_FILTERS.nsfw,
|
||||
relay: filters?.relay ?? DEFAULT_FILTERS.relay,
|
||||
kind:
|
||||
|
||||
+86
-30
@@ -30,6 +30,7 @@ export const DEFAULT_FILTERS = {
|
||||
},
|
||||
hasMagnet: null,
|
||||
hasUrl: null,
|
||||
hasTranscript: null,
|
||||
nsfw: "any",
|
||||
relay: null,
|
||||
kind: null,
|
||||
@@ -46,30 +47,51 @@ const cloneDefaultFilters = () => ({
|
||||
duration: { ...DEFAULT_FILTERS.duration },
|
||||
hasMagnet: DEFAULT_FILTERS.hasMagnet,
|
||||
hasUrl: DEFAULT_FILTERS.hasUrl,
|
||||
hasTranscript: DEFAULT_FILTERS.hasTranscript,
|
||||
nsfw: DEFAULT_FILTERS.nsfw,
|
||||
relay: DEFAULT_FILTERS.relay,
|
||||
kind: DEFAULT_FILTERS.kind,
|
||||
});
|
||||
|
||||
const normalizeWhitespace = (input) =>
|
||||
input.replace(/\s+/g, " ").trim();
|
||||
|
||||
const tokenizeQuery = (input) => {
|
||||
const tokens = [];
|
||||
let current = "";
|
||||
let currentIsPhrase = false;
|
||||
let inQuotes = false;
|
||||
let sawUnclosedQuote = false;
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i];
|
||||
if (char === '"' && input[i - 1] !== "\\") {
|
||||
inQuotes = !inQuotes;
|
||||
|
||||
if (char === "\\" && input[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes) {
|
||||
tokens.push({ value: current, isPhrase: true });
|
||||
current = "";
|
||||
currentIsPhrase = false;
|
||||
inQuotes = false;
|
||||
} else {
|
||||
if (current) {
|
||||
tokens.push({ value: current, isPhrase: currentIsPhrase });
|
||||
current = "";
|
||||
currentIsPhrase = false;
|
||||
}
|
||||
inQuotes = true;
|
||||
currentIsPhrase = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && /\s/.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
tokens.push({ value: current, isPhrase: currentIsPhrase });
|
||||
current = "";
|
||||
currentIsPhrase = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -78,10 +100,14 @@ const tokenizeQuery = (input) => {
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
tokens.push({ value: current, isPhrase: currentIsPhrase });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
if (inQuotes) {
|
||||
sawUnclosedQuote = true;
|
||||
}
|
||||
|
||||
return { tokens, sawUnclosedQuote };
|
||||
};
|
||||
|
||||
const parseDateValue = (value) => {
|
||||
@@ -154,29 +180,37 @@ const formatDurationValue = (seconds) => {
|
||||
};
|
||||
|
||||
export function parseFilterQuery(inputString = "") {
|
||||
const normalized = normalizeWhitespace(String(inputString || ""));
|
||||
const tokens = tokenizeQuery(normalized);
|
||||
const normalized = String(inputString || "").trim();
|
||||
const { tokens, sawUnclosedQuote } = tokenizeQuery(normalized);
|
||||
const filters = cloneDefaultFilters();
|
||||
const errors = [];
|
||||
const textTokens = [];
|
||||
|
||||
if (sawUnclosedQuote) {
|
||||
errors.push({ token: "\"", message: "Missing closing quote." });
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token) continue;
|
||||
const upperToken = token.toUpperCase();
|
||||
if (BOOLEAN_OPERATORS.has(upperToken)) {
|
||||
textTokens.push({ type: "operator", value: upperToken });
|
||||
if (!token?.value) continue;
|
||||
const upperToken = token.value.toUpperCase();
|
||||
if (!token.isPhrase && BOOLEAN_OPERATORS.has(upperToken)) {
|
||||
textTokens.push({ type: "operator", value: upperToken, isPhrase: false });
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyValue = parseKeyValueToken(token);
|
||||
const keyValue = parseKeyValueToken(token.value);
|
||||
if (!keyValue) {
|
||||
textTokens.push({ type: "term", value: token, isPhrase: token.includes(" ") });
|
||||
textTokens.push({
|
||||
type: "term",
|
||||
value: token.value,
|
||||
isPhrase: Boolean(token.isPhrase || token.value.includes(" ")),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { key, value } = keyValue;
|
||||
if (!value) {
|
||||
errors.push({ token, message: "Filter value is missing." });
|
||||
errors.push({ token: token.value, message: "Filter value is missing." });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -184,7 +218,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "author": {
|
||||
const values = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
||||
if (values.length === 0) {
|
||||
errors.push({ token, message: "Author value is empty." });
|
||||
errors.push({ token: token.value, message: "Author value is empty." });
|
||||
break;
|
||||
}
|
||||
filters.authorPubkeys.push(...values);
|
||||
@@ -196,7 +230,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
.map((entry) => entry.trim().replace(/^#/, ""))
|
||||
.filter(Boolean);
|
||||
if (values.length === 0) {
|
||||
errors.push({ token, message: "Tag value is empty." });
|
||||
errors.push({ token: token.value, message: "Tag value is empty." });
|
||||
break;
|
||||
}
|
||||
filters.tags.push(...values);
|
||||
@@ -205,7 +239,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "kind": {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
errors.push({ token, message: "Kind must be an integer." });
|
||||
errors.push({ token: token.value, message: "Kind must be an integer." });
|
||||
break;
|
||||
}
|
||||
filters.kind = parsed;
|
||||
@@ -218,7 +252,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "after": {
|
||||
const parsed = parseDateValue(value);
|
||||
if (parsed.error) {
|
||||
errors.push({ token, message: parsed.error });
|
||||
errors.push({ token: token.value, message: parsed.error });
|
||||
break;
|
||||
}
|
||||
filters.dateRange.after = parsed.value;
|
||||
@@ -227,7 +261,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "before": {
|
||||
const parsed = parseDateValue(value);
|
||||
if (parsed.error) {
|
||||
errors.push({ token, message: parsed.error });
|
||||
errors.push({ token: token.value, message: parsed.error });
|
||||
break;
|
||||
}
|
||||
filters.dateRange.before = parsed.value;
|
||||
@@ -236,14 +270,17 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "duration": {
|
||||
const match = value.match(/^(<=|>=|<|>)(.+)$/);
|
||||
if (!match) {
|
||||
errors.push({ token, message: "Duration must use < or > operators." });
|
||||
errors.push({
|
||||
token: token.value,
|
||||
message: "Duration must use < or > operators.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
const operator = match[1];
|
||||
const durationValue = match[2].trim();
|
||||
const parsed = parseDurationValue(durationValue);
|
||||
if (parsed.error) {
|
||||
errors.push({ token, message: parsed.error });
|
||||
errors.push({ token: token.value, message: parsed.error });
|
||||
break;
|
||||
}
|
||||
applyDurationFilter(filters, operator, parsed.value);
|
||||
@@ -255,8 +292,13 @@ export function parseFilterQuery(inputString = "") {
|
||||
filters.hasMagnet = true;
|
||||
} else if (normalizedValue === "url") {
|
||||
filters.hasUrl = true;
|
||||
} else if (normalizedValue === "transcript") {
|
||||
filters.hasTranscript = true;
|
||||
} else {
|
||||
errors.push({ token, message: "Has filter supports magnet or url." });
|
||||
errors.push({
|
||||
token: token.value,
|
||||
message: "Has filter supports magnet, url, or transcript.",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -265,27 +307,37 @@ export function parseFilterQuery(inputString = "") {
|
||||
if (["any", "true", "false", "only", "safe"].includes(normalizedValue)) {
|
||||
filters.nsfw = normalizedValue === "safe" ? "false" : normalizedValue;
|
||||
} else {
|
||||
errors.push({ token, message: "NSFW filter supports any/true/false/only." });
|
||||
errors.push({
|
||||
token: token.value,
|
||||
message: "NSFW filter supports any/true/false/only.",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sort": {
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (!SORT_VALUES.has(normalizedValue)) {
|
||||
errors.push({ token, message: "Sort filter is invalid." });
|
||||
errors.push({ token: token.value, message: "Sort filter is invalid." });
|
||||
break;
|
||||
}
|
||||
filters.sort = normalizedValue;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
errors.push({ token, message: `Unknown filter "${key}".` });
|
||||
errors.push({ token: token.value, message: `Unknown filter "${key}".` });
|
||||
}
|
||||
}
|
||||
|
||||
const text = textTokens
|
||||
.filter((token) => token.type === "term")
|
||||
.map((token) => token.value)
|
||||
.map((token) => {
|
||||
if (token.type === "operator") {
|
||||
return token.value;
|
||||
}
|
||||
if (token.isPhrase) {
|
||||
return `"${token.value}"`;
|
||||
}
|
||||
return token.value;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return {
|
||||
@@ -343,6 +395,10 @@ export function serializeFiltersToQuery(filters = DEFAULT_FILTERS) {
|
||||
tokens.push("has:url");
|
||||
}
|
||||
|
||||
if (filters.hasTranscript === true) {
|
||||
tokens.push("has:transcript");
|
||||
}
|
||||
|
||||
if (filters.nsfw && filters.nsfw !== "any") {
|
||||
tokens.push(`nsfw:${filters.nsfw}`);
|
||||
}
|
||||
|
||||
+19
-1
@@ -129,7 +129,7 @@ export async function initSearchView() {
|
||||
if (infoTrigger) {
|
||||
attachFeedInfoPopover(
|
||||
infoTrigger,
|
||||
"Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, and has:magnet/url."
|
||||
"Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, has:magnet/url/transcript, and boolean operators with quoted phrases."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -442,6 +442,17 @@ function renderActiveFilters(filters) {
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.hasTranscript === true) {
|
||||
pills.push({
|
||||
label: "Has transcript",
|
||||
onRemove: () => {
|
||||
const nextFilters = buildNextFilters();
|
||||
nextFilters.hasTranscript = null;
|
||||
applyFiltersAndRefresh(nextFilters);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.nsfw && filters.nsfw !== "any") {
|
||||
pills.push({
|
||||
label: `NSFW: ${filters.nsfw}`,
|
||||
@@ -771,6 +782,7 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
: null;
|
||||
const hasMagnet = filters?.hasMagnet === true;
|
||||
const hasUrl = filters?.hasUrl === true;
|
||||
const hasTranscript = filters?.hasTranscript === true;
|
||||
const nsfwFilter = typeof filters?.nsfw === "string" ? filters.nsfw : "any";
|
||||
|
||||
const matchesRelayFilters = (video) => {
|
||||
@@ -807,6 +819,12 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
|
||||
if (hasMagnet && !video.magnet) return false;
|
||||
if (hasUrl && !video.url) return false;
|
||||
if (hasTranscript) {
|
||||
const textTracks = Array.isArray(video.nip71?.textTracks)
|
||||
? video.nip71.textTracks
|
||||
: [];
|
||||
if (!textTracks.length) return false;
|
||||
}
|
||||
|
||||
if (Number.isFinite(minDuration) || Number.isFinite(maxDuration)) {
|
||||
const duration = Number(video.nip71?.duration);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import createPopover from "../overlay/popoverEngine.js";
|
||||
import { DEFAULT_FILTERS, SORT_OPTIONS } from "../../search/searchFilters.js";
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
SORT_OPTIONS,
|
||||
parseFilterQuery,
|
||||
} 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\"])";
|
||||
@@ -187,6 +191,12 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
header.append(heading, closeButton);
|
||||
panel.appendChild(header);
|
||||
|
||||
const parseMessage = createElement(doc, "p", {
|
||||
className: "text-xs text-warning hidden",
|
||||
attrs: { role: "status", "aria-live": "polite" },
|
||||
});
|
||||
panel.appendChild(parseMessage);
|
||||
|
||||
const dateSection = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
});
|
||||
@@ -299,7 +309,21 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
placeholder: "npub1...",
|
||||
},
|
||||
);
|
||||
panel.appendChild(authorWrapper);
|
||||
const { wrapper: kindWrapper, inputEl: kindInput } = buildLabeledInput(doc, {
|
||||
id: "search-filters-kind",
|
||||
label: "Kind",
|
||||
type: "number",
|
||||
placeholder: "30078",
|
||||
});
|
||||
const { wrapper: relayWrapper, inputEl: relayInput } = buildLabeledInput(
|
||||
doc,
|
||||
{
|
||||
id: "search-filters-relay",
|
||||
label: "Relay",
|
||||
placeholder: "wss://relay.example.com",
|
||||
},
|
||||
);
|
||||
panel.append(authorWrapper, kindWrapper, relayWrapper);
|
||||
|
||||
const tagSection = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
@@ -366,6 +390,37 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
durationSection.appendChild(durationGrid);
|
||||
panel.appendChild(durationSection);
|
||||
|
||||
const 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",
|
||||
});
|
||||
const 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);
|
||||
panel.appendChild(hasSection);
|
||||
|
||||
const toggleSection = createElement(doc, "div", {
|
||||
className: "grid gap-3",
|
||||
});
|
||||
@@ -463,6 +518,20 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
if (sortSelect) {
|
||||
sortSelect.value = safeFilters.sort || DEFAULT_FILTERS.sort;
|
||||
}
|
||||
kindInput.value = Number.isFinite(safeFilters.kind)
|
||||
? String(safeFilters.kind)
|
||||
: "";
|
||||
relayInput.value = safeFilters.relay || "";
|
||||
const hasFlags = {
|
||||
magnet: safeFilters.hasMagnet === true,
|
||||
url: safeFilters.hasUrl === true,
|
||||
transcript: safeFilters.hasTranscript === true,
|
||||
};
|
||||
hasChips.forEach((chip, key) => {
|
||||
const isOn = hasFlags[key] === true;
|
||||
chip.setAttribute("aria-pressed", isOn ? "true" : "false");
|
||||
chip.dataset.state = isOn ? "on" : "off";
|
||||
});
|
||||
};
|
||||
|
||||
const buildFiltersFromControls = () => {
|
||||
@@ -478,6 +547,9 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
filters.authorPubkeys = authorValues;
|
||||
const kindValue = Number.parseInt(kindInput.value, 10);
|
||||
filters.kind = Number.isFinite(kindValue) ? kindValue : null;
|
||||
filters.relay = relayInput.value.trim() || null;
|
||||
const selectedTags = new Set();
|
||||
tagChips.forEach((chip, tag) => {
|
||||
if (chip.getAttribute("aria-pressed") === "true") {
|
||||
@@ -515,6 +587,18 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
if (sortSelect?.value) {
|
||||
filters.sort = sortSelect.value;
|
||||
}
|
||||
filters.hasMagnet =
|
||||
hasChips.get("magnet")?.getAttribute("aria-pressed") === "true"
|
||||
? true
|
||||
: null;
|
||||
filters.hasUrl =
|
||||
hasChips.get("url")?.getAttribute("aria-pressed") === "true"
|
||||
? true
|
||||
: null;
|
||||
filters.hasTranscript =
|
||||
hasChips.get("transcript")?.getAttribute("aria-pressed") === "true"
|
||||
? true
|
||||
: null;
|
||||
return filters;
|
||||
};
|
||||
|
||||
@@ -538,6 +622,24 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
|
||||
controlState = {
|
||||
applyStateToControls,
|
||||
setParseErrors: (errors = []) => {
|
||||
if (!parseMessage) return;
|
||||
if (!errors?.length) {
|
||||
parseMessage.textContent = "";
|
||||
parseMessage.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
const message = errors
|
||||
.map((error) =>
|
||||
error?.token
|
||||
? `${error.token} (${error.message})`
|
||||
: error?.message,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
parseMessage.textContent = message || "Some filters could not be parsed.";
|
||||
parseMessage.classList.remove("hidden");
|
||||
},
|
||||
};
|
||||
return panel;
|
||||
};
|
||||
@@ -548,6 +650,23 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
restoreFocusOnClose: true,
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
triggerElement.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
if (popover.isOpen()) {
|
||||
@@ -555,7 +674,13 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
return;
|
||||
}
|
||||
popover.preload();
|
||||
if (controlState?.applyStateToControls) {
|
||||
const rawQuery =
|
||||
typeof options.getQueryInputValue === "function"
|
||||
? options.getQueryInputValue()
|
||||
: null;
|
||||
if (rawQuery !== null) {
|
||||
syncFromQueryInput(rawQuery);
|
||||
} else if (controlState?.applyStateToControls) {
|
||||
const nextState =
|
||||
typeof options.getState === "function" ? options.getState() : null;
|
||||
controlState.applyStateToControls(nextState?.filters || DEFAULT_FILTERS);
|
||||
@@ -563,6 +688,8 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
await popover.open();
|
||||
});
|
||||
|
||||
popover.syncFromQueryInput = syncFromQueryInput;
|
||||
|
||||
return popover;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user