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