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.
995 lines
32 KiB
995 lines
32 KiB
<script lang="ts"> |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { getEventsByKind } from '../../services/cache/event-cache.js'; |
|
import ProfileBadge from '../layout/ProfileBadge.svelte'; |
|
import RelayBadge from '../layout/RelayBadge.svelte'; |
|
import CacheBadge from '../layout/CacheBadge.svelte'; |
|
import { getKindInfo, KIND_LOOKUP, isParameterizedReplaceableKind } from '../../types/kind-lookup.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { goto } from '$app/navigation'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
let searchQuery = $state(''); |
|
let searching = $state(false); |
|
let results = $state<Array<{ event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>>([]); |
|
let cacheResults = $state<Array<{ event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>>([]); |
|
let timeoutId: ReturnType<typeof setTimeout> | null = $state(null); |
|
let cacheSearchTimeoutId: ReturnType<typeof setTimeout> | null = $state(null); |
|
|
|
// Tag search order (by relevance) |
|
const searchTags = ['d', 'T', 'C', 'title', 'author', 'summary', 'description']; |
|
const SEARCH_TIMEOUT = 10000; // 10 seconds |
|
const CACHE_SEARCH_DEBOUNCE = 500; // 500ms debounce for cache search |
|
|
|
// Get only the parameterized replaceable kinds that are actually defined (computed once) |
|
const parameterizedKinds = Object.keys(KIND_LOOKUP) |
|
.map(Number) |
|
.filter(kind => isParameterizedReplaceableKind(kind)); |
|
|
|
// Map to track results by event ID to avoid duplicates |
|
const resultsMap = new Map<string, { event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>(); |
|
// Map to track cache results by event ID |
|
const cacheResultsMap = new Map<string, { event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>(); |
|
// Map to track which relay each event came from |
|
const eventRelayMap = new Map<string, string>(); |
|
|
|
function scoreEvent(event: NostrEvent, query: string): { tag: string; value: string; relevance: number } | null { |
|
let bestMatch: { tag: string; value: string; relevance: number } | null = null; |
|
|
|
// Check each tag in order of relevance |
|
for (let i = 0; i < searchTags.length; i++) { |
|
const tagName = searchTags[i]; |
|
const relevance = searchTags.length - i; // Higher number = more relevant |
|
|
|
// Check both lowercase and original case tags |
|
const tagVariants = [tagName, tagName.toLowerCase(), tagName.toUpperCase()]; |
|
|
|
for (const tagVariant of tagVariants) { |
|
const tags = event.tags.filter(t => t[0] === tagVariant && t[1]); |
|
|
|
for (const tag of tags) { |
|
const value = tag[1]?.toLowerCase() || ''; |
|
|
|
// Check if query matches (exact match, contains, or word boundary match) |
|
if (value === query) { |
|
// Exact match - highest priority |
|
if (!bestMatch || bestMatch.relevance < relevance * 10) { |
|
bestMatch = { tag: tagVariant, value: tag[1] || '', relevance: relevance * 10 }; |
|
} |
|
} else if (value.includes(query)) { |
|
// Contains match |
|
if (!bestMatch || bestMatch.relevance < relevance) { |
|
bestMatch = { tag: tagVariant, value: tag[1] || '', relevance }; |
|
} |
|
} else if (query.includes(' ') || query.includes('-')) { |
|
// For multi-word queries, check if all words are present |
|
const queryWords = query.split(/[\s-]+/).filter(w => w.length > 0); |
|
const allWordsMatch = queryWords.every(word => value.includes(word)); |
|
|
|
if (allWordsMatch && (!bestMatch || bestMatch.relevance < relevance * 0.8)) { |
|
bestMatch = { tag: tagVariant, value: tag[1] || '', relevance: relevance * 0.8 }; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
return bestMatch; |
|
} |
|
|
|
function addResult(event: NostrEvent, query: string, relay?: string, isCache: boolean = false) { |
|
const targetMap = isCache ? cacheResultsMap : resultsMap; |
|
const limit = 100; |
|
|
|
// Limit to 100 results total |
|
if (targetMap.size >= limit) { |
|
return; |
|
} |
|
|
|
const bestMatch = scoreEvent(event, query); |
|
if (bestMatch) { |
|
// Track relay source |
|
if (relay) { |
|
eventRelayMap.set(event.id, relay); |
|
} |
|
|
|
const result = { |
|
event, |
|
matchedTag: bestMatch.tag, |
|
matchedValue: bestMatch.value, |
|
relevance: bestMatch.relevance, |
|
relay: relay || (isCache ? 'cache' : eventRelayMap.get(event.id)) |
|
}; |
|
|
|
// Update or add result |
|
targetMap.set(event.id, result); |
|
|
|
// Update results array (sorted by relevance, limit to 100) |
|
if (isCache) { |
|
cacheResults = Array.from(cacheResultsMap.values()) |
|
.sort((a, b) => b.relevance - a.relevance) |
|
.slice(0, limit); |
|
} else { |
|
results = Array.from(resultsMap.values()) |
|
.sort((a, b) => b.relevance - a.relevance) |
|
.slice(0, limit); |
|
} |
|
} |
|
} |
|
|
|
// Search cache only (debounced) |
|
async function searchCache() { |
|
if (!searchQuery.trim()) { |
|
cacheResults = []; |
|
cacheResultsMap.clear(); |
|
return; |
|
} |
|
|
|
const query = searchQuery.trim(); |
|
cacheResults = []; |
|
cacheResultsMap.clear(); |
|
|
|
try { |
|
// Check if it's an naddr |
|
if (/^naddr1[a-z0-9]+$/i.test(query)) { |
|
try { |
|
const decoded = nip19.decode(query); |
|
if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) { |
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string }; |
|
const kind = naddrData.kind; |
|
const pubkey = String(naddrData.pubkey); |
|
const dTag = String(naddrData.identifier); |
|
|
|
// Search cache for event with matching kind, pubkey, and d tag |
|
const cachedByKind = await getEventsByKind(kind, 1000); |
|
const event = cachedByKind.find(e => |
|
e.pubkey.toLowerCase() === pubkey.toLowerCase() && |
|
e.tags.some(t => t[0] === 'd' && t[1] === dTag) |
|
); |
|
|
|
if (event) { |
|
addResult(event, dTag, 'cache', true); |
|
return; |
|
} |
|
} |
|
} catch { |
|
// Not a valid naddr, continue with regular search |
|
} |
|
} |
|
|
|
const queryLower = query.toLowerCase(); |
|
|
|
// Get all cached events for these kinds |
|
const allCachedEvents: NostrEvent[] = []; |
|
for (const kind of parameterizedKinds) { |
|
const cached = await getEventsByKind(kind, 1000); // Get up to 1000 per kind |
|
allCachedEvents.push(...cached); |
|
} |
|
|
|
// Filter and score events |
|
for (const event of allCachedEvents) { |
|
if (cacheResultsMap.size < 100) { |
|
addResult(event, queryLower, 'cache', true); |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error searching cache:', error); |
|
} |
|
} |
|
|
|
// Debounced cache search |
|
let isSearchingCache = $state(false); |
|
|
|
$effect(() => { |
|
// Only react to searchQuery changes, not cacheResults |
|
const query = searchQuery.trim(); |
|
|
|
// Clear previous timeout |
|
if (cacheSearchTimeoutId) { |
|
clearTimeout(cacheSearchTimeoutId); |
|
cacheSearchTimeoutId = null; |
|
} |
|
|
|
if (query) { |
|
cacheSearchTimeoutId = setTimeout(() => { |
|
if (!isSearchingCache) { |
|
isSearchingCache = true; |
|
searchCache().finally(() => { |
|
isSearchingCache = false; |
|
}); |
|
} |
|
}, CACHE_SEARCH_DEBOUNCE); |
|
} else { |
|
// Only clear if we're not currently searching |
|
if (!isSearchingCache) { |
|
cacheResults = []; |
|
cacheResultsMap.clear(); |
|
} |
|
} |
|
|
|
return () => { |
|
if (cacheSearchTimeoutId) { |
|
clearTimeout(cacheSearchTimeoutId); |
|
cacheSearchTimeoutId = null; |
|
} |
|
}; |
|
}); |
|
|
|
async function search() { |
|
if (!searchQuery.trim()) { |
|
results = []; |
|
resultsMap.clear(); |
|
return; |
|
} |
|
|
|
// Clear previous timeout |
|
if (timeoutId) { |
|
clearTimeout(timeoutId); |
|
timeoutId = null; |
|
} |
|
|
|
searching = true; |
|
results = []; |
|
resultsMap.clear(); |
|
eventRelayMap.clear(); |
|
|
|
const query = searchQuery.trim(); |
|
|
|
// Set timeout |
|
timeoutId = setTimeout(() => { |
|
searching = false; |
|
timeoutId = null; |
|
}, SEARCH_TIMEOUT); |
|
|
|
try { |
|
// Check if it's an naddr |
|
if (/^naddr1[a-z0-9]+$/i.test(query)) { |
|
try { |
|
const decoded = nip19.decode(query); |
|
if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) { |
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string; relays?: string[] }; |
|
const kind = naddrData.kind; |
|
const pubkey = String(naddrData.pubkey); |
|
const dTag = String(naddrData.identifier); |
|
|
|
// Use relay hints from naddr if available, otherwise use default relays |
|
const relays = naddrData.relays && naddrData.relays.length > 0 |
|
? naddrData.relays |
|
: relayManager.getProfileReadRelays(); |
|
|
|
// Fetch the specific event |
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], |
|
relays, |
|
{ useCache: 'cache-first', cacheResults: true, timeout: SEARCH_TIMEOUT } |
|
); |
|
|
|
if (events.length > 0) { |
|
const event = events[0]; |
|
addResult(event, dTag, relays[0] || 'cache'); |
|
// Clear timeout |
|
if (timeoutId) { |
|
clearTimeout(timeoutId); |
|
timeoutId = null; |
|
} |
|
searching = false; |
|
return; |
|
} |
|
} |
|
} catch { |
|
// Not a valid naddr, continue with regular search |
|
} |
|
} |
|
|
|
const queryLower = query.toLowerCase(); |
|
// Normalize query: remove spaces, handle hyphens |
|
const normalizedQuery = queryLower.replace(/\s+/g, '-'); |
|
const queryWords = queryLower.split(/[\s-]+/).filter(w => w.length > 0); |
|
|
|
const relays = relayManager.getProfileReadRelays(); |
|
|
|
const allQueries: any[] = []; |
|
|
|
// Build queries with tag filters for better search results |
|
// Only query the specific parameterized replaceable kinds we know about |
|
|
|
// Search by d-tag (most specific) - try both original and normalized |
|
// Also try each word as a potential d-tag match |
|
allQueries.push( |
|
{ kinds: parameterizedKinds, '#d': [queryLower], limit: 50 }, |
|
{ kinds: parameterizedKinds, '#d': [normalizedQuery], limit: 50 } |
|
); |
|
for (const word of queryWords) { |
|
if (word.length > 2) { // Only search for words longer than 2 chars |
|
allQueries.push({ kinds: parameterizedKinds, '#d': [word], limit: 50 }); |
|
} |
|
} |
|
|
|
// Search by T-tag (topics/tags) for each word |
|
for (const word of queryWords) { |
|
if (word.length > 0) { |
|
allQueries.push({ kinds: parameterizedKinds, '#T': [word], limit: 50 }); |
|
} |
|
} |
|
|
|
// Search by C-tag (categories) for each word |
|
for (const word of queryWords) { |
|
if (word.length > 0) { |
|
allQueries.push({ kinds: parameterizedKinds, '#C': [word], limit: 50 }); |
|
} |
|
} |
|
|
|
// Search by title tag |
|
allQueries.push({ kinds: parameterizedKinds, '#title': [queryLower], limit: 50 }); |
|
for (const word of queryWords) { |
|
if (word.length > 0) { |
|
allQueries.push({ kinds: parameterizedKinds, '#title': [word], limit: 50 }); |
|
} |
|
} |
|
|
|
// Also do a broader search without tag filters to catch partial matches |
|
// This will find events where the query appears in d-tag but not as exact match |
|
allQueries.push({ kinds: parameterizedKinds, limit: 200 }); |
|
|
|
// Use onUpdateWithRelay to process events as they arrive with relay info |
|
await nostrClient.fetchEvents( |
|
allQueries, |
|
relays, |
|
{ |
|
useCache: 'cache-first', |
|
cacheResults: true, |
|
timeout: SEARCH_TIMEOUT, |
|
onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => { |
|
// Process events incrementally as they arrive (limit to 100 total) |
|
for (const { event, relay } of eventsWithRelay) { |
|
if (!resultsMap.has(event.id) && resultsMap.size < 100) { |
|
addResult(event, queryLower, relay); |
|
} |
|
} |
|
} |
|
} |
|
); |
|
|
|
// Clear timeout if fetch completes before timeout |
|
if (timeoutId) { |
|
clearTimeout(timeoutId); |
|
timeoutId = null; |
|
} |
|
} catch (error) { |
|
console.error('Error searching addressable events:', error); |
|
} finally { |
|
searching = false; |
|
} |
|
} |
|
|
|
|
|
function getTagValue(event: NostrEvent, tagName: string): string | null { |
|
const tagVariants = [tagName, tagName.toLowerCase(), tagName.toUpperCase()]; |
|
for (const variant of tagVariants) { |
|
const tag = event.tags.find(t => t[0] === variant && t[1]); |
|
if (tag && tag[1]) { |
|
return tag[1]; |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
function getAllTagValues(event: NostrEvent, tagName: string): string[] { |
|
const tagVariants = [tagName, tagName.toLowerCase(), tagName.toUpperCase()]; |
|
const values: string[] = []; |
|
for (const variant of tagVariants) { |
|
const tags = event.tags.filter(t => t[0] === variant && t[1]); |
|
for (const tag of tags) { |
|
if (tag[1] && !values.includes(tag[1])) { |
|
values.push(tag[1]); |
|
} |
|
} |
|
} |
|
return values; |
|
} |
|
|
|
function getImageTag(event: NostrEvent): string | null { |
|
const imageTag = event.tags.find(t => t[0] === 'image' && t[1]); |
|
return imageTag?.[1] || null; |
|
} |
|
|
|
function handleResultClick(event: NostrEvent) { |
|
const dTag = getTagValue(event, 'd'); |
|
if (dTag) { |
|
// Navigate to /replaceable/[d_tag] route |
|
goto(`/replaceable/${encodeURIComponent(dTag)}`); |
|
} |
|
} |
|
|
|
function handleResultKeydown(e: KeyboardEvent, event: NostrEvent) { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
handleResultClick(event); |
|
} |
|
} |
|
|
|
function highlightText(text: string, query: string, isMatch: boolean): string { |
|
if (!isMatch || !query) return text; |
|
|
|
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); |
|
return text.replace(regex, '<mark>$1</mark>'); |
|
} |
|
|
|
export function clearSearch() { |
|
searchQuery = ''; |
|
results = []; |
|
cacheResults = []; |
|
resultsMap.clear(); |
|
cacheResultsMap.clear(); |
|
searching = false; |
|
if (timeoutId) { |
|
clearTimeout(timeoutId); |
|
timeoutId = null; |
|
} |
|
if (cacheSearchTimeoutId) { |
|
clearTimeout(cacheSearchTimeoutId); |
|
cacheSearchTimeoutId = null; |
|
} |
|
} |
|
|
|
export function hasActiveSearch(): boolean { |
|
return searching || searchQuery.trim().length > 0 || results.length > 0 || cacheResults.length > 0; |
|
} |
|
</script> |
|
|
|
<div class="addressable-search"> |
|
<h2>Search Addressable Events</h2> |
|
<p class="section-description"> |
|
Search through parameterized events with d-tags, such as long-form articles, wiki pages, or e-books/publications |
|
</p> |
|
|
|
<div class="search-container"> |
|
<div class="search-bar-wrapper"> |
|
<input |
|
type="text" |
|
bind:value={searchQuery} |
|
placeholder="e.g., jane eyre, jane-eyre, Charlotte Bronte..." |
|
class="search-input" |
|
disabled={searching} |
|
/> |
|
</div> |
|
<div class="filter-and-button-wrapper"> |
|
<div style="flex: 1;"></div> |
|
<button |
|
class="search-button" |
|
onclick={search} |
|
disabled={searching || !searchQuery.trim()} |
|
> |
|
{searching ? 'Searching...' : 'Search'} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
{#if cacheResults.length > 0 || results.length > 0} |
|
<div class="results-container"> |
|
{#if cacheResults.length > 0} |
|
<h3>Found in Cache: ({cacheResults.length})</h3> |
|
<div class="results-grid"> |
|
{#each cacheResults as { event, matchedTag, matchedValue, relevance, relay }} |
|
<div |
|
class="result-card" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => handleResultClick(event)} |
|
onkeydown={(e) => handleResultKeydown(e, event)} |
|
> |
|
{#if getImageTag(event)} |
|
<div class="card-image"> |
|
<img src={getImageTag(event)} alt="" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> |
|
</div> |
|
{/if} |
|
|
|
<div class="card-content"> |
|
<div class="card-header"> |
|
<div class="card-header-left"> |
|
<ProfileBadge pubkey={event.pubkey} inline={true} /> |
|
<CacheBadge /> |
|
</div> |
|
<span class="kind-label">{getKindInfo(event.kind).description}</span> |
|
</div> |
|
|
|
<div class="card-metadata"> |
|
{#if getTagValue(event, 'd')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">d:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'd'}> |
|
{@html highlightText(getTagValue(event, 'd') || '', searchQuery, matchedTag.toLowerCase() === 'd')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'T')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">T:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag === 'T'}> |
|
{@html highlightText(getTagValue(event, 'T') || '', searchQuery, matchedTag === 'T')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'C')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">C:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag === 'C'}> |
|
{@html highlightText(getTagValue(event, 'C') || '', searchQuery, matchedTag === 'C')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'title')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">title:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'title'}> |
|
{@html highlightText(getTagValue(event, 'title') || '', searchQuery, matchedTag.toLowerCase() === 'title')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'author')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">author:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'author'}> |
|
{@html highlightText(getTagValue(event, 'author') || '', searchQuery, matchedTag.toLowerCase() === 'author')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'summary')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">summary:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'summary'}> |
|
{@html highlightText(getTagValue(event, 'summary') || '', searchQuery, matchedTag.toLowerCase() === 'summary')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'description')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">description:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'description'}> |
|
{@html highlightText(getTagValue(event, 'description') || '', searchQuery, matchedTag.toLowerCase() === 'description')} |
|
</span> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class="card-footer"> |
|
<code class="event-id">{event.id}</code> |
|
</div> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
{#if results.length > 0} |
|
<h3>Results from Relays ({results.length})</h3> |
|
<div class="results-grid"> |
|
{#each results as { event, matchedTag, matchedValue, relevance, relay }} |
|
<div |
|
class="result-card" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => handleResultClick(event)} |
|
onkeydown={(e) => handleResultKeydown(e, event)} |
|
> |
|
{#if getImageTag(event)} |
|
<div class="card-image"> |
|
<img src={getImageTag(event)} alt="" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> |
|
</div> |
|
{/if} |
|
|
|
<div class="card-content"> |
|
<div class="card-header"> |
|
<div class="card-header-left"> |
|
<ProfileBadge pubkey={event.pubkey} inline={true} /> |
|
{#if relay} |
|
{#if relay === 'cache'} |
|
<CacheBadge /> |
|
{:else} |
|
<RelayBadge relayUrl={relay} /> |
|
{/if} |
|
{/if} |
|
</div> |
|
<span class="kind-label">{getKindInfo(event.kind).description}</span> |
|
</div> |
|
|
|
<div class="card-metadata"> |
|
{#if getTagValue(event, 'd')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">d:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'd'}> |
|
{@html highlightText(getTagValue(event, 'd') || '', searchQuery, matchedTag.toLowerCase() === 'd')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'T')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">T:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag === 'T'}> |
|
{@html highlightText(getTagValue(event, 'T') || '', searchQuery, matchedTag === 'T')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'C')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">C:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag === 'C'}> |
|
{@html highlightText(getTagValue(event, 'C') || '', searchQuery, matchedTag === 'C')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'title')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">title:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'title'}> |
|
{@html highlightText(getTagValue(event, 'title') || '', searchQuery, matchedTag.toLowerCase() === 'title')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'author')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">author:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'author'}> |
|
{@html highlightText(getTagValue(event, 'author') || '', searchQuery, matchedTag.toLowerCase() === 'author')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'summary')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">summary:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'summary'}> |
|
{@html highlightText(getTagValue(event, 'summary') || '', searchQuery, matchedTag.toLowerCase() === 'summary')} |
|
</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getTagValue(event, 'description')} |
|
<div class="metadata-item"> |
|
<strong class="metadata-label">description:</strong> |
|
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'description'}> |
|
{@html highlightText(getTagValue(event, 'description') || '', searchQuery, matchedTag.toLowerCase() === 'description')} |
|
</span> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class="card-footer"> |
|
<code class="event-id">{event.id}</code> |
|
</div> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{:else if !searching && searchQuery.trim() && cacheResults.length === 0} |
|
<div class="no-results"> |
|
No addressable events found matching "{searchQuery}" |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.addressable-search { |
|
/* No container styling - parent .find-section handles it */ |
|
} |
|
|
|
.addressable-search h2 { |
|
margin: 0 0 1.5rem 0; |
|
font-size: 1.25rem; |
|
font-weight: 600; |
|
color: var(--fog-text, #475569); |
|
} |
|
|
|
:global(.dark) .addressable-search h2 { |
|
color: var(--fog-dark-text, #cbd5e1); |
|
} |
|
|
|
.section-description { |
|
margin: 0 0 1.5rem 0; |
|
color: var(--fog-text-light, #52667a); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .section-description { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.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; |
|
} |
|
} |
|
|
|
.search-input { |
|
width: 100%; |
|
padding: 0.75rem 1rem; |
|
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; |
|
font-family: inherit; |
|
} |
|
|
|
.search-input::placeholder { |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .search-input { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .search-input::placeholder { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.search-input:focus { |
|
outline: none; |
|
border-color: var(--fog-accent, #64748b); |
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
|
} |
|
|
|
:global(.dark) .search-input:focus { |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
|
} |
|
|
|
.search-input:disabled { |
|
opacity: 0.6; |
|
cursor: not-allowed; |
|
} |
|
|
|
.search-button { |
|
padding: 0.75rem 1.5rem; |
|
background: var(--fog-accent, #64748b); |
|
color: #ffffff; |
|
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: #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-container { |
|
margin-top: 2rem; |
|
} |
|
|
|
.results-container h3 { |
|
margin: 0 0 1.5rem 0; |
|
font-size: 1rem; |
|
font-weight: 500; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .results-container h3 { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.results-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
|
gap: 1.5rem; |
|
} |
|
|
|
.result-card { |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
background: var(--fog-post, #ffffff); |
|
overflow: hidden; |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
} |
|
|
|
:global(.dark) .result-card { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.result-card:hover { |
|
border-color: var(--fog-accent, #64748b); |
|
transform: translateY(-2px); |
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
:global(.dark) .result-card:hover { |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.card-image { |
|
width: 100%; |
|
height: 200px; |
|
overflow: hidden; |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .card-image { |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.card-image img { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
} |
|
|
|
.card-content { |
|
padding: 1rem; |
|
} |
|
|
|
.card-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 1rem; |
|
gap: 0.5rem; |
|
} |
|
|
|
.card-header-left { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.kind-label { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
padding: 0.25rem 0.5rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
} |
|
|
|
:global(.dark) .kind-label { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.card-metadata { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.75rem; |
|
} |
|
|
|
.metadata-item { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.25rem; |
|
} |
|
|
|
.metadata-label { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
font-weight: 600; |
|
text-transform: uppercase; |
|
} |
|
|
|
:global(.dark) .metadata-label { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.metadata-value { |
|
font-size: 0.875rem; |
|
color: var(--fog-text, #1f2937); |
|
word-wrap: break-word; |
|
} |
|
|
|
:global(.dark) .metadata-value { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.metadata-value.highlighted { |
|
background: var(--fog-accent-light, #e2e8f0); |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.25rem; |
|
font-weight: 500; |
|
} |
|
|
|
:global(.dark) .metadata-value.highlighted { |
|
background: var(--fog-dark-accent-light, #475569); |
|
} |
|
|
|
.metadata-value :global(mark) { |
|
background: var(--fog-accent, #64748b); |
|
color: white; |
|
padding: 0.125rem 0.25rem; |
|
border-radius: 0.125rem; |
|
} |
|
|
|
:global(.dark) .metadata-value :global(mark) { |
|
background: var(--fog-dark-accent, #94a3b8); |
|
color: var(--fog-dark-text, #1f2937); |
|
} |
|
|
|
.card-footer { |
|
margin-top: 1rem; |
|
padding-top: 1rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .card-footer { |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.event-id { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
font-family: monospace; |
|
word-break: break-all; |
|
} |
|
|
|
:global(.dark) .event-id { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.no-results { |
|
padding: 2rem; |
|
text-align: center; |
|
color: var(--fog-text-light, #52667a); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .no-results { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
</style>
|
|
|