9 changed files with 558 additions and 228 deletions
@ -0,0 +1,235 @@
@@ -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 @@
@@ -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