11 changed files with 1548 additions and 2117 deletions
@ -0,0 +1,672 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { KIND_LOOKUP } from '../../types/kind-lookup.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { onSearchResults }: Props = $props(); |
||||||
|
|
||||||
|
// NIP-01 standard filter controls |
||||||
|
let filterIds = $state<string>(''); |
||||||
|
let filterAuthors = $state<string>(''); |
||||||
|
let filterKinds = $state<string>(''); |
||||||
|
let filterE = $state<string>(''); |
||||||
|
let filterP = $state<string>(''); |
||||||
|
let filterA = $state<string>(''); |
||||||
|
let filterQ = $state<string>(''); |
||||||
|
let filterT = $state<string>(''); |
||||||
|
let filterC = $state<string>(''); |
||||||
|
let filterD = $state<string>(''); |
||||||
|
let filterSince = $state<string>(''); |
||||||
|
let filterUntil = $state<string>(''); |
||||||
|
let filterLimit = $state<number>(100); |
||||||
|
let searching = $state(false); |
||||||
|
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] }); |
||||||
|
const eventRelayMap = new Map<string, string>(); |
||||||
|
|
||||||
|
// Helper function to decode bech32 to hex |
||||||
|
async function decodeBech32ToHex(bech32: string, type: 'pubkey' | 'event'): Promise<string | null> { |
||||||
|
try { |
||||||
|
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) { |
||||||
|
console.debug('Error decoding bech32:', error); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Helper function to parse comma-separated values and validate hex strings |
||||||
|
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 |
||||||
|
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) { |
||||||
|
const match = part.match(/^(\d+):([^:]+)(?::(.+))?$/); |
||||||
|
if (match) { |
||||||
|
const [, kind, pubkeyPart, dTag] = match; |
||||||
|
let hexPubkey: string | null = null; |
||||||
|
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(pubkeyPart)) { |
||||||
|
hexPubkey = pubkeyPart.toLowerCase(); |
||||||
|
} else { |
||||||
|
hexPubkey = await decodeBech32ToHex(pubkeyPart, 'pubkey'); |
||||||
|
} |
||||||
|
|
||||||
|
if (hexPubkey) { |
||||||
|
if (dTag) { |
||||||
|
results.push(`${kind}:${hexPubkey}:${dTag}`); |
||||||
|
} else { |
||||||
|
results.push(`${kind}:${hexPubkey}:`); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return results; |
||||||
|
} |
||||||
|
|
||||||
|
// Helper function to convert date-time string to unix timestamp |
||||||
|
function dateTimeToUnixTimestamp(dateTimeStr: string): number | null { |
||||||
|
if (!dateTimeStr.trim()) return null; |
||||||
|
|
||||||
|
try { |
||||||
|
const date = new Date(dateTimeStr.trim()); |
||||||
|
|
||||||
|
if (isNaN(date.getTime())) { |
||||||
|
const timestamp = parseInt(dateTimeStr.trim(), 10); |
||||||
|
if (!isNaN(timestamp) && timestamp > 0) { |
||||||
|
return timestamp; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return Math.floor(date.getTime() / 1000); |
||||||
|
} catch (error) { |
||||||
|
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 = {}; |
||||||
|
|
||||||
|
if (filterIds.trim()) { |
||||||
|
const ids = await parseHexList(filterIds, 'event'); |
||||||
|
if (ids.length > 0) { |
||||||
|
filter.ids = ids; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterAuthors.trim()) { |
||||||
|
const authors = await parseHexList(filterAuthors, 'pubkey'); |
||||||
|
if (authors.length > 0) { |
||||||
|
filter.authors = authors; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterKinds.trim()) { |
||||||
|
const kinds = parseNumberList(filterKinds); |
||||||
|
if (kinds.length > 0) { |
||||||
|
filter.kinds = kinds; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterE.trim()) { |
||||||
|
const eTags = await parseHexList(filterE, 'event'); |
||||||
|
if (eTags.length > 0) { |
||||||
|
filter['#e'] = eTags; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterP.trim()) { |
||||||
|
const pTags = await parseHexList(filterP, 'pubkey'); |
||||||
|
if (pTags.length > 0) { |
||||||
|
filter['#p'] = pTags; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterA.trim()) { |
||||||
|
const aTags = await parseAddressableList(filterA); |
||||||
|
if (aTags.length > 0) { |
||||||
|
filter['#a'] = aTags; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterQ.trim()) { |
||||||
|
const qTags = await parseHexList(filterQ, 'event'); |
||||||
|
if (qTags.length > 0) { |
||||||
|
filter['#q'] = qTags; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterT.trim()) { |
||||||
|
const tTags = filterT |
||||||
|
.split(',') |
||||||
|
.map(s => s.trim()) |
||||||
|
.filter(s => s.length > 0); |
||||||
|
if (tTags.length > 0) { |
||||||
|
filter['#T'] = tTags; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterC.trim()) { |
||||||
|
const cTags = filterC |
||||||
|
.split(',') |
||||||
|
.map(s => s.trim()) |
||||||
|
.filter(s => s.length > 0); |
||||||
|
if (cTags.length > 0) { |
||||||
|
filter['#C'] = cTags; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterD.trim()) { |
||||||
|
const dTags = filterD |
||||||
|
.split(',') |
||||||
|
.map(s => s.trim()) |
||||||
|
.filter(s => s.length > 0); |
||||||
|
if (dTags.length > 0) { |
||||||
|
filter['#d'] = dTags; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterSince.trim()) { |
||||||
|
const since = dateTimeToUnixTimestamp(filterSince); |
||||||
|
if (since !== null && since > 0) { |
||||||
|
filter.since = since; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterUntil.trim()) { |
||||||
|
const until = dateTimeToUnixTimestamp(filterUntil); |
||||||
|
if (until !== null && until > 0) { |
||||||
|
filter.until = until; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (filterLimit > 0) { |
||||||
|
filter.limit = filterLimit; |
||||||
|
} else { |
||||||
|
filter.limit = 100; |
||||||
|
} |
||||||
|
|
||||||
|
return filter; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleSearch() { |
||||||
|
searching = true; |
||||||
|
const newEventRelayMap = new Map<string, string>(); |
||||||
|
|
||||||
|
try { |
||||||
|
await nostrClient.initialize(); |
||||||
|
const relays = relayManager.getAllAvailableRelays(); |
||||||
|
|
||||||
|
const filter = await buildFilter(); |
||||||
|
|
||||||
|
// Check if filter has any conditions (not just limit) |
||||||
|
const hasConditions = Object.keys(filter).some(key => key !== 'limit'); |
||||||
|
|
||||||
|
if (!hasConditions) { |
||||||
|
// No filter conditions, show error or return empty |
||||||
|
searchResults = { events: [], profiles: [], relays }; |
||||||
|
if (onSearchResults) { |
||||||
|
onSearchResults({ events: [], profiles: [], relays, eventRelays: new Map() }); |
||||||
|
} |
||||||
|
searching = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch events using the 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'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by created_at (newest first) |
||||||
|
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at); |
||||||
|
|
||||||
|
searchResults = { events: sortedEvents, profiles: [], relays }; |
||||||
|
if (onSearchResults) { |
||||||
|
onSearchResults({ events: sortedEvents, profiles: [], relays, eventRelays: newEventRelayMap }); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Advanced search error:', error); |
||||||
|
searchResults = { events: [], profiles: [], relays: [] }; |
||||||
|
if (onSearchResults) { |
||||||
|
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() }); |
||||||
|
} |
||||||
|
} finally { |
||||||
|
searching = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function clearSearch() { |
||||||
|
filterIds = ''; |
||||||
|
filterAuthors = ''; |
||||||
|
filterKinds = ''; |
||||||
|
filterE = ''; |
||||||
|
filterP = ''; |
||||||
|
filterA = ''; |
||||||
|
filterQ = ''; |
||||||
|
filterT = ''; |
||||||
|
filterC = ''; |
||||||
|
filterD = ''; |
||||||
|
filterSince = ''; |
||||||
|
filterUntil = ''; |
||||||
|
filterLimit = 100; |
||||||
|
searchResults = { events: [], profiles: [] }; |
||||||
|
eventRelayMap.clear(); |
||||||
|
if (onSearchResults) { |
||||||
|
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function getSearchResults() { |
||||||
|
return searchResults; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="advanced-search"> |
||||||
|
<h2>Advanced Search (NIP-01 Filters)</h2> |
||||||
|
<p class="section-description"> |
||||||
|
Use NIP-01 standard filters to search for events. All filters are optional - combine them as needed. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div class="search-container"> |
||||||
|
<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 or bech32 (note, nevent)" |
||||||
|
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 or bech32 (npub, nprofile)" |
||||||
|
class="filter-input" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="filter-group"> |
||||||
|
<label for="filter-kinds" class="filter-label">Kinds (comma-separated):</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 or bech32" |
||||||
|
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 or bech32" |
||||||
|
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 or bech32" |
||||||
|
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 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 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> |
||||||
|
|
||||||
|
<div class="search-button-wrapper"> |
||||||
|
<button |
||||||
|
class="search-button" |
||||||
|
onclick={handleSearch} |
||||||
|
disabled={searching} |
||||||
|
aria-label="Search" |
||||||
|
> |
||||||
|
{searching ? 'Searching...' : 'Search'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.advanced-search { |
||||||
|
/* No container styling - parent .find-section handles it */ |
||||||
|
} |
||||||
|
|
||||||
|
.advanced-search h2 { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
font-size: 1.25em; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--fog-text, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 640px) { |
||||||
|
.advanced-search h2 { |
||||||
|
margin: 0 0 1.5rem 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .advanced-search h2 { |
||||||
|
color: var(--fog-dark-text, #cbd5e1); |
||||||
|
} |
||||||
|
|
||||||
|
.section-description { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
font-size: 0.875em; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 640px) { |
||||||
|
.section-description { |
||||||
|
margin: 0 0 1.5rem 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .section-description { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
.search-container { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.advanced-filters-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 1fr; |
||||||
|
gap: 0.75rem; |
||||||
|
padding: 0.75rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-radius: 0.375rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 640px) { |
||||||
|
.advanced-filters-grid { |
||||||
|
gap: 1rem; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
: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.875em; |
||||||
|
font-weight: 500; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .filter-label { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.filter-input { |
||||||
|
padding: 0.5em; |
||||||
|
border: 1px solid var(--fog-border, #cbd5e1); |
||||||
|
border-radius: 0.375rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-size: 0.875em; |
||||||
|
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.75em; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
margin-top: 0.25em; |
||||||
|
display: block; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .filter-hint { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
.search-button-wrapper { |
||||||
|
display: flex; |
||||||
|
justify-content: flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
.search-button { |
||||||
|
padding: 0.75em 1.5em; |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: #ffffff; |
||||||
|
border: none; |
||||||
|
border-radius: 0.375rem; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.875em; |
||||||
|
font-weight: 500; |
||||||
|
font-family: inherit; |
||||||
|
white-space: nowrap; |
||||||
|
transition: all 0.2s; |
||||||
|
min-width: 100px; |
||||||
|
} |
||||||
|
|
||||||
|
: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; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,277 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import UnifiedSearch from '../layout/UnifiedSearch.svelte'; |
||||||
|
import { KIND_LOOKUP } from '../../types/kind-lookup.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { onSearchResults }: Props = $props(); |
||||||
|
|
||||||
|
let unifiedSearchComponent: { triggerSearch: () => void; clearSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null); |
||||||
|
let selectedKind = $state<number | null>(null); |
||||||
|
let selectedKindString = $state<string>(''); |
||||||
|
|
||||||
|
// Get all kinds for dropdown |
||||||
|
const allKinds = Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number); |
||||||
|
|
||||||
|
// Sync selectedKindString with selectedKind |
||||||
|
$effect(() => { |
||||||
|
selectedKindString = selectedKind?.toString() || ''; |
||||||
|
}); |
||||||
|
|
||||||
|
// Sync selectedKind with selectedKindString when it changes |
||||||
|
$effect(() => { |
||||||
|
if (selectedKindString === '') { |
||||||
|
selectedKind = null; |
||||||
|
} else { |
||||||
|
const parsed = parseInt(selectedKindString); |
||||||
|
if (!isNaN(parsed)) { |
||||||
|
selectedKind = parsed; |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function handleKindChange(e: Event) { |
||||||
|
const select = e.target as HTMLSelectElement; |
||||||
|
selectedKindString = select.value; |
||||||
|
} |
||||||
|
|
||||||
|
function handleSearch() { |
||||||
|
if (unifiedSearchComponent) { |
||||||
|
unifiedSearchComponent.triggerSearch(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function clearSearch() { |
||||||
|
if (unifiedSearchComponent) { |
||||||
|
unifiedSearchComponent.clearSearch(); |
||||||
|
} |
||||||
|
selectedKind = null; |
||||||
|
selectedKindString = ''; |
||||||
|
if (onSearchResults) { |
||||||
|
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function getSearchResults() { |
||||||
|
return { events: [], profiles: [] }; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="normal-search"> |
||||||
|
<h2>Normal Search</h2> |
||||||
|
<p class="section-description"> |
||||||
|
Search for events by ID, pubkey, NIP-05, or content. Use the kind filter to narrow results. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div class="search-container"> |
||||||
|
<div class="search-bar-wrapper"> |
||||||
|
<UnifiedSearch |
||||||
|
mode="search" |
||||||
|
bind:this={unifiedSearchComponent} |
||||||
|
selectedKind={selectedKind} |
||||||
|
hideDropdownResults={true} |
||||||
|
onSearchResults={onSearchResults} |
||||||
|
placeholder="Search events, profiles, pubkeys, or enter event ID..." |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="filter-and-button-wrapper"> |
||||||
|
<div class="kind-filter-wrapper"> |
||||||
|
<label for="kind-filter" class="kind-filter-label">Filter by Kind:</label> |
||||||
|
<select |
||||||
|
id="kind-filter" |
||||||
|
bind:value={selectedKindString} |
||||||
|
onchange={handleKindChange} |
||||||
|
class="kind-filter-select" |
||||||
|
aria-label="Filter by kind" |
||||||
|
> |
||||||
|
<option value="">All Kinds</option> |
||||||
|
{#each allKinds as kindInfo} |
||||||
|
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option> |
||||||
|
{/each} |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button |
||||||
|
class="search-button" |
||||||
|
onclick={handleSearch} |
||||||
|
disabled={!unifiedSearchComponent || !unifiedSearchComponent.getFilterResult().value?.trim()} |
||||||
|
aria-label="Search" |
||||||
|
> |
||||||
|
Search |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.normal-search { |
||||||
|
/* No container styling - parent .find-section handles it */ |
||||||
|
} |
||||||
|
|
||||||
|
.normal-search h2 { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
font-size: 1.25em; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--fog-text, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 640px) { |
||||||
|
.normal-search h2 { |
||||||
|
margin: 0 0 1.5rem 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .normal-search h2 { |
||||||
|
color: var(--fog-dark-text, #cbd5e1); |
||||||
|
} |
||||||
|
|
||||||
|
.section-description { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
font-size: 0.875em; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 640px) { |
||||||
|
.section-description { |
||||||
|
margin: 0 0 1.5rem 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
: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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.kind-filter-wrapper { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 640px) { |
||||||
|
.kind-filter-wrapper { |
||||||
|
flex-direction: row; |
||||||
|
align-items: center; |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.kind-filter-label { |
||||||
|
font-size: 0.875em; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-weight: 500; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind-filter-label { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.kind-filter-select { |
||||||
|
padding: 0.75em; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-size: 0.875em; |
||||||
|
cursor: pointer; |
||||||
|
width: 100%; |
||||||
|
font-family: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 640px) { |
||||||
|
.kind-filter-select { |
||||||
|
width: auto; |
||||||
|
min-width: 200px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.kind-filter-select:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind-filter-select { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind-filter-select:focus { |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.search-bar-wrapper :global(.unified-search-container) { |
||||||
|
max-width: none; |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.search-button { |
||||||
|
padding: 0.75em 1.5em; |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: #ffffff; |
||||||
|
border: none; |
||||||
|
border-radius: 0.375rem; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.875em; |
||||||
|
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; |
||||||
|
} |
||||||
|
</style> |
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue