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.
1414 lines
52 KiB
1414 lines
52 KiB
<script lang="ts"> |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { getEvent, getEventsByKind, getEventsByPubkey } from '../../services/cache/event-cache.js'; |
|
import { cacheEvent } from '../../services/cache/event-cache.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { goto } from '$app/navigation'; |
|
import { KIND, KIND_LOOKUP } from '../../types/kind-lookup.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { parseProfile } from '../../services/user-data.js'; |
|
|
|
interface Props { |
|
mode?: 'search' | 'filter'; // 'search' shows dropdown, 'filter' filters page content |
|
placeholder?: string; |
|
onFilterChange?: (result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }) => void; |
|
showKindFilter?: boolean; // Show kind filter dropdown |
|
selectedKind?: number | null; // Selected kind for filtering |
|
onKindChange?: (kind: number | null) => void; // Callback when kind filter changes |
|
hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page) |
|
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void; // Callback for search results (events, profile pubkeys, relays used, and relay per event) |
|
allowedKinds?: number[]; // Hard-coded list of kinds to search (hides kind dropdown) |
|
} |
|
|
|
let { mode = 'search', placeholder = 'Search events, profiles, pubkeys, or enter event ID...', onFilterChange, showKindFilter = false, selectedKind = null, onKindChange, hideDropdownResults = false, onSearchResults, allowedKinds }: Props = $props(); |
|
|
|
// Use allowedKinds if provided, otherwise use selectedKind |
|
let effectiveKinds = $derived(allowedKinds && allowedKinds.length > 0 ? allowedKinds : (selectedKind !== null ? [selectedKind] : null)); |
|
|
|
let searchQuery = $state(''); |
|
let searching = $state(false); |
|
let resolving = $state(false); |
|
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]); |
|
let showResults = $state(false); |
|
let searchInput: HTMLInputElement | null = $state(null); |
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null; |
|
let cacheSearchTimeoutId: ReturnType<typeof setTimeout> | null = null; |
|
|
|
// For collecting results when hideDropdownResults is true |
|
let foundEvents: NostrEvent[] = []; |
|
let foundProfiles: string[] = []; |
|
// For cache-only results |
|
let cacheEvents: NostrEvent[] = []; |
|
let cacheProfiles: string[] = []; |
|
// Map to track which relay each event came from |
|
const eventRelayMap = new Map<string, string>(); |
|
const CACHE_SEARCH_DEBOUNCE = 500; // 500ms debounce for cache search |
|
|
|
// Clear results at start of search |
|
function clearResults() { |
|
foundEvents = []; |
|
foundProfiles = []; |
|
eventRelayMap.clear(); |
|
} |
|
|
|
// Clear cache results |
|
function clearCacheResults() { |
|
cacheEvents = []; |
|
cacheProfiles = []; |
|
} |
|
|
|
// Search cache only (debounced) |
|
async function searchCacheOnly() { |
|
if (!searchQuery.trim()) { |
|
clearCacheResults(); |
|
if (hideDropdownResults && onSearchResults) { |
|
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() }); |
|
} |
|
return; |
|
} |
|
|
|
const query = searchQuery.trim(); |
|
clearCacheResults(); |
|
|
|
try { |
|
// 1. Check if it's a hex event ID |
|
if (/^[0-9a-f]{64}$/i.test(query)) { |
|
const hexId = query.toLowerCase(); |
|
const event = await getEvent(hexId); |
|
|
|
if (event) { |
|
// Check if kind matches filter |
|
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) { |
|
// Doesn't match, continue |
|
} else { |
|
cacheEvents = [event]; |
|
if (hideDropdownResults && onSearchResults) { |
|
const cacheRelays = new Map<string, string>(); |
|
cacheRelays.set(event.id, 'cache'); |
|
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays }); |
|
} |
|
return; |
|
} |
|
} |
|
|
|
// Try as pubkey |
|
const cachedByPubkey = await getEventsByPubkey(hexId, 100); |
|
if (cachedByPubkey.length > 0) { |
|
const filtered = effectiveKinds && effectiveKinds.length > 0 |
|
? cachedByPubkey.filter(e => effectiveKinds.includes(e.kind)) |
|
: cachedByPubkey; |
|
cacheEvents = filtered; |
|
cacheProfiles = [hexId]; |
|
if (hideDropdownResults && onSearchResults) { |
|
const cacheRelays = new Map<string, string>(); |
|
for (const event of cacheEvents) { |
|
cacheRelays.set(event.id, 'cache'); |
|
} |
|
onSearchResults({ events: cacheEvents, profiles: cacheProfiles, relays: [], eventRelays: cacheRelays }); |
|
} |
|
return; |
|
} |
|
} |
|
|
|
// 2. Check if it's a bech32 npub/nprofile/naddr |
|
if (/^(npub|nprofile|naddr)1[a-z0-9]+$/i.test(query)) { |
|
try { |
|
const decoded = nip19.decode(query); |
|
let pubkey: string | null = null; |
|
|
|
if (decoded.type === 'npub') { |
|
pubkey = String(decoded.data); |
|
} else if (decoded.type === 'nprofile') { |
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
pubkey = String(decoded.data.pubkey); |
|
} |
|
} else if (decoded.type === 'naddr') { |
|
// naddr encodes kind + pubkey + d tag |
|
if (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; |
|
pubkey = String(naddrData.pubkey); |
|
const dTag = String(naddrData.identifier); |
|
|
|
// Check if kind matches filter |
|
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(kind)) { |
|
// Doesn't match, continue |
|
} else { |
|
// Search cache for event with matching kind, pubkey, and d tag |
|
const cachedByKind = await getEventsByKind(kind, 1000); |
|
const event = cachedByKind.find(e => |
|
pubkey && e.pubkey.toLowerCase() === pubkey.toLowerCase() && |
|
e.tags.some(t => t[0] === 'd' && t[1] === dTag) |
|
); |
|
|
|
if (event) { |
|
cacheEvents = [event]; |
|
if (hideDropdownResults && onSearchResults) { |
|
const cacheRelays = new Map<string, string>(); |
|
cacheRelays.set(event.id, 'cache'); |
|
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays }); |
|
} |
|
return; |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (pubkey) { |
|
const normalizedPubkey = pubkey.toLowerCase(); |
|
const cachedByPubkey = await getEventsByPubkey(normalizedPubkey, 100); |
|
if (cachedByPubkey.length > 0) { |
|
const filtered = effectiveKinds && effectiveKinds.length > 0 |
|
? cachedByPubkey.filter(e => effectiveKinds.includes(e.kind)) |
|
: cachedByPubkey; |
|
cacheEvents = filtered; |
|
cacheProfiles = [normalizedPubkey]; |
|
if (hideDropdownResults && onSearchResults) { |
|
const cacheRelays = new Map<string, string>(); |
|
for (const event of cacheEvents) { |
|
cacheRelays.set(event.id, 'cache'); |
|
} |
|
onSearchResults({ events: cacheEvents, profiles: cacheProfiles, relays: [], eventRelays: cacheRelays }); |
|
} |
|
return; |
|
} |
|
} |
|
} catch { |
|
// Not a valid bech32, continue |
|
} |
|
} |
|
|
|
// 3. Search by kind if specified |
|
if (effectiveKinds && effectiveKinds.length > 0) { |
|
const allCached: NostrEvent[] = []; |
|
for (const kind of effectiveKinds) { |
|
const cached = await getEventsByKind(kind, 100); |
|
allCached.push(...cached); |
|
} |
|
|
|
// Filter by content matching query |
|
const queryLower = query.toLowerCase(); |
|
const matching = allCached.filter(event => { |
|
return event.content.toLowerCase().includes(queryLower) || |
|
event.tags.some(tag => tag.some(v => v && v.toLowerCase().includes(queryLower))); |
|
}); |
|
|
|
cacheEvents = matching.slice(0, 100); |
|
if (hideDropdownResults && onSearchResults && cacheEvents.length > 0) { |
|
const cacheRelays = new Map<string, string>(); |
|
for (const event of cacheEvents) { |
|
cacheRelays.set(event.id, 'cache'); |
|
} |
|
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays }); |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error searching cache:', error); |
|
} |
|
} |
|
|
|
// Debounced cache search |
|
$effect(() => { |
|
if (cacheSearchTimeoutId) { |
|
clearTimeout(cacheSearchTimeoutId); |
|
} |
|
|
|
if (searchQuery.trim() && hideDropdownResults && onSearchResults) { |
|
cacheSearchTimeoutId = setTimeout(() => { |
|
searchCacheOnly(); |
|
}, CACHE_SEARCH_DEBOUNCE); |
|
} else { |
|
clearCacheResults(); |
|
} |
|
|
|
return () => { |
|
if (cacheSearchTimeoutId) { |
|
clearTimeout(cacheSearchTimeoutId); |
|
} |
|
}; |
|
}); |
|
|
|
// For filter mode: resolved search result |
|
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }>({ type: null, value: null, kind: null }); |
|
|
|
// Note: filterResult kind is updated in performSearch, not here to avoid loops |
|
|
|
// Check if input looks like NIP-05 (user@domain.com) |
|
function isNIP05(input: string): boolean { |
|
const trimmed = input.trim(); |
|
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed); |
|
} |
|
|
|
// Search cache for profiles with matching NIP-05 |
|
async function searchCacheForNIP05(nip05: string): Promise<string | null> { |
|
try { |
|
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB()); |
|
const tx = db.transaction('profiles', 'readonly'); |
|
const store = tx.store; |
|
const profiles: any[] = []; |
|
|
|
let cursor = await store.openCursor(); |
|
while (cursor) { |
|
profiles.push(cursor.value); |
|
cursor = await cursor.continue(); |
|
} |
|
await tx.done; |
|
|
|
const normalizedNIP05 = nip05.toLowerCase(); |
|
for (const cached of profiles) { |
|
const profile = parseProfile(cached.event); |
|
if (profile.nip05) { |
|
for (const profileNip05 of profile.nip05) { |
|
if (profileNip05.toLowerCase() === normalizedNIP05) { |
|
return cached.pubkey; |
|
} |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
console.debug('Error searching cache for NIP-05:', error); |
|
} |
|
return null; |
|
} |
|
|
|
// Resolve NIP-05 from well-known.json |
|
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> { |
|
try { |
|
const [localPart, domain] = nip05.split('@'); |
|
if (!localPart || !domain) return null; |
|
|
|
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; |
|
|
|
const controller = new AbortController(); |
|
const timeout = setTimeout(() => controller.abort(), 5000); |
|
|
|
try { |
|
const response = await fetch(wellKnownUrl, { signal: controller.signal }); |
|
clearTimeout(timeout); |
|
|
|
if (!response.ok) { |
|
return null; |
|
} |
|
|
|
const data = await response.json(); |
|
const names = data.names || {}; |
|
const pubkey = names[localPart]; |
|
|
|
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { |
|
return pubkey.toLowerCase(); |
|
} |
|
} catch (fetchError) { |
|
clearTimeout(timeout); |
|
if (fetchError instanceof Error && fetchError.name !== 'AbortError') { |
|
console.debug('Error fetching well-known.json:', fetchError); |
|
} |
|
} |
|
} catch (error) { |
|
console.debug('Error resolving NIP-05:', error); |
|
} |
|
return null; |
|
} |
|
|
|
async function performSearch() { |
|
if (!searchQuery.trim()) { |
|
searchResults = []; |
|
showResults = false; |
|
filterResult = { type: null, value: null, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
return; |
|
} |
|
|
|
// Clear results at start of search |
|
clearResults(); |
|
|
|
await nostrClient.initialize(); |
|
searching = true; |
|
resolving = true; |
|
searchResults = []; |
|
showResults = true; |
|
filterResult = { type: null, value: null, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
|
|
// Get relays that will be used for search (used for empty result messages) |
|
const relaysUsed = relayManager.getAllAvailableRelays(); |
|
|
|
try { |
|
const query = searchQuery.trim(); |
|
|
|
// 1. Check if it's a hex event ID (64 hex chars) |
|
if (/^[0-9a-f]{64}$/i.test(query)) { |
|
const hexId = query.toLowerCase(); |
|
let event: NostrEvent | undefined = await getEvent(hexId); |
|
|
|
if (!event) { |
|
const relays = relayManager.getAllAvailableRelays(); |
|
const events = await nostrClient.fetchEvents( |
|
[{ ids: [hexId] }], |
|
relays, |
|
{ useCache: false, cacheResults: true } |
|
); |
|
|
|
if (events.length > 0) { |
|
event = events[0]; |
|
await cacheEvent(event); |
|
} |
|
} |
|
|
|
if (event) { |
|
// If kinds are specified, filter by kind |
|
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) { |
|
// Event found but doesn't match allowed kinds, continue to next check |
|
} else { |
|
if (mode === 'search') { |
|
if (hideDropdownResults && onSearchResults) { |
|
foundEvents = [event]; |
|
// Create relay map for found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
} else { |
|
searchResults = [{ event, matchType: 'Event ID' }]; |
|
showResults = true; |
|
} |
|
} else { |
|
filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
} |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} |
|
} |
|
|
|
// Event not found, try as pubkey (step 2) |
|
const hexPubkey = hexId.toLowerCase(); |
|
// If kinds are specified, search for events with this pubkey in p/q tags |
|
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) { |
|
const relays = relayManager.getAllAvailableRelays(); |
|
const allEvents = new Map<string, NostrEvent>(); |
|
|
|
// Search each allowed kind |
|
for (const kind of effectiveKinds) { |
|
// Search by author (most important for bookmarks and highlights) |
|
const eventsByAuthor = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [hexPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Search for events with pubkey in p or q tags |
|
const eventsWithP = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#p': [hexPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
const eventsWithQ = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#q': [hexPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Add to combined results |
|
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) { |
|
allEvents.set(event.id, event); |
|
} |
|
} |
|
|
|
foundEvents = Array.from(allEvents.values()); |
|
// Create relay map for found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else if (mode === 'search') { |
|
if (hideDropdownResults && onSearchResults) { |
|
foundProfiles = [hexPubkey]; |
|
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed }); |
|
} else { |
|
// For search mode, navigate to profile |
|
handleProfileClick(hexPubkey); |
|
} |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else { |
|
filterResult = { type: 'pubkey', value: hexPubkey, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} |
|
} |
|
|
|
// 3. Check npub, nprofile (resolve to hex) |
|
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(query)) { |
|
try { |
|
const decoded = nip19.decode(query); |
|
let pubkey: string | null = null; |
|
|
|
if (decoded.type === 'npub') { |
|
pubkey = String(decoded.data); |
|
} else if (decoded.type === 'nprofile') { |
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
pubkey = String(decoded.data.pubkey); |
|
} |
|
} |
|
|
|
if (pubkey) { |
|
const normalizedPubkey = pubkey.toLowerCase(); |
|
// If kinds are specified, search for events with this pubkey in p/q tags |
|
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) { |
|
const relays = relayManager.getAllAvailableRelays(); |
|
const allEvents = new Map<string, NostrEvent>(); |
|
|
|
// Search each allowed kind |
|
for (const kind of effectiveKinds) { |
|
// Search by author (most important for bookmarks and highlights) |
|
const eventsByAuthor = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Search for events with pubkey in p or q tags |
|
const eventsWithP = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
const eventsWithQ = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Add to combined results |
|
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) { |
|
allEvents.set(event.id, event); |
|
} |
|
} |
|
|
|
foundEvents = Array.from(allEvents.values()); |
|
// Create relay map for found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else if (mode === 'search') { |
|
if (hideDropdownResults && onSearchResults) { |
|
foundProfiles = [normalizedPubkey]; |
|
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed }); |
|
} else { |
|
handleProfileClick(normalizedPubkey); |
|
} |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else { |
|
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} |
|
} |
|
} catch (error) { |
|
console.debug('Error decoding npub/nprofile:', error); |
|
} |
|
} |
|
|
|
// 4. Check NIP-05 (resolve to hex) |
|
if (isNIP05(query)) { |
|
resolving = true; |
|
try { |
|
// First check cache |
|
const cachedPubkey = await searchCacheForNIP05(query); |
|
if (cachedPubkey) { |
|
const normalizedPubkey = cachedPubkey.toLowerCase(); |
|
// If kinds are specified, search for events with this pubkey in p/q tags |
|
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) { |
|
const relays = relayManager.getAllAvailableRelays(); |
|
const allEvents = new Map<string, NostrEvent>(); |
|
|
|
// Search each allowed kind |
|
for (const kind of effectiveKinds) { |
|
// Search by author (most important for bookmarks and highlights) |
|
const eventsByAuthor = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Search for events with pubkey in p or q tags |
|
const eventsWithP = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
const eventsWithQ = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Add to combined results |
|
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) { |
|
allEvents.set(event.id, event); |
|
} |
|
} |
|
|
|
foundEvents = Array.from(allEvents.values()); |
|
// Create relay map for found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else if (mode === 'search') { |
|
if (hideDropdownResults && onSearchResults) { |
|
foundProfiles = [normalizedPubkey]; |
|
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed }); |
|
} else { |
|
handleProfileClick(normalizedPubkey); |
|
} |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else { |
|
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} |
|
} |
|
|
|
// Then check well-known.json |
|
const wellKnownPubkey = await resolveNIP05FromWellKnown(query); |
|
if (wellKnownPubkey) { |
|
const normalizedPubkey = wellKnownPubkey.toLowerCase(); |
|
// If kinds are specified, search for events with this pubkey in p/q tags |
|
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) { |
|
const relays = relayManager.getAllAvailableRelays(); |
|
const allEvents = new Map<string, NostrEvent>(); |
|
|
|
// Search each allowed kind |
|
for (const kind of effectiveKinds) { |
|
// Search by author (most important for bookmarks and highlights) |
|
const eventsByAuthor = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Search for events with pubkey in p or q tags |
|
const eventsWithP = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
const eventsWithQ = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
); |
|
|
|
// Add to combined results |
|
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) { |
|
allEvents.set(event.id, event); |
|
} |
|
} |
|
|
|
foundEvents = Array.from(allEvents.values()); |
|
// Create relay map for found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else if (mode === 'search') { |
|
if (hideDropdownResults && onSearchResults) { |
|
foundProfiles = [normalizedPubkey]; |
|
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed }); |
|
} else { |
|
handleProfileClick(normalizedPubkey); |
|
} |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} else { |
|
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} |
|
} |
|
} catch (error) { |
|
console.debug('Error resolving NIP-05:', error); |
|
} |
|
resolving = false; |
|
} |
|
|
|
// 5. Check note, nevent, naddr (resolve to hex event ID) |
|
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(query)) { |
|
try { |
|
const decoded = nip19.decode(query); |
|
let eventId: string | null = null; |
|
|
|
if (decoded.type === 'note') { |
|
eventId = String(decoded.data); |
|
} else if (decoded.type === 'nevent') { |
|
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
|
eventId = String(decoded.data.id); |
|
} |
|
} else if (decoded.type === 'naddr') { |
|
// naddr encodes kind + pubkey + d tag |
|
if (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); |
|
|
|
// Check if kind matches filter |
|
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(kind)) { |
|
// Doesn't match, continue |
|
} else { |
|
// Try to find in cache first |
|
const cachedByKind = await getEventsByKind(kind, 1000); |
|
let event: NostrEvent | undefined = cachedByKind.find(e => |
|
e.pubkey.toLowerCase() === pubkey.toLowerCase() && |
|
e.tags.some(t => t[0] === 'd' && t[1] === dTag) |
|
); |
|
|
|
if (!event) { |
|
// Fetch from relays |
|
const relays = naddrData.relays && naddrData.relays.length > 0 |
|
? naddrData.relays |
|
: relayManager.getAllAvailableRelays(); |
|
|
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], |
|
relays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
if (events.length > 0) { |
|
event = events[0]; |
|
} |
|
} |
|
|
|
if (event) { |
|
if (mode === 'search') { |
|
if (hideDropdownResults && onSearchResults) { |
|
foundEvents = [event]; |
|
const foundEventRelays = new Map<string, string>(); |
|
foundEventRelays.set(event.id, 'cache'); |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
} else { |
|
searchResults = [{ event, matchType: 'Addressable Event (naddr)' }]; |
|
showResults = true; |
|
} |
|
} else { |
|
filterResult = { type: 'event', value: event.id, kind: kind }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
} |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (eventId) { |
|
let event: NostrEvent | undefined = await getEvent(eventId); |
|
|
|
if (!event) { |
|
const relays = relayManager.getAllAvailableRelays(); |
|
const events = await nostrClient.fetchEvents( |
|
[{ ids: [eventId] }], |
|
relays, |
|
{ useCache: false, cacheResults: true } |
|
); |
|
|
|
if (events.length > 0) { |
|
event = events[0]; |
|
await cacheEvent(event); |
|
} |
|
} |
|
|
|
if (event) { |
|
// If kinds are specified, filter by kind |
|
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) { |
|
// Event found but doesn't match allowed kinds, continue to next check |
|
} else { |
|
if (mode === 'search') { |
|
if (hideDropdownResults && onSearchResults) { |
|
foundEvents = [event]; |
|
// Create relay map for found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
} else { |
|
searchResults = [{ event, matchType: 'Event ID' }]; |
|
showResults = true; |
|
} |
|
} else { |
|
filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
} |
|
searching = false; |
|
resolving = false; |
|
return; |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
console.debug('Error decoding note/nevent/naddr:', error); |
|
} |
|
} |
|
|
|
// 6. Anything else is a full-text search |
|
if (mode === 'search') { |
|
let allEvents: NostrEvent[] = []; |
|
const uniqueEventIds = new Set<string>(); |
|
|
|
// Helper to add event and notify if needed |
|
const addEvent = (event: NostrEvent, relay?: string) => { |
|
if (!uniqueEventIds.has(event.id)) { |
|
uniqueEventIds.add(event.id); |
|
allEvents.push(event); |
|
|
|
// Track relay source |
|
if (relay) { |
|
eventRelayMap.set(event.id, relay); |
|
} |
|
|
|
// If hideDropdownResults, notify incrementally |
|
if (hideDropdownResults && onSearchResults) { |
|
const queryLower = query.toLowerCase(); |
|
const matches = allEvents.filter(event => { |
|
const contentMatch = event.content.toLowerCase().includes(queryLower); |
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
|
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
const summaryTag = event.tags.find(t => t[0] === 'summary'); |
|
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
return contentMatch || titleMatch || summaryMatch; |
|
}); |
|
|
|
// Sort and limit |
|
const sorted = matches.sort((a, b) => { |
|
const aExact = a.content.toLowerCase() === queryLower; |
|
const bExact = b.content.toLowerCase() === queryLower; |
|
if (aExact && !bExact) return -1; |
|
if (!aExact && bExact) return 1; |
|
return b.created_at - a.created_at; |
|
}); |
|
|
|
foundEvents = sorted.slice(0, 100); |
|
// Create a map of event IDs to relay URLs for the found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
} |
|
} |
|
}; |
|
|
|
// If kinds are specified, search from relays |
|
if (effectiveKinds && effectiveKinds.length > 0) { |
|
const relays = relayManager.getAllAvailableRelays(); |
|
const queryLower = query.toLowerCase(); |
|
|
|
// Search each allowed kind with onUpdate for incremental results |
|
for (const kind of effectiveKinds) { |
|
await nostrClient.fetchEvents( |
|
[{ kinds: [kind], limit: 100 }], |
|
relays, |
|
{ |
|
useCache: 'cache-first', |
|
cacheResults: true, |
|
timeout: 10000, |
|
onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => { |
|
// Filter and add events as they arrive with relay info |
|
for (const { event, relay } of eventsWithRelay) { |
|
const contentMatch = event.content.toLowerCase().includes(queryLower); |
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
|
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
const summaryTag = event.tags.find(t => t[0] === 'summary'); |
|
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
|
|
if (contentMatch || titleMatch || summaryMatch) { |
|
addEvent(event, relay); |
|
} |
|
} |
|
} |
|
} |
|
); |
|
} |
|
} else { |
|
// Text search in cached events (title, summary, content) |
|
const allCached: NostrEvent[] = []; |
|
|
|
// If kind filter is selected, only search that kind |
|
if (selectedKind !== null) { |
|
const kindEvents = await getEventsByKind(selectedKind, 100); |
|
allCached.push(...kindEvents); |
|
} else { |
|
// Search all kinds we handle (limit each to 20 to keep total reasonable, then limit final to 100) |
|
const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind); |
|
for (const kind of kindsToSearch) { |
|
try { |
|
const kindEvents = await getEventsByKind(kind, 20); |
|
allCached.push(...kindEvents); |
|
// Stop if we have enough events already |
|
if (allCached.length >= 100) break; |
|
} catch (e) { |
|
// Skip kinds that fail |
|
} |
|
} |
|
} |
|
|
|
const queryLower = query.toLowerCase(); |
|
allEvents = allCached.filter(event => { |
|
const contentMatch = event.content.toLowerCase().includes(queryLower); |
|
|
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
|
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
|
|
const summaryTag = event.tags.find(t => t[0] === 'summary'); |
|
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
|
|
// Mark all cached events as coming from cache |
|
if (contentMatch || titleMatch || summaryMatch) { |
|
eventRelayMap.set(event.id, 'cache'); |
|
} |
|
|
|
return contentMatch || titleMatch || summaryMatch; |
|
}); |
|
} |
|
|
|
// Final sort and limit (only if not already handled incrementally) |
|
if (!(hideDropdownResults && onSearchResults && effectiveKinds && effectiveKinds.length > 0)) { |
|
const queryLower = query.toLowerCase(); |
|
const sorted = allEvents.sort((a, b) => { |
|
const aExact = a.content.toLowerCase() === queryLower; |
|
const bExact = b.content.toLowerCase() === queryLower; |
|
if (aExact && !bExact) return -1; |
|
if (!aExact && bExact) return 1; |
|
return b.created_at - a.created_at; |
|
}); |
|
|
|
// Deduplicate by event ID |
|
const uniqueEvents = new Map<string, NostrEvent>(); |
|
for (const event of sorted) { |
|
uniqueEvents.set(event.id, event); |
|
} |
|
|
|
const limitedResults = Array.from(uniqueEvents.values()).slice(0, 100); |
|
|
|
if (hideDropdownResults && onSearchResults) { |
|
foundEvents = limitedResults; |
|
// Create relay map for found events |
|
const foundEventRelays = new Map<string, string>(); |
|
for (const event of foundEvents) { |
|
const relay = eventRelayMap.get(event.id); |
|
if (relay) { |
|
foundEventRelays.set(event.id, relay); |
|
} |
|
} |
|
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); |
|
} else { |
|
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' })); |
|
showResults = true; |
|
} |
|
} |
|
} else { |
|
// Filter mode: treat as text search |
|
filterResult = { type: 'text', value: query, kind: effectiveKinds?.[0] || selectedKind || null }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
} |
|
} catch (error) { |
|
console.error('Search error:', error); |
|
// Ensure we reset state even on error |
|
if (hideDropdownResults && onSearchResults) { |
|
onSearchResults({ events: [], profiles: [], relays: relaysUsed }); |
|
} |
|
} finally { |
|
searching = false; |
|
resolving = false; |
|
} |
|
} |
|
|
|
function handleSearchInput(e: Event) { |
|
const target = e.target as HTMLInputElement; |
|
const newValue = target.value; |
|
searchQuery = newValue; |
|
|
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
} |
|
|
|
if (newValue.trim()) { |
|
searching = true; |
|
searchTimeout = setTimeout(() => { |
|
performSearch(); |
|
}, 300); |
|
} else { |
|
searchResults = []; |
|
showResults = false; |
|
searching = false; |
|
filterResult = { type: null, value: null, kind: selectedKind }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
} |
|
} |
|
|
|
function handleKeyDown(e: KeyboardEvent) { |
|
if (e.key === 'Enter') { |
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
} |
|
performSearch(); |
|
} else if (e.key === 'Escape') { |
|
showResults = false; |
|
searchQuery = ''; |
|
filterResult = { type: null, value: null, kind: selectedKind }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
} |
|
} |
|
|
|
function handleResultClick(event: NostrEvent) { |
|
showResults = false; |
|
searchQuery = ''; |
|
goto(`/event/${event.id}`); |
|
} |
|
|
|
function handleProfileClick(pubkey: string) { |
|
showResults = false; |
|
searchQuery = ''; |
|
goto(`/profile/${pubkey}`); |
|
} |
|
|
|
// Close results when clicking outside |
|
$effect(() => { |
|
if (showResults) { |
|
const handleClickOutside = (e: MouseEvent) => { |
|
const target = e.target as HTMLElement; |
|
if (!target.closest('.unified-search-container')) { |
|
showResults = false; |
|
} |
|
}; |
|
|
|
document.addEventListener('click', handleClickOutside, true); |
|
return () => { |
|
document.removeEventListener('click', handleClickOutside, true); |
|
}; |
|
} |
|
}); |
|
|
|
// Cleanup timeout on unmount |
|
$effect(() => { |
|
return () => { |
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
} |
|
}; |
|
}); |
|
|
|
// Expose filter result for parent components (filter mode) |
|
export function getFilterResult(): { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } { |
|
return filterResult; |
|
} |
|
|
|
// Expose performSearch for manual trigger |
|
export function triggerSearch() { |
|
performSearch(); |
|
} |
|
|
|
export function clearSearch() { |
|
searchQuery = ''; |
|
searchResults = []; |
|
showResults = false; |
|
clearResults(); |
|
clearCacheResults(); |
|
filterResult = { type: null, value: null, kind: selectedKind }; |
|
if (onFilterChange) onFilterChange(filterResult); |
|
if (hideDropdownResults && onSearchResults) { |
|
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() }); |
|
} |
|
} |
|
|
|
// Note: filterResult kind is updated in performSearch, not here to avoid loops |
|
|
|
// Get all kinds for dropdown (sorted by number) |
|
let allKinds = $derived(Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number)); |
|
|
|
function handleKindChange(e: Event) { |
|
const select = e.target as HTMLSelectElement; |
|
const kind = select.value === '' ? null : parseInt(select.value); |
|
if (onKindChange) { |
|
onKindChange(kind); |
|
} |
|
// Update filter result |
|
filterResult = { ...filterResult, kind }; |
|
if (onFilterChange) { |
|
onFilterChange(filterResult); |
|
} |
|
// Re-run search if there's a query |
|
if (searchQuery.trim()) { |
|
performSearch(); |
|
} |
|
} |
|
</script> |
|
|
|
<div class="unified-search-container"> |
|
<div class="search-input-wrapper"> |
|
{#if showKindFilter && !allowedKinds} |
|
<select |
|
value={selectedKind?.toString() || ''} |
|
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> |
|
{/if} |
|
<input |
|
bind:this={searchInput} |
|
type="text" |
|
placeholder={placeholder} |
|
value={searchQuery} |
|
oninput={handleSearchInput} |
|
onkeydown={handleKeyDown} |
|
class="search-input" |
|
class:resolving={resolving} |
|
class:with-kind-filter={showKindFilter} |
|
aria-label="Search" |
|
/> |
|
{#if searching || resolving} |
|
<span class="search-loading">⟳</span> |
|
{/if} |
|
</div> |
|
|
|
{#if mode === 'search' && !hideDropdownResults && showResults && searchResults.length > 0} |
|
<div class="search-results"> |
|
{#each searchResults as { event, matchType }} |
|
<button |
|
onclick={() => { |
|
if (event.kind === KIND.METADATA) { |
|
handleProfileClick(event.pubkey); |
|
} else { |
|
handleResultClick(event); |
|
} |
|
}} |
|
class="search-result-item" |
|
> |
|
<div class="search-result-header"> |
|
<span class="search-result-type">{matchType}</span> |
|
<span class="search-result-id">{event.id.substring(0, 16)}...</span> |
|
</div> |
|
<div class="search-result-content"> |
|
{event.content.substring(0, 100)}{event.content.length > 100 ? '...' : ''} |
|
</div> |
|
<div class="search-result-meta"> |
|
Kind {event.kind} • {new Date(event.created_at * 1000).toLocaleDateString()} |
|
</div> |
|
</button> |
|
{/each} |
|
</div> |
|
{:else if mode === 'search' && !hideDropdownResults && showResults && !searching && searchQuery.trim()} |
|
<div class="search-results"> |
|
<div class="search-no-results">No results found</div> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.unified-search-container { |
|
position: relative; |
|
width: 100%; |
|
max-width: 600px; |
|
} |
|
|
|
.search-input-wrapper { |
|
position: relative; |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.kind-filter-select { |
|
padding: 0.75rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
background: var(--fog-post, #ffffff); |
|
background-color: var(--fog-post, #ffffff); |
|
color: var(--fog-text, #1f2937); |
|
font-size: 0.875rem; |
|
font-family: monospace; |
|
cursor: pointer; |
|
min-width: 150px; |
|
appearance: none; |
|
-webkit-appearance: none; |
|
-moz-appearance: none; |
|
} |
|
|
|
.kind-filter-select option { |
|
background: var(--fog-post, #ffffff); |
|
background-color: var(--fog-post, #ffffff); |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
.kind-filter-select option:checked { |
|
background: var(--fog-accent, #64748b) !important; |
|
background-color: var(--fog-accent, #64748b) !important; |
|
color: var(--fog-post, #ffffff) !important; |
|
} |
|
|
|
/* Ensure selected option in dropdown has proper contrast */ |
|
.kind-filter-select option:checked:not(:disabled) { |
|
background: var(--fog-accent, #64748b) !important; |
|
background-color: var(--fog-accent, #64748b) !important; |
|
color: var(--fog-post, #ffffff) !important; |
|
} |
|
|
|
.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); |
|
background-color: var(--fog-dark-post, #1f2937); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .kind-filter-select option { |
|
background: var(--fog-dark-post, #1f2937); |
|
background-color: var(--fog-dark-post, #1f2937); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .kind-filter-select option:checked { |
|
background: var(--fog-dark-accent, #94a3b8) !important; |
|
background-color: var(--fog-dark-accent, #94a3b8) !important; |
|
color: #ffffff !important; |
|
} |
|
|
|
/* Ensure selected option in dropdown has proper contrast in dark mode */ |
|
:global(.dark) .kind-filter-select option:checked:not(:disabled) { |
|
background: var(--fog-dark-accent, #94a3b8) !important; |
|
background-color: var(--fog-dark-accent, #94a3b8) !important; |
|
color: #ffffff !important; |
|
} |
|
|
|
: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-input.with-kind-filter { |
|
flex: 1; |
|
} |
|
|
|
.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; |
|
transition: all 0.2s; |
|
} |
|
|
|
.search-input:focus { |
|
outline: none; |
|
border-color: var(--fog-accent, #64748b); |
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
|
} |
|
|
|
.search-input.resolving { |
|
border-color: var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .search-input { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
: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-loading { |
|
position: absolute; |
|
right: 1rem; |
|
color: var(--fog-text-light, #9ca3af); |
|
animation: spin 1s linear infinite; |
|
} |
|
|
|
@keyframes spin { |
|
from { transform: rotate(0deg); } |
|
to { transform: rotate(360deg); } |
|
} |
|
|
|
.search-results { |
|
position: absolute; |
|
top: 100%; |
|
left: 0; |
|
right: 0; |
|
margin-top: 0.25rem; |
|
background: var(--fog-post, #ffffff); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
|
max-height: 400px; |
|
overflow-y: auto; |
|
z-index: 1000; |
|
} |
|
|
|
:global(.dark) .search-results { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
|
} |
|
|
|
.search-result-item { |
|
width: 100%; |
|
padding: 0.75rem; |
|
border: none; |
|
background: transparent; |
|
text-align: left; |
|
cursor: pointer; |
|
transition: background 0.2s; |
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
.search-result-item:last-child { |
|
border-bottom: none; |
|
} |
|
|
|
.search-result-item:hover { |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .search-result-item { |
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
:global(.dark) .search-result-item:hover { |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.search-result-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 0.25rem; |
|
} |
|
|
|
.search-result-type { |
|
font-size: 0.75rem; |
|
font-weight: 600; |
|
color: var(--fog-accent, #64748b); |
|
text-transform: uppercase; |
|
} |
|
|
|
:global(.dark) .search-result-type { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.search-result-id { |
|
font-size: 0.75rem; |
|
font-family: monospace; |
|
color: var(--fog-text-light, #9ca3af); |
|
} |
|
|
|
:global(.dark) .search-result-id { |
|
color: var(--fog-dark-text-light, #6b7280); |
|
} |
|
|
|
.search-result-content { |
|
font-size: 0.875rem; |
|
color: var(--fog-text, #1f2937); |
|
margin-bottom: 0.25rem; |
|
line-height: 1.4; |
|
} |
|
|
|
:global(.dark) .search-result-content { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.search-result-meta { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #9ca3af); |
|
} |
|
|
|
:global(.dark) .search-result-meta { |
|
color: var(--fog-dark-text-light, #6b7280); |
|
} |
|
|
|
.search-no-results { |
|
padding: 1rem; |
|
text-align: center; |
|
color: var(--fog-text-light, #9ca3af); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .search-no-results { |
|
color: var(--fog-dark-text-light, #6b7280); |
|
} |
|
</style>
|
|
|