11 changed files with 1548 additions and 2117 deletions
@ -0,0 +1,672 @@
@@ -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 @@
@@ -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