Highlight the search terms in the results
This commit is contained in:
parent
540ba10654
commit
d836ee588f
2 changed files with 48 additions and 6 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
let results = $state<SearchResult[]>([]);
|
let results = $state<SearchResult[]>([]);
|
||||||
|
let resultsQuery = $state(""); // The query that produced the current results
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
let activeIndex = $state(-1);
|
let activeIndex = $state(-1);
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
const r = await searchThreads(q);
|
const r = await searchThreads(q);
|
||||||
if (id !== seq) return;
|
if (id !== seq) return;
|
||||||
results = r;
|
results = r;
|
||||||
|
resultsQuery = q;
|
||||||
} finally {
|
} finally {
|
||||||
if (id === seq) searching = false;
|
if (id === seq) searching = false;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +65,25 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const terms = $derived(
|
||||||
|
resultsQuery.split(/\s+/).filter((t) => t.length >= 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
function escapeRe(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into alternating plain/matched segments for <mark> 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) }));
|
||||||
|
}
|
||||||
|
|
||||||
function onFocus() {
|
function onFocus() {
|
||||||
if (query.trim().length >= 2) open = true;
|
if (query.trim().length >= 2) open = true;
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +154,14 @@
|
||||||
No results
|
No results
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#snippet marked(text: string)}
|
||||||
|
{#each highlight(text) as p}
|
||||||
|
{#if p.hit}<mark
|
||||||
|
class="bg-secondary/40 dark:bg-secondary/30 rounded-sm text-inherit"
|
||||||
|
>{p.text}</mark
|
||||||
|
>{:else}{p.text}{/if}
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
{#each results as r, i (r.threadId)}
|
{#each results as r, i (r.threadId)}
|
||||||
<a
|
<a
|
||||||
id="search-result-{i}"
|
id="search-result-{i}"
|
||||||
|
|
@ -151,7 +180,7 @@
|
||||||
<span
|
<span
|
||||||
class="block truncate font-medium text-neutral-800 dark:text-neutral-200"
|
class="block truncate font-medium text-neutral-800 dark:text-neutral-200"
|
||||||
>
|
>
|
||||||
{r.title}
|
{@render marked(r.title)}
|
||||||
{#if r.matchKind === "reply"}
|
{#if r.matchKind === "reply"}
|
||||||
<span
|
<span
|
||||||
class="ml-1 text-xs font-normal text-neutral-400 dark:text-neutral-500"
|
class="ml-1 text-xs font-normal text-neutral-400 dark:text-neutral-500"
|
||||||
|
|
@ -163,7 +192,7 @@
|
||||||
<span
|
<span
|
||||||
class="block truncate text-sm text-neutral-500 dark:text-neutral-400"
|
class="block truncate text-sm text-neutral-500 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
{r.snippet}
|
{@render marked(r.snippet)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,22 @@ export type SearchResult = {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function snippetOf(content: string): string {
|
// Window the snippet around the first term match so the highlight is visible
|
||||||
|
// even when the match sits deep in a long post.
|
||||||
|
function snippetOf(content: string, query: string): string {
|
||||||
const flat = content.replace(/\s+/g, " ").trim();
|
const flat = content.replace(/\s+/g, " ").trim();
|
||||||
return flat.length > 140 ? flat.slice(0, 140) + "…" : flat;
|
const MAX = 140;
|
||||||
|
if (flat.length <= MAX) return flat;
|
||||||
|
const lower = flat.toLowerCase();
|
||||||
|
let idx = -1;
|
||||||
|
for (const t of query.toLowerCase().split(/\s+/).filter(Boolean)) {
|
||||||
|
const i = lower.indexOf(t);
|
||||||
|
if (i !== -1 && (idx === -1 || i < idx)) idx = i;
|
||||||
|
}
|
||||||
|
if (idx <= 40) return flat.slice(0, MAX) + "…";
|
||||||
|
const start = idx - 40;
|
||||||
|
const end = Math.min(flat.length, start + MAX);
|
||||||
|
return "…" + flat.slice(start, end) + (end < flat.length ? "…" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-50 search over the forum relay: thread OPs (kind 11) and replies
|
// NIP-50 search over the forum relay: thread OPs (kind 11) and replies
|
||||||
|
|
@ -45,7 +58,7 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
|
||||||
byThread.set(e.id, {
|
byThread.set(e.id, {
|
||||||
threadId: e.id,
|
threadId: e.id,
|
||||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
||||||
snippet: snippetOf(e.content),
|
snippet: snippetOf(e.content, query),
|
||||||
matchKind: "thread",
|
matchKind: "thread",
|
||||||
createdAt: e.created_at,
|
createdAt: e.created_at,
|
||||||
});
|
});
|
||||||
|
|
@ -55,7 +68,7 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
|
||||||
const r: SearchResult = {
|
const r: SearchResult = {
|
||||||
threadId: root,
|
threadId: root,
|
||||||
title: "",
|
title: "",
|
||||||
snippet: snippetOf(e.content),
|
snippet: snippetOf(e.content, query),
|
||||||
matchKind: "reply",
|
matchKind: "reply",
|
||||||
createdAt: e.created_at,
|
createdAt: e.created_at,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue