|
|
|
|
@ -5,16 +5,16 @@
@@ -5,16 +5,16 @@
|
|
|
|
|
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 { relayManager } from '../../lib/services/nostr/relay-manager.js'; |
|
|
|
|
import type { NostrEvent } from '../../lib/types/nostr.js'; |
|
|
|
|
import { onMount } from 'svelte'; |
|
|
|
|
import { nip19 } from 'nostr-tools'; |
|
|
|
|
|
|
|
|
|
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; hasActiveSearch: () => boolean } | 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 |
|
|
|
|
@ -22,6 +22,22 @@
@@ -22,6 +22,22 @@
|
|
|
|
|
let searching = $state(false); |
|
|
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null); |
|
|
|
|
|
|
|
|
|
// NIP-01 standard filter controls |
|
|
|
|
let filterIds = $state<string>(''); // Comma-separated event IDs |
|
|
|
|
let filterAuthors = $state<string>(''); // Comma-separated pubkeys |
|
|
|
|
let filterKinds = $state<string>(''); // Comma-separated kind numbers |
|
|
|
|
let filterE = $state<string>(''); // Comma-separated event IDs for #e tag |
|
|
|
|
let filterP = $state<string>(''); // Comma-separated pubkeys for #p tag |
|
|
|
|
let filterA = $state<string>(''); // Comma-separated addressable event refs for #a tag |
|
|
|
|
let filterQ = $state<string>(''); // Comma-separated event IDs for #q tag (quoted events) |
|
|
|
|
let filterT = $state<string>(''); // Comma-separated topic values for #T tag |
|
|
|
|
let filterC = $state<string>(''); // Comma-separated category values for #C tag |
|
|
|
|
let filterD = $state<string>(''); // Comma-separated d-tag values for #d tag |
|
|
|
|
let filterSince = $state<string>(''); // Date-time string (will be converted to unix timestamp) |
|
|
|
|
let filterUntil = $state<string>(''); // Date-time string (will be converted to unix timestamp) |
|
|
|
|
let filterLimit = $state<number>(100); // Limit number |
|
|
|
|
let showAdvancedFilters = $state(false); // Toggle for showing advanced filters |
|
|
|
|
|
|
|
|
|
// Check if there are any active searches or results |
|
|
|
|
const hasActiveSearch = $derived.by(() => { |
|
|
|
|
if (searching) return true; |
|
|
|
|
@ -32,10 +48,6 @@
@@ -32,10 +48,6 @@
|
|
|
|
|
const filterResult = component.getFilterResult(); |
|
|
|
|
if (filterResult.value !== null) return true; |
|
|
|
|
} |
|
|
|
|
// Check SearchAddressableEvents component |
|
|
|
|
if (addressableSearchComponent) { |
|
|
|
|
if (addressableSearchComponent.hasActiveSearch()) return true; |
|
|
|
|
} |
|
|
|
|
return false; |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
@ -61,22 +73,581 @@
@@ -61,22 +73,581 @@
|
|
|
|
|
selectedKindString = select.value; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function handleSearch() { |
|
|
|
|
if (unifiedSearchComponent) { |
|
|
|
|
// Clear previous timeout |
|
|
|
|
if (searchTimeout) { |
|
|
|
|
clearTimeout(searchTimeout); |
|
|
|
|
searchTimeout = null; |
|
|
|
|
// Helper function to decode bech32 to hex (for pubkeys and event IDs) |
|
|
|
|
async function decodeBech32ToHex(bech32: string, type: 'pubkey' | 'event'): Promise<string | null> { |
|
|
|
|
try { |
|
|
|
|
// Check if it's a bech32 format |
|
|
|
|
if (!/^(npub|nprofile|note|nevent|naddr)1[a-z0-9]+$/i.test(bech32)) { |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const decoded = nip19.decode(bech32); |
|
|
|
|
|
|
|
|
|
if (type === 'pubkey') { |
|
|
|
|
if (decoded.type === 'npub') { |
|
|
|
|
return String(decoded.data).toLowerCase(); |
|
|
|
|
} else if (decoded.type === 'nprofile') { |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
|
|
|
return String(decoded.data.pubkey).toLowerCase(); |
|
|
|
|
} |
|
|
|
|
} else if (decoded.type === 'naddr') { |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
|
|
|
return String(decoded.data.pubkey).toLowerCase(); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} else if (type === 'event') { |
|
|
|
|
if (decoded.type === 'note') { |
|
|
|
|
return String(decoded.data).toLowerCase(); |
|
|
|
|
} else if (decoded.type === 'nevent') { |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
|
|
|
|
return String(decoded.data.id).toLowerCase(); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} catch (error) { |
|
|
|
|
// Not a valid bech32, return null |
|
|
|
|
console.debug('Error decoding bech32:', error); |
|
|
|
|
} |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Helper function to parse comma-separated values and validate hex strings |
|
|
|
|
// Also decodes bech32 formats (npub, nprofile, note, nevent) |
|
|
|
|
async function parseHexList(input: string, type: 'pubkey' | 'event' = 'event'): Promise<string[]> { |
|
|
|
|
const results: string[] = []; |
|
|
|
|
const parts = input.split(',').map(s => s.trim()).filter(s => s.length > 0); |
|
|
|
|
|
|
|
|
|
for (const part of parts) { |
|
|
|
|
// Check if it's already a hex string |
|
|
|
|
if (/^[0-9a-f]{64}$/i.test(part)) { |
|
|
|
|
results.push(part.toLowerCase()); |
|
|
|
|
} else { |
|
|
|
|
// Try to decode as bech32 |
|
|
|
|
const decoded = await decodeBech32ToHex(part, type); |
|
|
|
|
if (decoded) { |
|
|
|
|
results.push(decoded); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return results; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Helper function to parse comma-separated numbers |
|
|
|
|
function parseNumberList(input: string): number[] { |
|
|
|
|
return input |
|
|
|
|
.split(',') |
|
|
|
|
.map(s => parseInt(s.trim(), 10)) |
|
|
|
|
.filter(n => !isNaN(n) && n >= 0 && n <= 65535); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Helper function to parse comma-separated addressable event refs (kind:pubkey:d-tag) |
|
|
|
|
// Also decodes bech32 formats in the pubkey part (npub, nprofile, naddr) |
|
|
|
|
async function parseAddressableList(input: string): Promise<string[]> { |
|
|
|
|
const results: string[] = []; |
|
|
|
|
const parts = input.split(',').map(s => s.trim()).filter(s => s.length > 0); |
|
|
|
|
|
|
|
|
|
for (const part of parts) { |
|
|
|
|
// Check if it matches the addressable format: kind:pubkey:d-tag or kind:pubkey: |
|
|
|
|
const match = part.match(/^(\d+):([^:]+)(?::(.+))?$/); |
|
|
|
|
if (match) { |
|
|
|
|
const [, kind, pubkeyPart, dTag] = match; |
|
|
|
|
let hexPubkey: string | null = null; |
|
|
|
|
|
|
|
|
|
// Check if pubkey part is already hex |
|
|
|
|
if (/^[0-9a-f]{64}$/i.test(pubkeyPart)) { |
|
|
|
|
hexPubkey = pubkeyPart.toLowerCase(); |
|
|
|
|
} else { |
|
|
|
|
// Try to decode as bech32 |
|
|
|
|
hexPubkey = await decodeBech32ToHex(pubkeyPart, 'pubkey'); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (hexPubkey) { |
|
|
|
|
// Reconstruct the addressable format |
|
|
|
|
if (dTag) { |
|
|
|
|
results.push(`${kind}:${hexPubkey}:${dTag}`); |
|
|
|
|
} else { |
|
|
|
|
results.push(`${kind}:${hexPubkey}:`); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return results; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Helper function to convert date-time string to unix timestamp |
|
|
|
|
// Accepts ISO 8601 format (e.g., "2024-01-15T14:30:00Z" or "2024-01-15T14:30:00+05:00") |
|
|
|
|
// Also accepts unix timestamp as fallback |
|
|
|
|
function dateTimeToUnixTimestamp(dateTimeStr: string): number | null { |
|
|
|
|
if (!dateTimeStr.trim()) return null; |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
// Try parsing as ISO 8601 date-time string (supports timezone) |
|
|
|
|
const date = new Date(dateTimeStr.trim()); |
|
|
|
|
|
|
|
|
|
// Check if date is valid |
|
|
|
|
if (isNaN(date.getTime())) { |
|
|
|
|
// If ISO parsing fails, try as unix timestamp (backward compatibility) |
|
|
|
|
const timestamp = parseInt(dateTimeStr.trim(), 10); |
|
|
|
|
if (!isNaN(timestamp) && timestamp > 0) { |
|
|
|
|
return timestamp; |
|
|
|
|
} |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Convert to unix timestamp (seconds) |
|
|
|
|
return Math.floor(date.getTime() / 1000); |
|
|
|
|
} catch (error) { |
|
|
|
|
// If parsing fails, try as unix timestamp (backward compatibility) |
|
|
|
|
const timestamp = parseInt(dateTimeStr.trim(), 10); |
|
|
|
|
if (!isNaN(timestamp) && timestamp > 0) { |
|
|
|
|
return timestamp; |
|
|
|
|
} |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Build NIP-01 filter from form inputs |
|
|
|
|
async function buildFilter(): Promise<any> { |
|
|
|
|
const filter: any = {}; |
|
|
|
|
|
|
|
|
|
// ids filter (event IDs) |
|
|
|
|
if (filterIds.trim()) { |
|
|
|
|
const ids = await parseHexList(filterIds, 'event'); |
|
|
|
|
if (ids.length > 0) { |
|
|
|
|
filter.ids = ids; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// authors filter (pubkeys) |
|
|
|
|
if (filterAuthors.trim()) { |
|
|
|
|
const authors = await parseHexList(filterAuthors, 'pubkey'); |
|
|
|
|
if (authors.length > 0) { |
|
|
|
|
filter.authors = authors; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// kinds filter - prioritize advanced filter, then selectedKind, then all kinds |
|
|
|
|
if (filterKinds.trim()) { |
|
|
|
|
const kinds = parseNumberList(filterKinds); |
|
|
|
|
if (kinds.length > 0) { |
|
|
|
|
filter.kinds = kinds; |
|
|
|
|
} |
|
|
|
|
} else if (selectedKind !== null) { |
|
|
|
|
filter.kinds = [selectedKind]; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// #e tag filter (event IDs) |
|
|
|
|
if (filterE.trim()) { |
|
|
|
|
const eTags = await parseHexList(filterE, 'event'); |
|
|
|
|
if (eTags.length > 0) { |
|
|
|
|
filter['#e'] = eTags; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// #p tag filter (pubkeys) |
|
|
|
|
if (filterP.trim()) { |
|
|
|
|
const pTags = await parseHexList(filterP, 'pubkey'); |
|
|
|
|
if (pTags.length > 0) { |
|
|
|
|
filter['#p'] = pTags; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// #a tag filter (addressable events) |
|
|
|
|
if (filterA.trim()) { |
|
|
|
|
const aTags = await parseAddressableList(filterA); |
|
|
|
|
if (aTags.length > 0) { |
|
|
|
|
filter['#a'] = aTags; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// #q tag filter (quoted events - event IDs) |
|
|
|
|
if (filterQ.trim()) { |
|
|
|
|
const qTags = await parseHexList(filterQ, 'event'); |
|
|
|
|
if (qTags.length > 0) { |
|
|
|
|
filter['#q'] = qTags; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// #T tag filter (topics) |
|
|
|
|
if (filterT.trim()) { |
|
|
|
|
const tTags = filterT |
|
|
|
|
.split(',') |
|
|
|
|
.map(s => s.trim()) |
|
|
|
|
.filter(s => s.length > 0); |
|
|
|
|
if (tTags.length > 0) { |
|
|
|
|
filter['#T'] = tTags; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// #C tag filter (categories) |
|
|
|
|
if (filterC.trim()) { |
|
|
|
|
const cTags = filterC |
|
|
|
|
.split(',') |
|
|
|
|
.map(s => s.trim()) |
|
|
|
|
.filter(s => s.length > 0); |
|
|
|
|
if (cTags.length > 0) { |
|
|
|
|
filter['#C'] = cTags; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// #d tag filter (d-tags for addressable events) |
|
|
|
|
if (filterD.trim()) { |
|
|
|
|
const dTags = filterD |
|
|
|
|
.split(',') |
|
|
|
|
.map(s => s.trim()) |
|
|
|
|
.filter(s => s.length > 0); |
|
|
|
|
if (dTags.length > 0) { |
|
|
|
|
filter['#d'] = dTags; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// since filter - convert date-time to unix timestamp |
|
|
|
|
if (filterSince.trim()) { |
|
|
|
|
const since = dateTimeToUnixTimestamp(filterSince); |
|
|
|
|
if (since !== null && since > 0) { |
|
|
|
|
filter.since = since; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// until filter - convert date-time to unix timestamp |
|
|
|
|
if (filterUntil.trim()) { |
|
|
|
|
const until = dateTimeToUnixTimestamp(filterUntil); |
|
|
|
|
if (until !== null && until > 0) { |
|
|
|
|
filter.until = until; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// limit filter |
|
|
|
|
if (filterLimit > 0) { |
|
|
|
|
filter.limit = filterLimit; |
|
|
|
|
} else { |
|
|
|
|
filter.limit = 100; // Default limit |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return filter; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if any advanced filters are set (including selectedKind) |
|
|
|
|
function hasAdvancedFilters(): boolean { |
|
|
|
|
return !!( |
|
|
|
|
selectedKind !== null || |
|
|
|
|
filterIds.trim() || |
|
|
|
|
filterAuthors.trim() || |
|
|
|
|
filterKinds.trim() || |
|
|
|
|
filterE.trim() || |
|
|
|
|
filterP.trim() || |
|
|
|
|
filterA.trim() || |
|
|
|
|
filterQ.trim() || |
|
|
|
|
filterT.trim() || |
|
|
|
|
filterC.trim() || |
|
|
|
|
filterD.trim() || |
|
|
|
|
filterSince.trim() || |
|
|
|
|
filterUntil.trim() || |
|
|
|
|
filterLimit !== 100 |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if there's any input (search term, kind, or advanced filters) |
|
|
|
|
const hasAnyInput = $derived.by(() => { |
|
|
|
|
if (!unifiedSearchComponent) return false; |
|
|
|
|
const filterResult = unifiedSearchComponent.getFilterResult(); |
|
|
|
|
const hasSearchTerm = !!(filterResult.value && filterResult.value.trim()); |
|
|
|
|
return hasSearchTerm || hasAdvancedFilters(); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
async function handleSearch() { |
|
|
|
|
if (!unifiedSearchComponent) return; |
|
|
|
|
|
|
|
|
|
// Clear previous timeout |
|
|
|
|
if (searchTimeout) { |
|
|
|
|
clearTimeout(searchTimeout); |
|
|
|
|
searchTimeout = null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const filterResult = unifiedSearchComponent.getFilterResult(); |
|
|
|
|
const searchTerm = filterResult.value?.trim() || ''; |
|
|
|
|
const hasAdvanced = hasAdvancedFilters(); |
|
|
|
|
|
|
|
|
|
// If no search term, use NIP-01 filters |
|
|
|
|
if (!searchTerm) { |
|
|
|
|
searching = true; |
|
|
|
|
unifiedSearchComponent.triggerSearch(); |
|
|
|
|
|
|
|
|
|
// Set 10 second timeout |
|
|
|
|
searchTimeout = setTimeout(() => { |
|
|
|
|
try { |
|
|
|
|
await nostrClient.initialize(); |
|
|
|
|
const relays = relayManager.getAllAvailableRelays(); |
|
|
|
|
const newEventRelayMap = new Map<string, string>(); |
|
|
|
|
|
|
|
|
|
// Build filter from form inputs |
|
|
|
|
const filter = await buildFilter(); |
|
|
|
|
|
|
|
|
|
// If no filter conditions at all, default to all kinds with limit |
|
|
|
|
if (Object.keys(filter).length === 1 && filter.limit) { |
|
|
|
|
// Only limit specified, get all kinds |
|
|
|
|
const allKindNumbers = Object.values(KIND_LOOKUP).map(k => k.number); |
|
|
|
|
// Split into chunks to avoid too large filters |
|
|
|
|
const chunkSize = 10; |
|
|
|
|
const filters: any[] = []; |
|
|
|
|
for (let i = 0; i < allKindNumbers.length; i += chunkSize) { |
|
|
|
|
const chunk = allKindNumbers.slice(i, i + chunkSize); |
|
|
|
|
filters.push({ kinds: chunk, limit: filter.limit }); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Fetch events |
|
|
|
|
const allEvents: NostrEvent[] = []; |
|
|
|
|
for (const f of filters) { |
|
|
|
|
const events = await nostrClient.fetchEvents( |
|
|
|
|
[f], |
|
|
|
|
relays, |
|
|
|
|
{ useCache: true, cacheResults: true } |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
for (const event of events) { |
|
|
|
|
if (!newEventRelayMap.has(event.id)) { |
|
|
|
|
newEventRelayMap.set(event.id, relays[0] || 'unknown'); |
|
|
|
|
} |
|
|
|
|
allEvents.push(event); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Deduplicate and sort |
|
|
|
|
const uniqueEvents = Array.from( |
|
|
|
|
new Map(allEvents.map(e => [e.id, e])).values() |
|
|
|
|
).sort((a, b) => b.created_at - a.created_at); |
|
|
|
|
|
|
|
|
|
const limitedEvents = uniqueEvents.slice(0, filter.limit); |
|
|
|
|
|
|
|
|
|
// Update eventRelayMap |
|
|
|
|
eventRelayMap.clear(); |
|
|
|
|
for (const [eventId, relay] of newEventRelayMap) { |
|
|
|
|
eventRelayMap.set(eventId, relay); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Update results |
|
|
|
|
searchResults = { |
|
|
|
|
events: limitedEvents, |
|
|
|
|
profiles: [], |
|
|
|
|
relays: relays |
|
|
|
|
}; |
|
|
|
|
} else { |
|
|
|
|
// Use the built filter |
|
|
|
|
const events = await nostrClient.fetchEvents( |
|
|
|
|
[filter], |
|
|
|
|
relays, |
|
|
|
|
{ useCache: true, cacheResults: true } |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
// Track which relay each event came from |
|
|
|
|
for (const event of events) { |
|
|
|
|
if (!newEventRelayMap.has(event.id)) { |
|
|
|
|
newEventRelayMap.set(event.id, relays[0] || 'unknown'); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Update eventRelayMap |
|
|
|
|
eventRelayMap.clear(); |
|
|
|
|
for (const [eventId, relay] of newEventRelayMap) { |
|
|
|
|
eventRelayMap.set(eventId, relay); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Update results |
|
|
|
|
searchResults = { |
|
|
|
|
events: events, |
|
|
|
|
profiles: [], |
|
|
|
|
relays: relays |
|
|
|
|
}; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
searching = false; |
|
|
|
|
searchTimeout = null; |
|
|
|
|
}, 10000); |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Error searching with filters:', error); |
|
|
|
|
searching = false; |
|
|
|
|
searchResults = { events: [], profiles: [], relays: [] }; |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
// Has search term - always combine with filters (kind dropdown + advanced filters) |
|
|
|
|
searching = true; |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
await nostrClient.initialize(); |
|
|
|
|
const relays = relayManager.getAllAvailableRelays(); |
|
|
|
|
const newEventRelayMap = new Map<string, string>(); |
|
|
|
|
|
|
|
|
|
// Build filter from all form inputs (includes selectedKind and advanced filters) |
|
|
|
|
const filter = await buildFilter(); |
|
|
|
|
|
|
|
|
|
// Decode bech32 formats in search term and add to filter |
|
|
|
|
let decodedPubkey: string | null = null; |
|
|
|
|
let decodedEventId: string | null = null; |
|
|
|
|
|
|
|
|
|
// Check if search term is a bech32 format (npub, nprofile, note, nevent, naddr) |
|
|
|
|
if (/^(npub|nprofile|note|nevent|naddr)1[a-z0-9]+$/i.test(searchTerm)) { |
|
|
|
|
try { |
|
|
|
|
const decoded = nip19.decode(searchTerm); |
|
|
|
|
|
|
|
|
|
if (decoded.type === 'npub') { |
|
|
|
|
decodedPubkey = String(decoded.data).toLowerCase(); |
|
|
|
|
} else if (decoded.type === 'nprofile') { |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
|
|
|
decodedPubkey = String(decoded.data.pubkey).toLowerCase(); |
|
|
|
|
} |
|
|
|
|
} else if (decoded.type === 'note') { |
|
|
|
|
decodedEventId = String(decoded.data).toLowerCase(); |
|
|
|
|
} else if (decoded.type === 'nevent') { |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
|
|
|
|
decodedEventId = String(decoded.data.id).toLowerCase(); |
|
|
|
|
} |
|
|
|
|
} else if (decoded.type === 'naddr') { |
|
|
|
|
// naddr encodes kind + pubkey + d tag, we can extract pubkey |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
|
|
|
decodedPubkey = String(decoded.data.pubkey).toLowerCase(); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} catch (error) { |
|
|
|
|
// Not a valid bech32, continue with text search |
|
|
|
|
console.debug('Error decoding bech32:', error); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if search term is a hex string (64 hex chars) |
|
|
|
|
// Could be event ID or pubkey |
|
|
|
|
if (!decodedPubkey && !decodedEventId && /^[0-9a-f]{64}$/i.test(searchTerm)) { |
|
|
|
|
// Try to get event from cache first to determine if it's an event ID |
|
|
|
|
const { getEvent } = await import('../../lib/services/cache/event-cache.js'); |
|
|
|
|
const hexLower = searchTerm.toLowerCase(); |
|
|
|
|
const cachedEvent = await getEvent(hexLower); |
|
|
|
|
|
|
|
|
|
if (cachedEvent) { |
|
|
|
|
// It's an event ID |
|
|
|
|
decodedEventId = hexLower; |
|
|
|
|
} else { |
|
|
|
|
// Assume it's a pubkey (more common in searches) |
|
|
|
|
decodedPubkey = hexLower; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Add decoded pubkey to authors filter |
|
|
|
|
if (decodedPubkey) { |
|
|
|
|
if (filter.authors) { |
|
|
|
|
// Add to existing authors if not already present |
|
|
|
|
const authors = Array.isArray(filter.authors) ? filter.authors : [filter.authors]; |
|
|
|
|
if (!authors.includes(decodedPubkey)) { |
|
|
|
|
filter.authors = [...authors, decodedPubkey]; |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
filter.authors = [decodedPubkey]; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Add decoded event ID to ids filter |
|
|
|
|
if (decodedEventId) { |
|
|
|
|
if (filter.ids) { |
|
|
|
|
// Add to existing ids if not already present |
|
|
|
|
const ids = Array.isArray(filter.ids) ? filter.ids : [filter.ids]; |
|
|
|
|
if (!ids.includes(decodedEventId)) { |
|
|
|
|
filter.ids = [...ids, decodedEventId]; |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
filter.ids = [decodedEventId]; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if filter has meaningful conditions (not just limit) |
|
|
|
|
const hasFilterConditions = Object.keys(filter).length > 1 || |
|
|
|
|
(filter.kinds && filter.kinds.length > 0) || |
|
|
|
|
filter.ids || filter.authors || filter['#e'] || filter['#p'] || |
|
|
|
|
filter['#a'] || filter['#q'] || filter['#T'] || filter['#C'] || |
|
|
|
|
filter['#d'] || filter.since || filter.until; |
|
|
|
|
|
|
|
|
|
let eventsToSearch: NostrEvent[] = []; |
|
|
|
|
|
|
|
|
|
if (hasFilterConditions) { |
|
|
|
|
// Has filter conditions, use them |
|
|
|
|
eventsToSearch = await nostrClient.fetchEvents( |
|
|
|
|
[filter], |
|
|
|
|
relays, |
|
|
|
|
{ useCache: true, cacheResults: true } |
|
|
|
|
); |
|
|
|
|
} else { |
|
|
|
|
// No filters at all, fall back to UnifiedSearch for better text search |
|
|
|
|
unifiedSearchComponent.triggerSearch(); |
|
|
|
|
searchTimeout = setTimeout(() => { |
|
|
|
|
searching = false; |
|
|
|
|
searchTimeout = null; |
|
|
|
|
}, 10000); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// If we decoded a pubkey or event ID, we already filtered by that, so just return those events |
|
|
|
|
// Otherwise, filter by text content client-side |
|
|
|
|
let filteredEvents: NostrEvent[] = []; |
|
|
|
|
|
|
|
|
|
if (decodedPubkey || decodedEventId) { |
|
|
|
|
// Already filtered by pubkey/event ID, use results as-is |
|
|
|
|
filteredEvents = eventsToSearch; |
|
|
|
|
} else { |
|
|
|
|
// Filter by text content client-side |
|
|
|
|
const queryLower = searchTerm.toLowerCase(); |
|
|
|
|
filteredEvents = eventsToSearch.filter(event => { |
|
|
|
|
// Check content |
|
|
|
|
if (event.content.toLowerCase().includes(queryLower)) return true; |
|
|
|
|
|
|
|
|
|
// Check title tag |
|
|
|
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
|
|
|
|
if (titleTag?.[1]?.toLowerCase().includes(queryLower)) return true; |
|
|
|
|
|
|
|
|
|
// Check summary tag |
|
|
|
|
const summaryTag = event.tags.find(t => t[0] === 'summary'); |
|
|
|
|
if (summaryTag?.[1]?.toLowerCase().includes(queryLower)) return true; |
|
|
|
|
|
|
|
|
|
// Check if search term matches event ID or pubkey |
|
|
|
|
if (event.id.toLowerCase().includes(queryLower)) return true; |
|
|
|
|
if (event.pubkey.toLowerCase().includes(queryLower)) return true; |
|
|
|
|
|
|
|
|
|
return false; |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Sort by relevance (exact content match first, then by created_at) |
|
|
|
|
// Only do text-based sorting if we didn't decode a pubkey/event ID |
|
|
|
|
const sortedEvents = decodedPubkey || decodedEventId |
|
|
|
|
? filteredEvents.sort((a, b) => b.created_at - a.created_at) |
|
|
|
|
: filteredEvents.sort((a, b) => { |
|
|
|
|
const queryLower = searchTerm.toLowerCase(); |
|
|
|
|
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; |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
// Track which relay each event came from |
|
|
|
|
for (const event of sortedEvents) { |
|
|
|
|
if (!newEventRelayMap.has(event.id)) { |
|
|
|
|
newEventRelayMap.set(event.id, relays[0] || 'unknown'); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Update eventRelayMap |
|
|
|
|
eventRelayMap.clear(); |
|
|
|
|
for (const [eventId, relay] of newEventRelayMap) { |
|
|
|
|
eventRelayMap.set(eventId, relay); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Update results |
|
|
|
|
searchResults = { |
|
|
|
|
events: sortedEvents, |
|
|
|
|
profiles: [], |
|
|
|
|
relays: relays |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
searching = false; |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Error searching with combined filters:', error); |
|
|
|
|
searching = false; |
|
|
|
|
searchResults = { events: [], profiles: [], relays: [] }; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -117,11 +688,6 @@
@@ -117,11 +688,6 @@
|
|
|
|
|
unifiedSearchComponent.clearSearch(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Clear SearchAddressableEvents |
|
|
|
|
if (addressableSearchComponent) { |
|
|
|
|
addressableSearchComponent.clearSearch(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Clear local state |
|
|
|
|
searchResults = { events: [], profiles: [] }; |
|
|
|
|
cacheResults = { events: [], profiles: [] }; |
|
|
|
|
@ -130,6 +696,21 @@
@@ -130,6 +696,21 @@
|
|
|
|
|
selectedKind = null; |
|
|
|
|
selectedKindString = ''; |
|
|
|
|
|
|
|
|
|
// Clear filter inputs |
|
|
|
|
filterIds = ''; |
|
|
|
|
filterAuthors = ''; |
|
|
|
|
filterKinds = ''; |
|
|
|
|
filterE = ''; |
|
|
|
|
filterP = ''; |
|
|
|
|
filterA = ''; |
|
|
|
|
filterQ = ''; |
|
|
|
|
filterT = ''; |
|
|
|
|
filterC = ''; |
|
|
|
|
filterD = ''; |
|
|
|
|
filterSince = ''; |
|
|
|
|
filterUntil = ''; |
|
|
|
|
filterLimit = 100; |
|
|
|
|
|
|
|
|
|
// Clear timeouts |
|
|
|
|
if (searchTimeout) { |
|
|
|
|
clearTimeout(searchTimeout); |
|
|
|
|
@ -137,6 +718,7 @@
@@ -137,6 +718,7 @@
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onMount(async () => { |
|
|
|
|
await nostrClient.initialize(); |
|
|
|
|
}); |
|
|
|
|
@ -148,7 +730,7 @@
@@ -148,7 +730,7 @@
|
|
|
|
|
<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} |
|
|
|
|
{#if hasAnyInput} |
|
|
|
|
<button |
|
|
|
|
class="clear-button" |
|
|
|
|
onclick={clearAllSearches} |
|
|
|
|
@ -202,6 +784,167 @@
@@ -202,6 +784,167 @@
|
|
|
|
|
{searching ? 'Searching...' : 'Search'} |
|
|
|
|
</button> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="advanced-filters-section"> |
|
|
|
|
<button |
|
|
|
|
type="button" |
|
|
|
|
class="toggle-advanced-filters" |
|
|
|
|
onclick={() => showAdvancedFilters = !showAdvancedFilters} |
|
|
|
|
aria-label="Toggle advanced filters" |
|
|
|
|
> |
|
|
|
|
{showAdvancedFilters ? '▼' : '▶'} Advanced Filters (NIP-01) |
|
|
|
|
</button> |
|
|
|
|
|
|
|
|
|
{#if showAdvancedFilters} |
|
|
|
|
<div class="advanced-filters-grid"> |
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-ids" class="filter-label">IDs (comma-separated event IDs):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-ids" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterIds} |
|
|
|
|
placeholder="64-char hex event IDs" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-authors" class="filter-label">Authors (comma-separated pubkeys):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-authors" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterAuthors} |
|
|
|
|
placeholder="64-char hex pubkeys" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-kinds" class="filter-label">Kinds (comma-separated, overrides kind dropdown):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-kinds" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterKinds} |
|
|
|
|
placeholder="e.g., 1, 7, 11" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-e" class="filter-label">#e tag (comma-separated event IDs):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-e" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterE} |
|
|
|
|
placeholder="64-char hex event IDs" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-p" class="filter-label">#p tag (comma-separated pubkeys):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-p" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterP} |
|
|
|
|
placeholder="64-char hex pubkeys" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-a" class="filter-label">#a tag (comma-separated addressable refs):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-a" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterA} |
|
|
|
|
placeholder="kind:pubkey:d-tag" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-q" class="filter-label">#q tag (comma-separated quoted event IDs):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-q" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterQ} |
|
|
|
|
placeholder="64-char hex event IDs" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-t" class="filter-label">#T tag (comma-separated topics):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-t" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterT} |
|
|
|
|
placeholder="topic1, topic2, topic3" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-c" class="filter-label">#C tag (comma-separated categories):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-c" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterC} |
|
|
|
|
placeholder="category1, category2, category3" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-d" class="filter-label">#d tag (comma-separated d-tag values):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-d" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterD} |
|
|
|
|
placeholder="d-tag1, d-tag2, d-tag3" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-since" class="filter-label">Since (date-time or unix timestamp):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-since" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterSince} |
|
|
|
|
placeholder="2024-01-15T14:30:00Z or unix timestamp" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
<small class="filter-hint">Accepts ISO 8601 format (e.g., 2024-01-15T14:30:00Z, 2024-01-15T14:30:00+05:00) or unix timestamp</small> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-until" class="filter-label">Until (date-time or unix timestamp):</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-until" |
|
|
|
|
type="text" |
|
|
|
|
bind:value={filterUntil} |
|
|
|
|
placeholder="2024-01-15T14:30:00Z or unix timestamp" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
<small class="filter-hint">Accepts ISO 8601 format (e.g., 2024-01-15T14:30:00Z, 2024-01-15T14:30:00+05:00) or unix timestamp</small> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="filter-group"> |
|
|
|
|
<label for="filter-limit" class="filter-label">Limit:</label> |
|
|
|
|
<input |
|
|
|
|
id="filter-limit" |
|
|
|
|
type="number" |
|
|
|
|
bind:value={filterLimit} |
|
|
|
|
min="1" |
|
|
|
|
max="1000" |
|
|
|
|
class="filter-input" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
</section> |
|
|
|
|
|
|
|
|
|
@ -289,10 +1032,6 @@
@@ -289,10 +1032,6 @@
|
|
|
|
|
</div> |
|
|
|
|
</section> |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
<section class="find-section"> |
|
|
|
|
<SearchAddressableEvents bind:this={addressableSearchComponent} /> |
|
|
|
|
</section> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
</main> |
|
|
|
|
@ -672,4 +1411,116 @@
@@ -672,4 +1411,116 @@
|
|
|
|
|
:global(.dark) .no-results { |
|
|
|
|
color: var(--fog-dark-text-light, #9ca3af); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.advanced-filters-section { |
|
|
|
|
margin-top: 1.5rem; |
|
|
|
|
padding-top: 1.5rem; |
|
|
|
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .advanced-filters-section { |
|
|
|
|
border-top-color: var(--fog-dark-border, #374151); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.toggle-advanced-filters { |
|
|
|
|
background: none; |
|
|
|
|
border: none; |
|
|
|
|
color: var(--fog-accent, #64748b); |
|
|
|
|
cursor: pointer; |
|
|
|
|
font-size: 0.875rem; |
|
|
|
|
font-weight: 500; |
|
|
|
|
padding: 0.5rem 0; |
|
|
|
|
text-align: left; |
|
|
|
|
width: 100%; |
|
|
|
|
transition: color 0.2s; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .toggle-advanced-filters { |
|
|
|
|
color: var(--fog-dark-accent, #94a3b8); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.toggle-advanced-filters:hover { |
|
|
|
|
color: var(--fog-text, #475569); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .toggle-advanced-filters:hover { |
|
|
|
|
color: var(--fog-dark-text, #cbd5e1); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.advanced-filters-grid { |
|
|
|
|
display: grid; |
|
|
|
|
grid-template-columns: 1fr; |
|
|
|
|
gap: 1rem; |
|
|
|
|
margin-top: 1rem; |
|
|
|
|
padding: 1rem; |
|
|
|
|
background: var(--fog-highlight, #f3f4f6); |
|
|
|
|
border-radius: 0.375rem; |
|
|
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .advanced-filters-grid { |
|
|
|
|
background: var(--fog-dark-highlight, #374151); |
|
|
|
|
border-color: var(--fog-dark-border, #475569); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@media (min-width: 768px) { |
|
|
|
|
.advanced-filters-grid { |
|
|
|
|
grid-template-columns: repeat(2, 1fr); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.filter-group { |
|
|
|
|
display: flex; |
|
|
|
|
flex-direction: column; |
|
|
|
|
gap: 0.5rem; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.filter-label { |
|
|
|
|
font-size: 0.875rem; |
|
|
|
|
font-weight: 500; |
|
|
|
|
color: var(--fog-text, #1f2937); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .filter-label { |
|
|
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.filter-input { |
|
|
|
|
padding: 0.5rem; |
|
|
|
|
border: 1px solid var(--fog-border, #cbd5e1); |
|
|
|
|
border-radius: 0.375rem; |
|
|
|
|
background: var(--fog-post, #ffffff); |
|
|
|
|
color: var(--fog-text, #1f2937); |
|
|
|
|
font-size: 0.875rem; |
|
|
|
|
font-family: inherit; |
|
|
|
|
width: 100%; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.filter-input:focus { |
|
|
|
|
outline: none; |
|
|
|
|
border-color: var(--fog-accent, #64748b); |
|
|
|
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .filter-input { |
|
|
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
|
|
background: var(--fog-dark-post, #1f2937); |
|
|
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .filter-input:focus { |
|
|
|
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
|
|
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.filter-hint { |
|
|
|
|
font-size: 0.75rem; |
|
|
|
|
color: var(--fog-text-light, #6b7280); |
|
|
|
|
margin-top: 0.25rem; |
|
|
|
|
display: block; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .filter-hint { |
|
|
|
|
color: var(--fog-dark-text-light, #9ca3af); |
|
|
|
|
} |
|
|
|
|
</style> |
|
|
|
|
|