You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

666 lines
18 KiB

<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import RelayBadge from '../../lib/components/layout/RelayBadge.svelte';
import CacheBadge from '../../lib/components/layout/CacheBadge.svelte';
import SearchAddressableEvents from '../../lib/components/find/SearchAddressableEvents.svelte';
import { KIND, KIND_LOOKUP } from '../../lib/types/kind-lookup.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
let selectedKind = $state<number | null>(null);
let selectedKindString = $state<string>('');
let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }; clearSearch: () => void } | null = $state(null);
let addressableSearchComponent: { clearSearch: () => void } | null = $state(null);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
let cacheResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null);
// Check if there are any active searches or results
const hasActiveSearch = $derived.by(() => {
if (searching) return true;
if (searchResults.events.length > 0 || searchResults.profiles.length > 0) return true;
if (cacheResults.events.length > 0 || cacheResults.profiles.length > 0) return true;
const component = unifiedSearchComponent;
if (component) {
const filterResult = component.getFilterResult();
if (filterResult.value !== null) return true;
}
return false;
});
// Sync selectedKindString with selectedKind
$effect(() => {
selectedKindString = selectedKind?.toString() || '';
});
// Sync selectedKind with selectedKindString when it changes
$effect(() => {
if (selectedKindString === '') {
selectedKind = null;
} else {
const parsed = parseInt(selectedKindString);
if (!isNaN(parsed)) {
selectedKind = parsed;
}
}
});
function handleKindChange(e: Event) {
const select = e.target as HTMLSelectElement;
selectedKindString = select.value;
}
function handleSearch() {
if (unifiedSearchComponent) {
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
searching = true;
unifiedSearchComponent.triggerSearch();
// Set 10 second timeout
searchTimeout = setTimeout(() => {
searching = false;
searchTimeout = null;
}, 10000);
}
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) {
// Check if these are cache-only results (no relays or all events from cache)
const isCacheOnly = !results.relays || results.relays.length === 0 ||
(results.eventRelays && Array.from(results.eventRelays.values()).every(r => r === 'cache'));
if (isCacheOnly) {
// These are cache results
cacheResults = { events: results.events, profiles: results.profiles };
} else {
// These are relay results (from button click)
searchResults = results;
// Update eventRelayMap with relay information from results
if (results.eventRelays) {
for (const [eventId, relay] of results.eventRelays) {
eventRelayMap.set(eventId, relay);
}
}
// Clear timeout when results arrive (search is complete)
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
searching = false;
}
}
}
// Get all kinds for dropdown (sorted by number)
const allKinds = Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number);
function clearAllSearches() {
// Clear UnifiedSearch
if (unifiedSearchComponent) {
unifiedSearchComponent.clearSearch();
}
// Clear SearchAddressableEvents
if (addressableSearchComponent) {
addressableSearchComponent.clearSearch();
}
// Clear local state
searchResults = { events: [], profiles: [] };
cacheResults = { events: [], profiles: [] };
eventRelayMap.clear();
searching = false;
selectedKind = null;
selectedKindString = '';
// Clear timeouts
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
}
onMount(async () => {
await nostrClient.initialize();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="find-page">
<div class="page-header">
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Find</h1>
{#if hasActiveSearch}
<button
class="clear-button"
onclick={clearAllSearches}
aria-label="Clear all searches"
>
Clear
</button>
{/if}
</div>
<div class="find-sections">
<section class="find-section">
<h2>Search Events</h2>
<p class="section-description">Search for events by ID, pubkey, NIP-05, or content. Use the kind filter to narrow results.</p>
<div class="search-container">
<div class="search-bar-wrapper">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
selectedKind={selectedKind}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
placeholder="Search events, profiles, pubkeys, or enter event ID..."
/>
</div>
<div class="filter-and-button-wrapper">
<div class="kind-filter-wrapper">
<label for="kind-filter" class="kind-filter-label">Filter by Kind:</label>
<select
id="kind-filter"
bind:value={selectedKindString}
onchange={handleKindChange}
class="kind-filter-select"
aria-label="Filter by kind"
>
<option value="">All Kinds</option>
{#each allKinds as kindInfo}
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option>
{/each}
</select>
</div>
<button
class="search-button"
onclick={handleSearch}
disabled={searching}
aria-label="Search"
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
</section>
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0}
<section class="results-section">
<h2>Search Results</h2>
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0}
<div class="results-group">
<h3>Found in Cache:</h3>
{#if cacheResults.profiles.length > 0}
<div class="profile-results">
{#each cacheResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
<CacheBadge />
</a>
{/each}
</div>
{/if}
{#if cacheResults.events.length > 0}
<div class="event-results">
{#each cacheResults.events as event}
<div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} />
</a>
<div class="event-relay-badge">
<CacheBadge />
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<div class="results-group">
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0}
<h3>Results from Relays:</h3>
{:else}
<h3>Search Results</h3>
{/if}
{#if searchResults.profiles.length > 0}
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{/each}
</div>
{/if}
{#if searchResults.events.length > 0}
<div class="event-results">
{#each searchResults.events as event}
<div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} />
</a>
{#if eventRelayMap.has(event.id)}
<div class="event-relay-badge">
{#if eventRelayMap.get(event.id) === 'cache'}
<CacheBadge />
{:else}
<RelayBadge relayUrl={eventRelayMap.get(event.id)!} />
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</section>
{:else if !searching && unifiedSearchComponent && unifiedSearchComponent.getFilterResult().value}
<section class="results-section">
<div class="no-results">
No results found on the relays: {searchResults.relays && searchResults.relays.length > 0 ? searchResults.relays.join(', ') : 'No relays available'}
</div>
</section>
{/if}
<section class="find-section">
<SearchAddressableEvents bind:this={addressableSearchComponent} />
</section>
</div>
</div>
</main>
<style>
.find-page {
max-width: var(--content-width);
margin: 0 auto;
padding: 0 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.clear-button {
padding: 0.5rem 1rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #475569);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
transition: all 0.2s;
}
:global(.dark) .clear-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #cbd5e1);
border-color: var(--fog-dark-border, #475569);
}
.clear-button:hover {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .clear-button:hover {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.clear-button {
width: 100%;
}
}
.find-sections {
display: flex;
flex-direction: column;
gap: 3rem;
}
.find-section {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .find-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.find-section h2 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .find-section h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.section-description {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #9ca3af);
}
.search-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-bar-wrapper {
width: 100%;
}
.filter-and-button-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.filter-and-button-wrapper {
flex-direction: row;
align-items: flex-end;
}
}
.kind-filter-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
@media (min-width: 640px) {
.kind-filter-wrapper {
flex-direction: row;
align-items: center;
flex: 1;
}
}
.kind-filter-label {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
font-weight: 500;
white-space: nowrap;
}
:global(.dark) .kind-filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.kind-filter-select {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
cursor: pointer;
width: 100%;
font-family: inherit;
}
@media (min-width: 640px) {
.kind-filter-select {
width: auto;
min-width: 200px;
}
}
.kind-filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .kind-filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
white-space: nowrap;
transition: all 0.2s;
min-width: 100px;
}
@media (min-width: 640px) {
.search-button {
min-width: auto;
}
}
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.search-button:active:not(:disabled) {
transform: translateY(0);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.results-section {
margin-top: 2rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-section h2 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .results-section h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.results-group {
margin-bottom: 2rem;
}
.results-group:last-child {
margin-bottom: 0;
}
.results-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
}
.profile-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-result-card {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
text-decoration: none;
transition: all 0.2s;
}
:global(.dark) .profile-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.profile-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .profile-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-result-card {
display: flex;
flex-direction: column;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
transition: all 0.2s;
}
:global(.dark) .event-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-result-link {
display: block;
text-decoration: none;
color: inherit;
flex: 1;
}
.event-relay-badge {
padding: 0.5rem 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .event-relay-badge {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.event-result-link {
display: block;
text-decoration: none;
color: inherit;
flex: 1;
}
.event-relay-badge {
padding: 0.5rem 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .event-relay-badge {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.no-results {
padding: 2rem;
text-align: center;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .no-results {
color: var(--fog-dark-text-light, #9ca3af);
}
</style>