Compare commits
No commits in common. "0170570538b1334d0d953ef5722ece6b02e45d7d" and "b18b201fbbc7636d56fa1531ed5cec1cefd6a3b4" have entirely different histories.
0170570538
...
b18b201fbb
11 changed files with 180 additions and 432 deletions
95
homepages.sh
95
homepages.sh
|
|
@ -1,95 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,11 +1 @@
|
||||||
<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#">
|
<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>
|
||||||
<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.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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 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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
<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 type { SearchResult } from "$lib/search";
|
import { searchThreads, 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(() => {
|
||||||
|
|
@ -19,11 +24,67 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|
@ -49,7 +110,7 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close search"
|
aria-label="Close search"
|
||||||
class="absolute inset-0 bg-black/40 dark:bg-black/65"
|
class="absolute inset-0 bg-black/40"
|
||||||
onclick={closeSearch}
|
onclick={closeSearch}
|
||||||
></button>
|
></button>
|
||||||
<div
|
<div
|
||||||
|
|
@ -76,19 +137,19 @@
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value={search.query}
|
bind:value={query}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={search.active}
|
aria-expanded={active}
|
||||||
aria-controls={search.active ? "search-results-modal" : undefined}
|
aria-controls={active ? "search-results" : undefined}
|
||||||
aria-activedescendant={search.activeIndex >= 0
|
aria-activedescendant={activeIndex >= 0
|
||||||
? `search-results-modal-${search.activeIndex}`
|
? `search-result-${activeIndex}`
|
||||||
: undefined}
|
: undefined}
|
||||||
aria-label="Search discussions"
|
aria-label="Search discussions"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
oninput={search.schedule}
|
oninput={schedule}
|
||||||
onkeydown={(e) => search.navigate(e, select)}
|
onkeydown={onInputKeydown}
|
||||||
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
|
||||||
|
|
@ -97,13 +158,75 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if search.active || search.searching}
|
{#if active || searching}
|
||||||
<SearchResults
|
<div
|
||||||
{search}
|
id="search-results"
|
||||||
idBase="search-results-modal"
|
role="listbox"
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -6,17 +6,8 @@ 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() {
|
||||||
const inline = document.querySelector<HTMLInputElement>(
|
open = true;
|
||||||
"[data-search-inline]",
|
|
||||||
);
|
|
||||||
if (inline) inline.focus();
|
|
||||||
else open = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeSearch() {
|
export function closeSearch() {
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
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>;
|
|
||||||
|
|
@ -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 SearchInline from "$lib/components/SearchInline.svelte";
|
import { openSearch } from "$lib/searchModal.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,10 +52,35 @@
|
||||||
{/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}
|
||||||
<div class="mt-2 mb-6">
|
<button
|
||||||
<SearchInline />
|
type="button"
|
||||||
</div>
|
onclick={openSearch}
|
||||||
|
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}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://nostr.basspistol.org/users/{c.npub}"
|
href="https://njump.me/{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"
|
||||||
|
|
|
||||||
|
|
@ -35,17 +35,14 @@ 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>. */
|
||||||
/* Plain .dark (not :where) so these outweigh the forms plugin's white
|
: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"]),
|
||||||
background, which sits in this same base layer; utilities still win via
|
:where(.dark) textarea,
|
||||||
layer order. */
|
:where(.dark) select {
|
||||||
.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);
|
||||||
}
|
}
|
||||||
.dark input::placeholder,
|
:where(.dark) input::placeholder,
|
||||||
.dark textarea::placeholder {
|
:where(.dark) textarea::placeholder {
|
||||||
color: var(--color-neutral-500);
|
color: var(--color-neutral-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,13 +101,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue