Compare commits

..

10 commits

Author SHA1 Message Date
sakrecoer
0170570538 let the content pages live inside a bash script for nak 2026-08-24 13:04:38 +02:00
sakrecoer
0f02a36d1e copy for home pages (about, guidelines, etc) 2026-08-21 14:23:20 +02:00
sakrecoer
b38b8322c7 we haz our own profile pages 2026-08-21 14:23:02 +02:00
sakrecoer
44f2d6b353 we has our own icon 2026-08-21 14:22:49 +02:00
sakrecoer
9197e4d12c hide some forums 2026-08-21 14:21:58 +02:00
dtonon
d61ec05963 Fix dark textareas losing to the forms plugin inside the base layer 2026-07-28 19:51:31 +02:00
dtonon
475d891b4c Merge branch 'search' 2026-07-28 19:22:39 +02:00
dtonon
fecfbaab8b Route search triggers to the inline input via the DOM 2026-07-28 12:03:45 +01:00
dtonon
a5237a5e75 Darken the chat composer in dark mode 2026-07-17 18:22:16 +01:00
dtonon
d256abb42f Split search into shared state with inline homepage input and modal 2026-07-16 13:23:28 +01:00
11 changed files with 432 additions and 180 deletions

95
homepages.sh Normal file
View file

@ -0,0 +1,95 @@
#!/bin/sh
###
# HOME
###
nak event --prompt-sec -k 30023 -c "https://basspistol.org/7c1a1054c24fdf0972fc2259c43cb516f42adf2d95a08e7ad50c8be0dfd5fdfd.png
### This is a forum Managed by nostr:npub15a5jljcjaqgty64hn5v3993zx09yjz2gepwt0q8strvlegdy2q8q6xkqfd — the Outernational Music Syndicate.
It is intended for all things music, including but not limited to technical questions, referals, self-promotion (it's a dirty job, but someone has to do it.). Read more in the [about page](/resource/about)" --tag t=squalk-partial --tag d=home --tag title=Homepage basspistol.org
###
# ABOUT
###
nak event --prompt-sec -k 30023 -c "https://basspistol.org/7c1a1054c24fdf0972fc2259c43cb516f42adf2d95a08e7ad50c8be0dfd5fdfd.png
# About this forum
This is a community forum for people who enjoy, create, promote and grow Music, managed by nostr:npub15a5jljcjaqgty64hn5v3993zx09yjz2gepwt0q8strvlegdy2q8q6xkqfd — the Outernation Music Syndicate. t is intended for all things music, including but not limited to technical questions, referals, self-promotion (it's a dirty job, but someone has to do it.) and runs on Nostr
## What is Nostr?
Nostr — Notes and Other Stuff Transmitted by Relays — is an open protocol for decentralized social communication. There is no central server and no company in control: you own a keypair, your posts are signed events, and relays pass them around. That simplicity makes Nostr a fertile ground for experimentation, and this forum is a place to do that thinking out loud, together.
## What we talk about
Common themes include:
- Feedback — stuck in the creative process, uncertain where to take your tune, wondering if the high-hat cuts too deep? Just ask!
- Promotion and growth — how to launch a project, reach people, write good posts, and tell the story of what you are creating without spamming.
- Synths and software — proposing, refining, and discussing the must have in your studio.
- Showcase — release announcements, demos, and works in progress that deserve eyes and feedback.
- Help and onboarding — a friendly place to ask the obvious questions about how to leverage nostr for your music project, get unstuck, and help newcomers find their footing.
## How it works
Discussions are long-form threads you can follow and search, with a live chat sidebar for quick back-and-forth. Everything is built on open Nostr standards, so your identity and your posts are portable — they are not locked inside this site. Sign in with your Nostr key, join a room, and you are ready to take part.
## A community, not a feed
This forum favors depth over noise. We would rather have one thoughtful thread than ten throwaway hot takes. Be generous with your knowledge, credit the work of others, and keep the tone welcoming.
Before posting, take a moment to read the [Netiquette guidelines](/resource/guidelines) linked in the Resources menu — they describe the simple etiquette that keeps this place worth coming back to.
Welcome aboard, and happy musing." --tag t=squalk-resource --tag d=about --tag title=About basspistol.org
###
# TERMS
###
nak event --prompt-sec -k 30023 -c "https://basspistol.org/7c1a1054c24fdf0972fc2259c43cb516f42adf2d95a08e7ad50c8be0dfd5fdfd.png
# Forum Netiquette
Welcome! This forum works best when everyone treats it as a shared space. These guidelines describe the basic etiquette we expect from every member. They are not rigid rules so much as the habits that keep discussion useful, friendly, and worth coming back to.
## Be respectful
Treat others the way you would want to be treated in person. Disagree with ideas, not people. Personal attacks, insults, harassment, and discriminatory language have no place here. A heated topic is no excuse for contempt — if a thread makes you angry, step away before replying.
## Stay on topic
Post in the room that fits your subject, and keep replies relevant to the thread you are in. If a conversation drifts somewhere interesting, start a new thread for it rather than derailing the original one. Focused threads are easier for everyone to follow and to search later.
## Write so others can read you
Give your thread a descriptive title and take a moment to organize your thoughts. Break long posts into paragraphs, quote only the part you are replying to, and avoid writing in all capitals — it reads as shouting. Before asking a question, search to see whether it has already been answered.
## Add value
Aim to contribute something: an answer, a perspective, a useful link, or a thoughtful question. Avoid one-word replies, duplicate posts, and +1 comments that add noise. If you simply appreciate a post, a reaction is enough — there is no need to bump a thread without new content.
## Respect privacy and ownership
Do not share other people's private information, and do not repost private messages without permission. When you quote or build on someone else's work, credit them. Spam, advertising, and self-promotion that ignores the conversation are not welcome.
## Assume good faith
Tone is hard to read online. Assume the person you are replying to means well, ask for clarification before accusing, and be willing to admit when you were wrong. A short, honest my mistake defuses more arguments than any long rebuttal.
## Welcome newcomers
Everyone was new once. If a newcomer phrases a question awkwardly or misses an existing thread, point them in the right direction kindly instead of mocking them. The way regulars treat beginners sets the tone for the whole community, and patient answers are remembered long after the question is forgotten.
## A note on language
This is an international community, and many members are not writing in their first language. Be patient with grammar and spelling, and prefer plain wording over heavy slang, sarcasm, or inside jokes that are easy to misread. When tone matters, a short clarifying line or a reaction can prevent a misunderstanding before it starts.
## When things go wrong
If you see a post that breaks these guidelines, report it to the moderators instead of starting a public fight. Moderators are volunteers keeping the community healthy: follow their guidance, and reach out privately if you disagree with a decision.
By taking part in this forum, you agree to follow this etiquette. Thanks for helping keep the community welcoming for everyone." --tag t=squalk-resource --tag d=guidelines --tag title=Guidelines basspistol.org

View file

@ -1 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> <svg width="128" height="128" version="1.1" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<title>Basspistol Icon</title>
<path d="m121.15 78.333q0 18.096-16.842 32.608-16.841 14.512-35.295 14.512-8.0624 0-15.587-3.0458-7.3458-3.0458-12.004-6.6291-4.6583-3.5833-9.8541-6.6291-5.0166-3.0458-8.7791-3.0458-3.5833 0-6.6291 2.15-2.8666 2.15-3.0458 5.7333h-3.0458q0.17912-10.212 9.3166-17.558 9.3166-7.3458 19.887-7.5249 7.1666-7.7041 7.1666-28.666v-27.591q0-4.4791-0.35837-7.5249-0.17912-3.225-1.2541-7.7041-0.89588-4.4791-3.5833-6.8083-2.6875-2.5083-6.8083-2.5083-4.3 0-6.8083 3.5833-2.3292 3.5833-2.3292 8.0624 0 5.0166 5.7333 15.229 5.7333 10.033 5.7333 16.842 0 15.946-25.8 24.008l-1.2541-1.9708q8.7791-3.9416 8.7791-10.392 0-4.1208-5.9124-14.154-5.7333-10.033-5.7333-18.454 0-12.362 11.467-20.246 11.646-8.0624 24.725-8.0624 18.454 0 25.8 14.692l21.679-14.692q9.8541 0.53749 17.379 5.7333 7.5249 5.0166 7.5249 14.333 0 8.7791-6.2708 14.692-6.0916 5.9124-15.587 8.9583 12.9 0.7166 20.246 9.8541 7.3458 8.9582 7.3458 22.216zm-27.412-44.791q0-6.4499-3.5833-11.287-3.5833-5.0166-9.6749-5.0166-5.0166 0-10.571 5.1958 1.2541 10.571 1.2541 25.083 0 3.7625 0 8.9582 9.3166-2.8666 15.946-8.5999 6.6291-5.9124 6.6291-14.333zm7.5249 61.274q0-10.212-8.5999-21.858-8.4208-11.646-18.096-11.646-2.3291 0-3.7625 0.71661-0.7166 6.0916-3.5833 10.75-2.6875 4.4791-5.3749 6.4499-2.5083 1.9708-8.4207 5.5541-5.7333 3.5833-8.5999 5.5541 6.2708 1.7917 21.141 11.646 15.05 9.6749 20.783 9.6749 6.6291 0 10.571-5.0166 3.9416-5.0166 3.9416-11.825z" fill="#7fff00" stroke-width="4.4791" style="white-space:pre"/>
<metadata>
<rdf:RDF>
<cc:Work rdf:about="">
<dc:title>Basspistol Icon</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

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

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

@ -1,19 +1,14 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { searchThreads, type SearchResult } from "$lib/search"; import type { SearchResult } from "$lib/search";
import { createSearchState } from "$lib/searchState.svelte";
import { searchModal, openSearch, closeSearch } from "$lib/searchModal.svelte"; import { searchModal, openSearch, closeSearch } from "$lib/searchModal.svelte";
import SearchResults from "$lib/components/SearchResults.svelte";
const search = 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 inputEl = $state<HTMLInputElement | null>(null); let inputEl = $state<HTMLInputElement | null>(null);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
const active = $derived(resultsQuery.trim().length >= 2);
// Focus (and select, so a stale query is typed over) when the modal opens // Focus (and select, so a stale query is typed over) when the modal opens
$effect(() => { $effect(() => {
@ -24,67 +19,11 @@
}); });
}); });
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 select(r: SearchResult) { function select(r: SearchResult) {
closeSearch(); closeSearch();
goto(`/thread/${r.threadId}`); goto(`/thread/${r.threadId}`);
} }
function onInputKeydown(e: KeyboardEvent) {
if (results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndex = (activeIndex + 1) % results.length;
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndex = (activeIndex - 1 + results.length) % results.length;
} else if (e.key === "Enter" && activeIndex >= 0) {
e.preventDefault();
select(results[activeIndex]);
}
}
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) }));
}
// Global "/" opens the search from any page, unless typing somewhere else // Global "/" opens the search from any page, unless typing somewhere else
function onWindowKeydown(e: KeyboardEvent) { function onWindowKeydown(e: KeyboardEvent) {
if (searchModal.open) { if (searchModal.open) {
@ -110,7 +49,7 @@
<button <button
type="button" type="button"
aria-label="Close search" aria-label="Close search"
class="absolute inset-0 bg-black/40" class="absolute inset-0 bg-black/40 dark:bg-black/65"
onclick={closeSearch} onclick={closeSearch}
></button> ></button>
<div <div
@ -137,19 +76,19 @@
</svg> </svg>
<input <input
bind:this={inputEl} bind:this={inputEl}
bind:value={query} bind:value={search.query}
type="search" type="search"
placeholder="Search" placeholder="Search"
role="combobox" role="combobox"
aria-expanded={active} aria-expanded={search.active}
aria-controls={active ? "search-results" : undefined} aria-controls={search.active ? "search-results-modal" : undefined}
aria-activedescendant={activeIndex >= 0 aria-activedescendant={search.activeIndex >= 0
? `search-result-${activeIndex}` ? `search-results-modal-${search.activeIndex}`
: undefined} : undefined}
aria-label="Search discussions" aria-label="Search discussions"
autocomplete="off" autocomplete="off"
oninput={schedule} oninput={search.schedule}
onkeydown={onInputKeydown} 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" 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 <kbd
@ -158,75 +97,13 @@
> >
</div> </div>
{#if active || searching} {#if search.active || search.searching}
<div <SearchResults
id="search-results" {search}
role="listbox" idBase="search-results-modal"
aria-label="Search results"
class="mt-3 max-h-[50vh] overflow-auto border-t border-neutral-100 pt-2 dark:border-neutral-800" class="mt-3 max-h-[50vh] overflow-auto border-t border-neutral-100 pt-2 dark:border-neutral-800"
> onselect={select}
{#if 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 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 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)}
<a
id="search-result-{i}"
href="/thread/{r.threadId}"
role="option"
aria-selected={i === activeIndex}
class="block rounded px-4 py-2 {i === activeIndex
? 'bg-neutral-100 dark:bg-neutral-800'
: ''}"
onmousedown={(e) => {
e.preventDefault();
select(r);
}}
onmouseenter={() => (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>
{/if} {/if}
</div> </div>
</div> </div>

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

@ -6,8 +6,17 @@ export const searchModal = {
}, },
}; };
// 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() { export function openSearch() {
open = true; const inline = document.querySelector<HTMLInputElement>(
"[data-search-inline]",
);
if (inline) inline.focus();
else open = true;
} }
export function closeSearch() { export function closeSearch() {

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

@ -6,7 +6,7 @@
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, SEARCH_ENABLED } from "$lib/config"; 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"; import type { NostrUser } from "@nostr/gadgets/metadata";
const partial = $derived(partialsStore.get("home")); const partial = $derived(partialsStore.get("home"));
@ -52,35 +52,10 @@
{/if} {/if}
{/snippet} {/snippet}
<!-- Looks like the search input but only opens the global search modal, so
there is a single search implementation. -->
{#if SEARCH_ENABLED} {#if SEARCH_ENABLED}
<button <div class="mt-2 mb-6">
type="button" <SearchInline />
onclick={openSearch} </div>
class="focus:ring-accent relative mt-2 mb-6 flex w-full items-center gap-3 rounded border border-neutral-200 bg-neutral-100 px-4 py-2 text-neutral-400 focus:ring-1 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
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>
Search
<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
>
</button>
{/if} {/if}
{#if partial} {#if partial}

View file

@ -117,7 +117,7 @@
{/if} {/if}
</div> </div>
<a <a
href="https://njump.me/{c.npub}" href="https://nostr.basspistol.org/users/{c.npub}"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-accent shrink-0 text-sm hover:underline" class="text-accent shrink-0 text-sm hover:underline"

View file

@ -35,14 +35,17 @@ body {
/* 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>. */
: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"]), /* Plain .dark (not :where) so these outweigh the forms plugin's white
:where(.dark) textarea, background, which sits in this same base layer; utilities still win via
:where(.dark) select { layer order. */
.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"]),
.dark textarea,
.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, .dark input::placeholder,
:where(.dark) textarea::placeholder { .dark textarea::placeholder {
color: var(--color-neutral-500); color: var(--color-neutral-500);
} }
@ -101,3 +104,13 @@ body {
--color-secondary: #ffaf25; --color-secondary: #ffaf25;
--color-secondary-hover: #e69e1a; --color-secondary-hover: #e69e1a;
} }
/* setto */
a[href*="iapochoopl"] {
display:none
}
a[href*="a1aad3a97e1dbec6ca85de96a3fc96249595c545f6085baa75b14761f85e6669"] {
display:none
}