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.
 
 
 
 
 

509 lines
14 KiB

<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import PageHeader from '../../lib/components/layout/PageHeader.svelte';
import NormalSearch from '../../lib/components/find/NormalSearch.svelte';
import SearchAddressableEvents from '../../lib/components/find/SearchAddressableEvents.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 { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { untrack } from 'svelte';
import { afterNavigate } from '$app/navigation';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
let normalSearchComponent: { clearSearch: () => void; getSearchResults: () => { events: NostrEvent[]; profiles: string[] } } | null = $state(null);
let addressableSearchComponent: { setSearchQuery: (query: string) => void; clearSearch: () => void } | null = $state(null);
// Combined results from both search types
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>();
// Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedCacheEvents = $derived(
cacheResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(cacheResults.events, currentPage, ITEMS_PER_PAGE)
: cacheResults.events
);
let paginatedSearchEvents = $derived(
searchResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(searchResults.events, currentPage, ITEMS_PER_PAGE)
: searchResults.events
);
// Check if there are any active searches or results
const hasActiveSearch = $derived.by(() => {
if (searchResults.events.length > 0 || searchResults.profiles.length > 0) return true;
if (cacheResults.events.length > 0 || cacheResults.profiles.length > 0) return true;
return false;
});
function handleNormalSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) {
// Check if these are cache-only results
const isCacheOnly = !results.relays || results.relays.length === 0 ||
(results.eventRelays && Array.from(results.eventRelays.values()).every(r => r === 'cache'));
if (isCacheOnly) {
cacheResults = { events: results.events, profiles: results.profiles };
} else {
searchResults = results;
if (results.eventRelays) {
for (const [eventId, relay] of results.eventRelays) {
eventRelayMap.set(eventId, relay);
}
}
}
}
function clearAllSearches() {
if (normalSearchComponent) {
normalSearchComponent.clearSearch();
}
if (addressableSearchComponent) {
addressableSearchComponent.clearSearch();
}
searchResults = { events: [], profiles: [] };
cacheResults = { events: [], profiles: [] };
eventRelayMap.clear();
}
// Track last processed query to prevent infinite loops
let lastProcessedQuery = $state<string | null>(null);
let isInitialized = $state(false);
onMount(async () => {
await nostrClient.initialize();
isInitialized = true;
// Check for query parameter and trigger search
const queryParam = $page.url.searchParams.get('q');
if (queryParam && addressableSearchComponent) {
lastProcessedQuery = queryParam;
addressableSearchComponent.setSearchQuery(queryParam);
}
});
// React to URL changes using afterNavigate to avoid reactive loops
afterNavigate(({ to }) => {
if (!isInitialized || !addressableSearchComponent || !to) return;
const queryParam = to.url.searchParams.get('q');
// Only update if the query param actually changed
if (queryParam !== lastProcessedQuery) {
lastProcessedQuery = queryParam;
// Use untrack when calling setSearchQuery to prevent reactive loops
const component = addressableSearchComponent;
untrack(() => {
if (queryParam) {
component.setSearchQuery(queryParam);
} else {
component.clearSearch();
}
});
}
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="find-page">
<PageHeader title="/Find" onRefresh={clearAllSearches} />
<div class="page-header">
{#if hasActiveSearch}
<button
class="clear-button"
onclick={clearAllSearches}
aria-label="Clear all searches"
>
Clear
</button>
{/if}
</div>
{#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 paginatedCacheEvents 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 cacheResults.events.length > ITEMS_PER_PAGE}
<Pagination totalItems={cacheResults.events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/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 paginatedSearchEvents 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 searchResults.events.length > ITEMS_PER_PAGE}
<Pagination totalItems={searchResults.events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/if}
</div>
{/if}
</section>
{/if}
<div class="find-sections" class:collapsed={hasActiveSearch}>
<section class="find-section">
<NormalSearch
bind:this={normalSearchComponent}
onSearchResults={handleNormalSearchResults}
/>
</section>
<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: 1rem;
gap: 1rem;
margin-top: -1rem;
}
@media (min-width: 640px) {
.page-header {
margin-bottom: 1.5rem;
}
}
.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.875em;
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: 1.5rem;
}
.find-sections.collapsed {
display: none;
}
@media (min-width: 640px) {
.find-sections {
gap: 2rem;
}
}
@media (min-width: 1024px) {
.find-sections {
gap: 3rem;
}
}
.find-section {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
background: var(--fog-post, #ffffff);
}
@media (min-width: 640px) {
.find-section {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.find-section {
padding: 2rem;
}
}
:global(.dark) .find-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-section {
margin-top: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
background: var(--fog-post, #ffffff);
}
@media (min-width: 640px) {
.results-section {
padding: 1.5rem;
margin-bottom: 2rem;
}
}
@media (min-width: 1024px) {
.results-section {
padding: 2rem;
}
}
:global(.dark) .results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-section h2 {
margin: 0 0 1rem 0;
font-size: 1.25em;
font-weight: 600;
color: var(--fog-text, #475569);
}
@media (min-width: 640px) {
.results-section h2 {
margin: 0 0 1.5rem 0;
}
}
: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 0.75rem 0;
font-size: 1em;
font-weight: 500;
color: var(--fog-text-light, #52667a);
}
@media (min-width: 640px) {
.results-group h3 {
margin: 0 0 1rem 0;
}
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #a8b8d0);
}
.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.25rem 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-highlight, #f3f4f6);
display: flex;
align-items: center;
}
:global(.dark) .event-relay-badge {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
/* Make badges smaller to match feed page - scale down proportionally */
.event-relay-badge :global(.cache-badge),
.event-relay-badge :global(.relay-badge) {
font-size: 0.625em;
padding: 0.125rem 0.375rem;
gap: 0.125rem;
}
.event-relay-badge :global(.cache-icon) {
width: 0.625rem;
height: 0.625rem;
}
</style>