9 changed files with 558 additions and 228 deletions
@ -0,0 +1,235 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { stripMarkdown } from '../../services/text-utils.js'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
eventId: string; // Can be hex, note, nevent, naddr |
||||||
|
} |
||||||
|
|
||||||
|
let { eventId }: Props = $props(); |
||||||
|
|
||||||
|
let event = $state<NostrEvent | null>(null); |
||||||
|
let loading = $state(true); |
||||||
|
let error = $state(false); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await loadEvent(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadEvent() { |
||||||
|
loading = true; |
||||||
|
error = false; |
||||||
|
try { |
||||||
|
// Decode event ID |
||||||
|
let hexId: string | null = null; |
||||||
|
|
||||||
|
// Check if it's already hex |
||||||
|
if (/^[0-9a-f]{64}$/i.test(eventId)) { |
||||||
|
hexId = eventId.toLowerCase(); |
||||||
|
} else { |
||||||
|
// Try to decode bech32 |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(eventId); |
||||||
|
if (decoded.type === 'note') { |
||||||
|
hexId = String(decoded.data); |
||||||
|
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||||
|
hexId = String(decoded.data.id); |
||||||
|
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { |
||||||
|
// For naddr, we need to fetch by kind+pubkey+d |
||||||
|
// This is more complex, for now just try to get the identifier |
||||||
|
console.warn('naddr fetching not fully implemented'); |
||||||
|
error = true; |
||||||
|
return; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error('Failed to decode event ID:', e); |
||||||
|
error = true; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!hexId) { |
||||||
|
error = true; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const relays = relayManager.getThreadReadRelays(); |
||||||
|
const feedRelays = relayManager.getFeedReadRelays(); |
||||||
|
const allRelays = [...new Set([...relays, ...feedRelays])]; |
||||||
|
|
||||||
|
const loadedEvent = await nostrClient.getEventById(hexId, allRelays); |
||||||
|
event = loadedEvent; |
||||||
|
} catch (err) { |
||||||
|
console.error('Error loading embedded event:', err); |
||||||
|
error = true; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getTitle(): string { |
||||||
|
if (!event) return ''; |
||||||
|
if (event.kind === 11) { |
||||||
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
||||||
|
return titleTag?.[1] || 'Untitled'; |
||||||
|
} |
||||||
|
const firstLine = event.content.split('\n')[0].trim(); |
||||||
|
if (firstLine.length > 0 && firstLine.length < 100) { |
||||||
|
return firstLine; |
||||||
|
} |
||||||
|
return 'Event'; |
||||||
|
} |
||||||
|
|
||||||
|
function getSubject(): string | null { |
||||||
|
if (!event) return null; |
||||||
|
const subjectTag = event.tags.find(t => t[0] === 'subject'); |
||||||
|
return subjectTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function getImageUrl(): string | null { |
||||||
|
if (!event) return null; |
||||||
|
const imageTag = event.tags.find(t => t[0] === 'image'); |
||||||
|
return imageTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function getPreview(): string { |
||||||
|
if (!event) return ''; |
||||||
|
const preview = stripMarkdown(event.content).slice(0, 150); |
||||||
|
return preview.length < event.content.length ? preview + '...' : preview; |
||||||
|
} |
||||||
|
|
||||||
|
function getThreadUrl(): string { |
||||||
|
if (!event) return '#'; |
||||||
|
return `/thread/${event.id}`; |
||||||
|
} |
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) { |
||||||
|
e.preventDefault(); |
||||||
|
goto(getThreadUrl()); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<div class="embedded-event loading"> |
||||||
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading event...</span> |
||||||
|
</div> |
||||||
|
{:else if error} |
||||||
|
<div class="embedded-event error"> |
||||||
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Failed to load event</span> |
||||||
|
</div> |
||||||
|
{:else if event} |
||||||
|
<div class="embedded-event" onclick={handleClick} role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(e as any); } }}> |
||||||
|
{#if getImageUrl()} |
||||||
|
<div class="embedded-event-image"> |
||||||
|
<img src={getImageUrl()} alt={getTitle()} loading="lazy" /> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class="embedded-event-content"> |
||||||
|
<h4 class="embedded-event-title">{getTitle()}</h4> |
||||||
|
{#if getSubject()} |
||||||
|
<p class="embedded-event-subject">{getSubject()}</p> |
||||||
|
{/if} |
||||||
|
<p class="embedded-event-preview">{getPreview()}</p> |
||||||
|
<a href={getThreadUrl()} class="embedded-event-link" onclick={(e) => e.stopPropagation()}>View thread →</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.embedded-event { |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.5rem; |
||||||
|
padding: 1rem; |
||||||
|
margin: 0.5rem 0; |
||||||
|
cursor: pointer; |
||||||
|
transition: background-color 0.2s; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .embedded-event { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event:hover { |
||||||
|
background: var(--fog-highlight, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .embedded-event:hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-image { |
||||||
|
width: 100%; |
||||||
|
max-height: 200px; |
||||||
|
overflow: hidden; |
||||||
|
border-radius: 0.25rem; |
||||||
|
margin-bottom: 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-image img { |
||||||
|
width: 100%; |
||||||
|
height: auto; |
||||||
|
object-fit: cover; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-title { |
||||||
|
font-weight: 600; |
||||||
|
font-size: 1.125rem; |
||||||
|
margin: 0; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .embedded-event-title { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-subject { |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text-light, #64748b); |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .embedded-event-subject { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-preview { |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text, #475569); |
||||||
|
margin: 0; |
||||||
|
line-height: 1.5; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .embedded-event-preview { |
||||||
|
color: var(--fog-dark-text, #cbd5e1); |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-link { |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-accent, #64748b); |
||||||
|
text-decoration: none; |
||||||
|
margin-top: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event-link:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-event.loading, |
||||||
|
.embedded-event.error { |
||||||
|
padding: 1rem; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
/** |
||||||
|
* Svelte action to mount a Svelte component into a DOM element |
||||||
|
*/ |
||||||
|
import type { ComponentType, Snippet } from 'svelte'; |
||||||
|
|
||||||
|
export function mountComponent( |
||||||
|
node: HTMLElement, |
||||||
|
component: ComponentType<any>, |
||||||
|
props: Record<string, any> |
||||||
|
) { |
||||||
|
let instance: any = null; |
||||||
|
|
||||||
|
// Mount the component
|
||||||
|
if (component && typeof component === 'function') { |
||||||
|
// For Svelte 5, we need to use the component constructor differently
|
||||||
|
try { |
||||||
|
// Create a new instance
|
||||||
|
instance = new (component as any)({ |
||||||
|
target: node, |
||||||
|
props |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
console.error('Failed to mount component:', e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
update(newProps: Record<string, any>) { |
||||||
|
if (instance && typeof instance.$set === 'function') { |
||||||
|
instance.$set(newProps); |
||||||
|
} |
||||||
|
}, |
||||||
|
destroy() { |
||||||
|
if (instance && typeof instance.$destroy === 'function') { |
||||||
|
instance.$destroy(); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
Loading…
Reference in new issue