From d256abb42fd999f0c2dfe3bf03e640fe54821069 Mon Sep 17 00:00:00 2001 From: dtonon Date: Thu, 16 Jul 2026 13:23:28 +0100 Subject: [PATCH] Split search into shared state with inline homepage input and modal --- src/lib/components/SearchInline.svelte | 96 ++++++++++++++ src/lib/components/SearchModal.svelte | 161 +++--------------------- src/lib/components/SearchResults.svelte | 76 +++++++++++ src/lib/searchModal.svelte.ts | 15 ++- src/lib/searchState.svelte.ts | 107 ++++++++++++++++ src/routes/+page.svelte | 33 +---- 6 files changed, 316 insertions(+), 172 deletions(-) create mode 100644 src/lib/components/SearchInline.svelte create mode 100644 src/lib/components/SearchResults.svelte create mode 100644 src/lib/searchState.svelte.ts diff --git a/src/lib/components/SearchInline.svelte b/src/lib/components/SearchInline.svelte new file mode 100644 index 0000000..66e20e4 --- /dev/null +++ b/src/lib/components/SearchInline.svelte @@ -0,0 +1,96 @@ + + +
+
+ + = 0 + ? `search-results-inline-${search.activeIndex}` + : undefined} + aria-label="Search discussions" + autocomplete="off" + oninput={onInput} + onkeydown={onKeydown} + onfocus={onFocus} + onblur={onBlur} + class="focus:ring-accent focus:dark:ring-accent w-full rounded border border-neutral-200 bg-neutral-100 py-2 pr-12 pl-11 text-neutral-800 placeholder-neutral-400 focus:bg-neutral-50 focus:ring-1 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:placeholder-neutral-500 focus:dark:bg-neutral-950" + /> + +
+ + {#if open} + + {/if} +
diff --git a/src/lib/components/SearchModal.svelte b/src/lib/components/SearchModal.svelte index ef72802..9d07dbf 100644 --- a/src/lib/components/SearchModal.svelte +++ b/src/lib/components/SearchModal.svelte @@ -1,19 +1,14 @@ + +
+ {#if search.searching} +
+ + Searching… +
+ {:else if search.results.length === 0} +
+ No results +
+ {/if} + {#snippet marked(text: string)} + {#each search.highlight(text) as p} + {#if p.hit}{p.text}{:else}{p.text}{/if} + {/each} + {/snippet} + {#each search.results as r, i (r.threadId)} + { + e.preventDefault(); + onselect(r); + }} + onmouseenter={() => (search.activeIndex = i)} + > + + {@render marked(r.title)} + {#if r.matchKind === "reply"} + reply + {/if} + + {#if r.snippet} + + {@render marked(r.snippet)} + + {/if} + + {/each} +
diff --git a/src/lib/searchModal.svelte.ts b/src/lib/searchModal.svelte.ts index 8e3892d..101af29 100644 --- a/src/lib/searchModal.svelte.ts +++ b/src/lib/searchModal.svelte.ts @@ -1,13 +1,26 @@ let open = $state(false); +// When a page hosts an inline search input (the homepage), it registers a +// focus function here and every search trigger routes to it instead of the +// modal, so a modal input never opens on top of an inline one. +let inlineFocus: (() => void) | null = null; + export const searchModal = { get open() { return open; }, }; +export function registerInlineSearch(focus: () => void): () => void { + inlineFocus = focus; + return () => { + if (inlineFocus === focus) inlineFocus = null; + }; +} + export function openSearch() { - open = true; + if (inlineFocus) inlineFocus(); + else open = true; } export function closeSearch() { diff --git a/src/lib/searchState.svelte.ts b/src/lib/searchState.svelte.ts new file mode 100644 index 0000000..beb9870 --- /dev/null +++ b/src/lib/searchState.svelte.ts @@ -0,0 +1,107 @@ +import { searchThreads, type SearchResult } from "$lib/search"; + +// Debounced-search state machine shared by the inline (homepage) and modal +// search shells, so behavior lives in one place and the shells only differ +// in chrome and positioning. +export function createSearchState() { + let query = $state(""); + let results = $state([]); + let resultsQuery = $state(""); // The query that produced the current results + let searching = $state(false); + let activeIndex = $state(-1); + let timer: ReturnType | null = null; + let seq = 0; + + const terms = $derived( + resultsQuery.split(/\s+/).filter((t) => t.length >= 2), + ); + + function schedule() { + const q = query.trim(); + if (timer) clearTimeout(timer); + activeIndex = -1; + if (q.length < 2) { + seq++; // Supersede any in-flight query + results = []; + resultsQuery = ""; + searching = false; + return; + } + searching = true; + timer = setTimeout(async () => { + const id = ++seq; + try { + const r = await searchThreads(q); + if (id !== seq) return; + results = r; + resultsQuery = q; + } finally { + if (id === seq) searching = false; + } + }, 300); + } + + function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + // Split into alternating plain/matched segments for rendering + function highlight(text: string): { text: string; hit: boolean }[] { + if (!text || terms.length === 0) return [{ text, hit: false }]; + const alts = terms.map(escapeRe).join("|"); + const exact = new RegExp(`^(${alts})$`, "i"); + return text + .split(new RegExp(`(${alts})`, "gi")) + .filter((s) => s !== "") + .map((s) => ({ text: s, hit: exact.test(s) })); + } + + // Arrow/Enter handling; returns true when the event was consumed + function navigate( + e: KeyboardEvent, + pick: (r: SearchResult) => void, + ): boolean { + if (results.length === 0) return false; + if (e.key === "ArrowDown") { + activeIndex = (activeIndex + 1) % results.length; + } else if (e.key === "ArrowUp") { + activeIndex = (activeIndex - 1 + results.length) % results.length; + } else if (e.key === "Enter" && activeIndex >= 0) { + pick(results[activeIndex]); + } else { + return false; + } + e.preventDefault(); + return true; + } + + return { + get query() { + return query; + }, + set query(v: string) { + query = v; + }, + get results() { + return results; + }, + get searching() { + return searching; + }, + get activeIndex() { + return activeIndex; + }, + set activeIndex(v: number) { + activeIndex = v; + }, + // True once a query has produced (or is about to produce) results + get active() { + return resultsQuery.trim().length >= 2; + }, + schedule, + highlight, + navigate, + }; +} + +export type SearchState = ReturnType; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5cee3b6..0a006cd 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,7 +6,7 @@ import { overviewStore, loadOverview } from "$lib/overview.svelte"; import { partialsStore } from "$lib/partials.svelte"; import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config"; - import { openSearch } from "$lib/searchModal.svelte"; + import SearchInline from "$lib/components/SearchInline.svelte"; import type { NostrUser } from "@nostr/gadgets/metadata"; const partial = $derived(partialsStore.get("home")); @@ -52,35 +52,10 @@ {/if} {/snippet} - {#if SEARCH_ENABLED} - +
+ +
{/if} {#if partial}