7 changed files with 1172 additions and 67 deletions
@ -0,0 +1,646 @@
@@ -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 @@
@@ -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 @@
@@ -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