You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

995 lines
32 KiB

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