7 changed files with 1172 additions and 67 deletions
@ -0,0 +1,646 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import ProfileBadge from '../layout/ProfileBadge.svelte'; |
||||||
|
import RelayBadge from '../layout/RelayBadge.svelte'; |
||||||
|
import CacheBadge from '../layout/CacheBadge.svelte'; |
||||||
|
import { getKindInfo } 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 timeoutId: 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 |
||||||
|
|
||||||
|
// 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 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) { |
||||||
|
// Limit to 100 results total |
||||||
|
if (resultsMap.size >= 100) { |
||||||
|
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 || eventRelayMap.get(event.id) |
||||||
|
}; |
||||||
|
|
||||||
|
// Update or add result |
||||||
|
resultsMap.set(event.id, result); |
||||||
|
|
||||||
|
// Update results array (sorted by relevance, limit to 100) |
||||||
|
results = Array.from(resultsMap.values()) |
||||||
|
.sort((a, b) => b.relevance - a.relevance) |
||||||
|
.slice(0, 100); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
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().toLowerCase(); |
||||||
|
|
||||||
|
// Set timeout |
||||||
|
timeoutId = setTimeout(() => { |
||||||
|
searching = false; |
||||||
|
timeoutId = null; |
||||||
|
}, SEARCH_TIMEOUT); |
||||||
|
|
||||||
|
try { |
||||||
|
// Fetch all events in kind range 30000-39999 |
||||||
|
const kinds: number[] = []; |
||||||
|
for (let kind = 30000; kind <= 39999; kind++) { |
||||||
|
kinds.push(kind); |
||||||
|
} |
||||||
|
|
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
|
||||||
|
// Use onUpdateWithRelay to process events as they arrive with relay info |
||||||
|
await nostrClient.fetchEvents( |
||||||
|
[{ kinds, limit: 100 }], |
||||||
|
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, query, 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 handleKeydown(e: KeyboardEvent) { |
||||||
|
if (e.key === 'Enter') { |
||||||
|
search(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
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>'); |
||||||
|
} |
||||||
|
</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"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
bind:value={searchQuery} |
||||||
|
onkeydown={handleKeydown} |
||||||
|
placeholder="e.g., jane eyre, jane-eyre, Charlotte Bronte..." |
||||||
|
class="search-input" |
||||||
|
disabled={searching} |
||||||
|
/> |
||||||
|
<button |
||||||
|
class="search-button" |
||||||
|
onclick={search} |
||||||
|
disabled={searching || !searchQuery.trim()} |
||||||
|
> |
||||||
|
{searching ? 'Searching...' : 'Search'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if results.length > 0} |
||||||
|
<div class="results-container"> |
||||||
|
<h3>Results ({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> |
||||||
|
</div> |
||||||
|
{:else if !searching && searchQuery.trim()} |
||||||
|
<div class="no-results"> |
||||||
|
No addressable events found matching "{searchQuery}" |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.addressable-search { |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.5rem; |
||||||
|
padding: 2rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .addressable-search { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
.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, #6b7280); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .section-description { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
.search-container { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.search-input { |
||||||
|
flex: 1; |
||||||
|
padding: 0.75rem; |
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-input { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.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: white; |
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-button { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
color: var(--fog-dark-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
.search-button:hover:not(:disabled) { |
||||||
|
opacity: 0.9; |
||||||
|
transform: translateY(-1px); |
||||||
|
} |
||||||
|
|
||||||
|
.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, #6b7280); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .results-container h3 { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
.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, #6b7280); |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-radius: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind-label { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
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, #6b7280); |
||||||
|
font-weight: 600; |
||||||
|
text-transform: uppercase; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .metadata-label { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
.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, #6b7280); |
||||||
|
font-family: monospace; |
||||||
|
word-break: break-all; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .event-id { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
.no-results { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .no-results { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
clickable?: boolean; // If true, make badge clickable to navigate to cache page |
||||||
|
} |
||||||
|
|
||||||
|
let { clickable = true }: Props = $props(); |
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) { |
||||||
|
if (clickable) { |
||||||
|
e.stopPropagation(); |
||||||
|
goto('/cache'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) { |
||||||
|
if (clickable && (e.key === 'Enter' || e.key === ' ')) { |
||||||
|
e.preventDefault(); |
||||||
|
e.stopPropagation(); |
||||||
|
goto('/cache'); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if clickable} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="cache-badge clickable" |
||||||
|
onclick={handleClick} |
||||||
|
onkeydown={handleKeydown} |
||||||
|
title="View cache page" |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
<img src="/favicon.ico" alt="Cache" class="cache-icon" /> |
||||||
|
<span class="cache-label">Cache</span> |
||||||
|
</button> |
||||||
|
{:else} |
||||||
|
<span |
||||||
|
class="cache-badge" |
||||||
|
title="From cache" |
||||||
|
tabindex="-1" |
||||||
|
> |
||||||
|
<img src="/favicon.ico" alt="Cache" class="cache-icon" /> |
||||||
|
<span class="cache-label">Cache</span> |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.cache-badge { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.25rem; |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
font-size: 0.75rem; |
||||||
|
font-weight: 500; |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .cache-badge { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
border-color: var(--fog-dark-border, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.cache-icon { |
||||||
|
width: 0.875rem; |
||||||
|
height: 0.875rem; |
||||||
|
object-fit: contain; |
||||||
|
flex-shrink: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.cache-label { |
||||||
|
font-family: monospace; |
||||||
|
} |
||||||
|
|
||||||
|
.cache-badge.clickable { |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.cache-badge.clickable:hover { |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
transform: translateY(-1px); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .cache-badge.clickable:hover { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
color: var(--fog-dark-text, #1f2937); |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.cache-badge.clickable:focus { |
||||||
|
outline: none; |
||||||
|
box-shadow: 0 0 0 2px var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .cache-badge.clickable:focus { |
||||||
|
box-shadow: 0 0 0 2px var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
button.cache-badge { |
||||||
|
font-family: inherit; |
||||||
|
font-size: inherit; |
||||||
|
line-height: inherit; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
relayUrl: string; |
||||||
|
clickable?: boolean; // If true, make badge clickable to navigate to relay feed |
||||||
|
} |
||||||
|
|
||||||
|
let { relayUrl, clickable = true }: Props = $props(); |
||||||
|
|
||||||
|
function getRelayDomain(url: string): string { |
||||||
|
try { |
||||||
|
// Remove protocol (wss://, ws://, https://, http://) |
||||||
|
const withoutProtocol = url.replace(/^wss?:\/\//, '').replace(/^https?:\/\//, ''); |
||||||
|
// Remove port if present |
||||||
|
const domain = withoutProtocol.split(':')[0]; |
||||||
|
// Remove path if present |
||||||
|
return domain.split('/')[0]; |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) { |
||||||
|
if (clickable) { |
||||||
|
e.stopPropagation(); |
||||||
|
const domain = getRelayDomain(relayUrl); |
||||||
|
goto(`/feed/relay/${encodeURIComponent(domain)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const displayName = $derived(getRelayDomain(relayUrl)); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if clickable} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="relay-badge clickable" |
||||||
|
onclick={handleClick} |
||||||
|
title={`View feed from ${displayName}`} |
||||||
|
> |
||||||
|
{displayName} |
||||||
|
</button> |
||||||
|
{:else} |
||||||
|
<span |
||||||
|
class="relay-badge" |
||||||
|
title={displayName} |
||||||
|
> |
||||||
|
{displayName} |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.relay-badge { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
font-size: 0.75rem; |
||||||
|
font-weight: 500; |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
font-family: monospace; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .relay-badge { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
border-color: var(--fog-dark-border, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.relay-badge.clickable { |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.relay-badge.clickable:hover { |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
transform: translateY(-1px); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .relay-badge.clickable:hover { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
color: var(--fog-dark-text, #1f2937); |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.relay-badge.clickable:focus { |
||||||
|
outline: none; |
||||||
|
box-shadow: 0 0 0 2px var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .relay-badge.clickable:focus { |
||||||
|
box-shadow: 0 0 0 2px var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
button.relay-badge { |
||||||
|
font-family: inherit; |
||||||
|
font-size: inherit; |
||||||
|
line-height: inherit; |
||||||
|
} |
||||||
|
</style> |
||||||
Loading…
Reference in new issue