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 @@
+
+
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}