Merge branch 'search'

This commit is contained in:
dtonon 2026-07-28 19:22:39 +02:00
commit 475d891b4c
15 changed files with 583 additions and 16 deletions

View file

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

View file

@ -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. |

View file

@ -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"

View file

@ -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">

View file

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

View 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>

View 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}

View 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>

View file

@ -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
View 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()];
}

View 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;
}

View 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>;

View file

@ -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 />

View file

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

View file

@ -31,6 +31,7 @@ body {
display: none; display: none;
} }
@layer base {
/* Form inputs in dark mode the forms plugin defaults to a white background, /* 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>. */
@ -45,6 +46,22 @@ body {
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
typography plugin's em-based heading margins; the first block stays flush. */ typography plugin's em-based heading margins; the first block stays flush. */
.prose :is(h1, h2, h3, h4, h5, h6) { .prose :is(h1, h2, h3, h4, h5, h6) {