Merge branch 'search'
This commit is contained in:
commit
475d891b4c
15 changed files with 583 additions and 16 deletions
|
|
@ -3,6 +3,7 @@ PUBLIC_MODE=simple # simple | full (single forum vs. many ro
|
||||||
PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode)
|
PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode)
|
||||||
PUBLIC_TITLE= # top-bar title; empty falls back to the group name
|
PUBLIC_TITLE= # top-bar title; empty falls back to the group name
|
||||||
PUBLIC_JOINCODE=no # yes | no — show an invite-code field when a join is rejected
|
PUBLIC_JOINCODE=no # yes | no — show an invite-code field when a join is rejected
|
||||||
|
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
|
||||||
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
|
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
|
||||||
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
||||||
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ Squalk is configured entirely through environment variables (all prefixed `PUBLI
|
||||||
| `PUBLIC_GROUP_ID` | in simple mode | — | The single forum's group id. Required when `PUBLIC_MODE=simple`; ignored in full mode, where rooms are selected at runtime. |
|
| `PUBLIC_GROUP_ID` | in simple mode | — | The single forum's group id. Required when `PUBLIC_MODE=simple`; ignored in full mode, where rooms are selected at runtime. |
|
||||||
| `PUBLIC_TITLE` | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
|
| `PUBLIC_TITLE` | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
|
||||||
| `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). |
|
| `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). |
|
||||||
|
| `PUBLIC_SEARCH` | no | `no` | `yes` to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. |
|
||||||
| `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. |
|
| `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. |
|
||||||
| `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. |
|
| `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. |
|
||||||
| `PUBLIC_ACCENT_COLOR` | no | `#e32a6d` | Override the accent (primary) color. Quote the value (`"#00ff00"`) — an unquoted leading `#` is read as a comment. The hover shade is derived automatically. |
|
| `PUBLIC_ACCENT_COLOR` | no | `#e32a6d` | Override the accent (primary) color. Quote the value (`"#00ff00"`) — an unquoted leading `#` is read as a comment. The hover shade is derived automatically. |
|
||||||
|
|
|
||||||
|
|
@ -502,7 +502,7 @@
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
placeholder="Message..."
|
placeholder="Message..."
|
||||||
{contextPubkeys}
|
{contextPubkeys}
|
||||||
textareaClass="block w-full resize-none rounded border border-neutral-200 dark:border-neutral-700 py-2 pl-3 pr-11 text-sm focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
|
textareaClass="block w-full resize-none rounded border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-900 focus:dark:bg-neutral-950 py-2 pl-3 pr-11 text-sm focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
import { resourcesStore } from "$lib/resources.svelte";
|
import { resourcesStore } from "$lib/resources.svelte";
|
||||||
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||||
import { GROUP_ID, TITLE } from "$lib/config";
|
import { GROUP_ID, TITLE, SEARCH_ENABLED } from "$lib/config";
|
||||||
|
import { openSearch } from "$lib/searchModal.svelte";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -59,6 +60,20 @@
|
||||||
{mode === "simple" ? "Discussions" : "Home"}
|
{mode === "simple" ? "Discussions" : "Home"}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{#if SEARCH_ENABLED}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openSearch}
|
||||||
|
class="flex w-full items-center gap-2 py-1 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
<kbd
|
||||||
|
class="ml-auto flex h-5 w-5 items-center justify-center rounded bg-neutral-200 font-sans text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
aria-hidden="true">/</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if mode === "simple"}
|
{#if mode === "simple"}
|
||||||
<div class="mt-6 flex">
|
<div class="mt-6 flex">
|
||||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
import { resourcesStore } from "$lib/resources.svelte";
|
import { resourcesStore } from "$lib/resources.svelte";
|
||||||
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
||||||
|
import { openSearch } from "$lib/searchModal.svelte";
|
||||||
|
import { SEARCH_ENABLED } from "$lib/config";
|
||||||
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
|
||||||
|
|
@ -124,6 +126,19 @@
|
||||||
>{mode === "simple" ? "Discussions" : "Home"}</a
|
>{mode === "simple" ? "Discussions" : "Home"}</a
|
||||||
>
|
>
|
||||||
|
|
||||||
|
{#if SEARCH_ENABLED}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
onClose();
|
||||||
|
openSearch();
|
||||||
|
}}
|
||||||
|
class="hover:text-accent py-2 text-left text-xl text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if mode === "full"}
|
{#if mode === "full"}
|
||||||
<nav class="mt-4" aria-label="Rooms">
|
<nav class="mt-4" aria-label="Rooms">
|
||||||
<p
|
<p
|
||||||
|
|
|
||||||
90
src/lib/components/SearchInline.svelte
Normal file
90
src/lib/components/SearchInline.svelte
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { SearchResult } from "$lib/search";
|
||||||
|
import { createSearchState } from "$lib/searchState.svelte";
|
||||||
|
import SearchResults from "$lib/components/SearchResults.svelte";
|
||||||
|
|
||||||
|
const search = createSearchState();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
search.schedule();
|
||||||
|
open = search.query.trim().length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(r: SearchResult) {
|
||||||
|
open = false;
|
||||||
|
goto(`/thread/${r.threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
search.navigate(e, select);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocus() {
|
||||||
|
if (search.query.trim().length >= 2) open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
// Delay so a mousedown on a result still lands
|
||||||
|
setTimeout(() => (open = false), 120);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div class="relative">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="pointer-events-none absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:value={search.query}
|
||||||
|
data-search-inline
|
||||||
|
type="search"
|
||||||
|
placeholder="Search"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? "search-results-inline" : undefined}
|
||||||
|
aria-activedescendant={search.activeIndex >= 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"
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
class="pointer-events-none absolute top-1/2 right-3 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md bg-neutral-200 font-sans text-sm text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
aria-hidden="true">/</kbd
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<SearchResults
|
||||||
|
{search}
|
||||||
|
idBase="search-results-inline"
|
||||||
|
class="absolute right-0 left-0 z-30 mt-2 max-h-96 overflow-auto rounded-lg border border-neutral-200 bg-white py-2 shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
onselect={select}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
110
src/lib/components/SearchModal.svelte
Normal file
110
src/lib/components/SearchModal.svelte
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { SearchResult } from "$lib/search";
|
||||||
|
import { createSearchState } from "$lib/searchState.svelte";
|
||||||
|
import { searchModal, openSearch, closeSearch } from "$lib/searchModal.svelte";
|
||||||
|
import SearchResults from "$lib/components/SearchResults.svelte";
|
||||||
|
|
||||||
|
const search = createSearchState();
|
||||||
|
|
||||||
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Focus (and select, so a stale query is typed over) when the modal opens
|
||||||
|
$effect(() => {
|
||||||
|
if (searchModal.open)
|
||||||
|
tick().then(() => {
|
||||||
|
inputEl?.focus();
|
||||||
|
inputEl?.select();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function select(r: SearchResult) {
|
||||||
|
closeSearch();
|
||||||
|
goto(`/thread/${r.threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global "/" opens the search from any page, unless typing somewhere else
|
||||||
|
function onWindowKeydown(e: KeyboardEvent) {
|
||||||
|
if (searchModal.open) {
|
||||||
|
if (e.key === "Escape") closeSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
t &&
|
||||||
|
(t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
e.preventDefault();
|
||||||
|
openSearch();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
|
{#if searchModal.open}
|
||||||
|
<div class="fixed inset-0 z-50 flex flex-col items-center px-4 pt-[12vh]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close search"
|
||||||
|
class="absolute inset-0 bg-black/40 dark:bg-black/65"
|
||||||
|
onclick={closeSearch}
|
||||||
|
></button>
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-xl rounded-lg bg-white p-3 shadow-xl dark:bg-neutral-900"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Search"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="pointer-events-none absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={search.query}
|
||||||
|
type="search"
|
||||||
|
placeholder="Search"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={search.active}
|
||||||
|
aria-controls={search.active ? "search-results-modal" : undefined}
|
||||||
|
aria-activedescendant={search.activeIndex >= 0
|
||||||
|
? `search-results-modal-${search.activeIndex}`
|
||||||
|
: undefined}
|
||||||
|
aria-label="Search discussions"
|
||||||
|
autocomplete="off"
|
||||||
|
oninput={search.schedule}
|
||||||
|
onkeydown={(e) => search.navigate(e, select)}
|
||||||
|
class="focus:ring-accent focus:dark:ring-accent w-full rounded border border-neutral-200 bg-neutral-100 py-2 pr-14 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"
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
class="pointer-events-none absolute top-1/2 right-3 flex h-6 -translate-y-1/2 items-center justify-center rounded-md bg-neutral-200 px-1.5 font-sans text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
aria-hidden="true">esc</kbd
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if search.active || search.searching}
|
||||||
|
<SearchResults
|
||||||
|
{search}
|
||||||
|
idBase="search-results-modal"
|
||||||
|
class="mt-3 max-h-[50vh] overflow-auto border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
||||||
|
onselect={select}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
76
src/lib/components/SearchResults.svelte
Normal file
76
src/lib/components/SearchResults.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SearchState } from "$lib/searchState.svelte";
|
||||||
|
import type { SearchResult } from "$lib/search";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
search: SearchState;
|
||||||
|
// Base for the listbox and option element ids (aria-activedescendant)
|
||||||
|
idBase: string;
|
||||||
|
class?: string;
|
||||||
|
onselect: (r: SearchResult) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { search, idBase, class: cls = "", onselect }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id={idBase} role="listbox" aria-label="Search results" class={cls}>
|
||||||
|
{#if search.searching}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-3 w-3 animate-spin rounded-full border border-neutral-300 border-t-neutral-600 dark:border-neutral-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span>Searching…</span>
|
||||||
|
</div>
|
||||||
|
{:else if search.results.length === 0}
|
||||||
|
<div class="px-4 py-2 text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
No results
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#snippet marked(text: string)}
|
||||||
|
{#each search.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 search.results as r, i (r.threadId)}
|
||||||
|
<a
|
||||||
|
id="{idBase}-{i}"
|
||||||
|
href="/thread/{r.threadId}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === search.activeIndex}
|
||||||
|
class="block rounded px-4 py-2 {i === search.activeIndex
|
||||||
|
? 'bg-neutral-100 dark:bg-neutral-800'
|
||||||
|
: ''}"
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onselect(r);
|
||||||
|
}}
|
||||||
|
onmouseenter={() => (search.activeIndex = i)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block truncate font-medium text-neutral-800 dark:text-neutral-200"
|
||||||
|
>
|
||||||
|
{@render marked(r.title)}
|
||||||
|
{#if r.matchKind === "reply"}
|
||||||
|
<span
|
||||||
|
class="ml-1 text-xs font-normal text-neutral-400 dark:text-neutral-500"
|
||||||
|
>reply</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if r.snippet}
|
||||||
|
<span
|
||||||
|
class="block truncate text-sm text-neutral-500 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{@render marked(r.snippet)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
@ -19,6 +19,8 @@ if (MODE === "simple" && !GROUP_ID) {
|
||||||
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
||||||
}
|
}
|
||||||
export const JOINCODE_REQUIRED = env.PUBLIC_JOINCODE === "yes";
|
export const JOINCODE_REQUIRED = env.PUBLIC_JOINCODE === "yes";
|
||||||
|
// Requires a relay with NIP-50 support.
|
||||||
|
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
|
||||||
export const LABELS = (env.PUBLIC_LABELS ?? "")
|
export const LABELS = (env.PUBLIC_LABELS ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
|
|
|
||||||
93
src/lib/search.ts
Normal file
93
src/lib/search.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import type { Filter } from "@nostr/tools/filter";
|
||||||
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
|
||||||
|
export type SearchResult = {
|
||||||
|
threadId: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
matchKind: "thread" | "reply";
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
// (kind 1111), deduped by thread. Simple mode scopes to the single group;
|
||||||
|
// full mode spans every visible room. The group scope is applied client-side
|
||||||
|
// on the `h` tag: pyramid returns nothing when `search` is combined with a
|
||||||
|
// `#h` filter.
|
||||||
|
export async function searchThreads(query: string): Promise<SearchResult[]> {
|
||||||
|
const filter: Filter = { kinds: [11, 1111], search: query, limit: 30 };
|
||||||
|
const groups =
|
||||||
|
MODE === "simple"
|
||||||
|
? new Set([GROUP_ID])
|
||||||
|
: new Set(groupsStore.list.map((g) => g.id));
|
||||||
|
|
||||||
|
const all = await queryForum(filter, { label: "search" });
|
||||||
|
const events = all.filter((e) => {
|
||||||
|
const h = e.tags.find((t) => t[0] === "h")?.[1];
|
||||||
|
return h !== undefined && (groups.size === 0 || groups.has(h));
|
||||||
|
});
|
||||||
|
|
||||||
|
const byThread = new Map<string, SearchResult>();
|
||||||
|
const opless: SearchResult[] = [];
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.kind === 11) {
|
||||||
|
const existing = byThread.get(e.id);
|
||||||
|
// An OP match wins over a reply match on the same thread
|
||||||
|
if (existing && existing.matchKind === "thread") continue;
|
||||||
|
byThread.set(e.id, {
|
||||||
|
threadId: e.id,
|
||||||
|
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
||||||
|
snippet: snippetOf(e.content, query),
|
||||||
|
matchKind: "thread",
|
||||||
|
createdAt: e.created_at,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const root = e.tags.find((t) => t[0] === "E")?.[1];
|
||||||
|
if (!root || byThread.has(root)) continue;
|
||||||
|
const r: SearchResult = {
|
||||||
|
threadId: root,
|
||||||
|
title: "",
|
||||||
|
snippet: snippetOf(e.content, query),
|
||||||
|
matchKind: "reply",
|
||||||
|
createdAt: e.created_at,
|
||||||
|
};
|
||||||
|
byThread.set(root, r);
|
||||||
|
opless.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill titles for reply-only matches; the group relay truncates
|
||||||
|
// multi-id queries, so fetch each OP on its own
|
||||||
|
await Promise.all(
|
||||||
|
opless.map(async (r) => {
|
||||||
|
const ops = await queryForum({ kinds: [11], ids: [r.threadId] });
|
||||||
|
const op: Event | undefined = ops[0];
|
||||||
|
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the relay's relevance order (arrival order); a thread keeps the
|
||||||
|
// position of its best-ranked match
|
||||||
|
return [...byThread.values()];
|
||||||
|
}
|
||||||
24
src/lib/searchModal.svelte.ts
Normal file
24
src/lib/searchModal.svelte.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
export const searchModal = {
|
||||||
|
get open() {
|
||||||
|
return open;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the page hosts an inline search input (the homepage), every search
|
||||||
|
// trigger focuses it instead of opening the modal, so a modal input never
|
||||||
|
// opens on top of an inline one. Resolved through the DOM rather than a
|
||||||
|
// registration callback: it needs no lifecycle bookkeeping and stays correct
|
||||||
|
// even if HMR instantiates this module twice in dev.
|
||||||
|
export function openSearch() {
|
||||||
|
const inline = document.querySelector<HTMLInputElement>(
|
||||||
|
"[data-search-inline]",
|
||||||
|
);
|
||||||
|
if (inline) inline.focus();
|
||||||
|
else open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSearch() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
107
src/lib/searchState.svelte.ts
Normal file
107
src/lib/searchState.svelte.ts
Normal file
|
|
@ -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<SearchResult[]>([]);
|
||||||
|
let resultsQuery = $state(""); // The query that produced the current results
|
||||||
|
let searching = $state(false);
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | 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 <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) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<typeof createSearchState>;
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import ChatSidebar from "$lib/components/ChatSidebar.svelte";
|
import ChatSidebar from "$lib/components/ChatSidebar.svelte";
|
||||||
import LatestDiscussions from "$lib/components/LatestDiscussions.svelte";
|
import LatestDiscussions from "$lib/components/LatestDiscussions.svelte";
|
||||||
import LoginModal from "$lib/components/LoginModal.svelte";
|
import LoginModal from "$lib/components/LoginModal.svelte";
|
||||||
|
import SearchModal from "$lib/components/SearchModal.svelte";
|
||||||
import JoinModal from "$lib/components/JoinModal.svelte";
|
import JoinModal from "$lib/components/JoinModal.svelte";
|
||||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||||
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
||||||
|
|
@ -25,7 +26,12 @@
|
||||||
import { startChat } from "$lib/chat.svelte";
|
import { startChat } from "$lib/chat.svelte";
|
||||||
import { resetForumConnection } from "$lib/relay";
|
import { resetForumConnection } from "$lib/relay";
|
||||||
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
||||||
import { MODE, ACCENT_COLOR, SECONDARY_COLOR } from "$lib/config";
|
import {
|
||||||
|
MODE,
|
||||||
|
ACCENT_COLOR,
|
||||||
|
SECONDARY_COLOR,
|
||||||
|
SEARCH_ENABLED,
|
||||||
|
} from "$lib/config";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
@ -284,6 +290,9 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
|
{#if SEARCH_ENABLED}
|
||||||
|
<SearchModal />
|
||||||
|
{/if}
|
||||||
<JoinModal />
|
<JoinModal />
|
||||||
<NewDiscussionModal />
|
<NewDiscussionModal />
|
||||||
<DeleteModal />
|
<DeleteModal />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
import { overviewStore, loadOverview } from "$lib/overview.svelte";
|
import { overviewStore, loadOverview } from "$lib/overview.svelte";
|
||||||
import { partialsStore } from "$lib/partials.svelte";
|
import { partialsStore } from "$lib/partials.svelte";
|
||||||
import { MODE, GROUP_ID } from "$lib/config";
|
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
|
||||||
|
import SearchInline from "$lib/components/SearchInline.svelte";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
||||||
const partial = $derived(partialsStore.get("home"));
|
const partial = $derived(partialsStore.get("home"));
|
||||||
|
|
@ -51,6 +52,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#if SEARCH_ENABLED}
|
||||||
|
<div class="mt-2 mb-6">
|
||||||
|
<SearchInline />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if partial}
|
{#if partial}
|
||||||
<div class="mt-2 mb-8">
|
<div class="mt-2 mb-8">
|
||||||
<PostContent content={partial.content} headingOffset={0} />
|
<PostContent content={partial.content} headingOffset={0} />
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,35 @@ body {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form inputs in dark mode — the forms plugin defaults to a white background,
|
@layer base {
|
||||||
|
/* Form inputs in dark mode — the forms plugin defaults to a white background,
|
||||||
which would punch holes through panels. Override at the element level so we
|
which would punch holes through panels. Override at the element level so we
|
||||||
don't have to add `dark:bg-neutral-800` to every <input>/<textarea>. */
|
don't have to add `dark:bg-neutral-800` to every <input>/<textarea>. */
|
||||||
:where(.dark) input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]):not([type="color"]):not([type="submit"]):not([type="reset"]):not([type="button"]),
|
:where(.dark) input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]):not([type="color"]):not([type="submit"]):not([type="reset"]):not([type="button"]),
|
||||||
:where(.dark) textarea,
|
:where(.dark) textarea,
|
||||||
:where(.dark) select {
|
:where(.dark) select {
|
||||||
background-color: var(--color-neutral-800);
|
background-color: var(--color-neutral-800);
|
||||||
color: var(--color-neutral-100);
|
color: var(--color-neutral-100);
|
||||||
}
|
}
|
||||||
:where(.dark) input::placeholder,
|
:where(.dark) input::placeholder,
|
||||||
:where(.dark) textarea::placeholder {
|
:where(.dark) textarea::placeholder {
|
||||||
color: var(--color-neutral-500);
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Redraw the native search clear button so it follows our palette instead
|
||||||
|
of the OS accent color. Firefox renders no button at all. */
|
||||||
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 1.2em;
|
||||||
|
height: 1.2em;
|
||||||
|
background-color: var(--color-neutral-400);
|
||||||
|
-webkit-mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="black" d="M12 10.6 6.6 5.2 5.2 6.6l5.4 5.4-5.4 5.4 1.4 1.4 5.4-5.4 5.4 5.4 1.4-1.4-5.4-5.4 5.4-5.4-1.4-1.4z"/></svg>')
|
||||||
|
no-repeat center / contain;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
:where(.dark) input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
background-color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue