9 changed files with 882 additions and 183 deletions
@ -0,0 +1,302 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
||||||
|
import EventMenu from '../../components/EventMenu.svelte'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { getKindInfo, KIND } from '../../types/kind-lookup.js'; |
||||||
|
import { getHighlightsForEvent } from '../../services/nostr/highlight-service.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
highlight: NostrEvent; // The highlight event (kind 9802) |
||||||
|
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer |
||||||
|
} |
||||||
|
|
||||||
|
let { highlight, onOpenEvent }: Props = $props(); |
||||||
|
|
||||||
|
let sourceEvent = $state<NostrEvent | null>(null); |
||||||
|
let loadingSource = $state(false); |
||||||
|
|
||||||
|
// Extract source event ID from e-tag or a-tag |
||||||
|
function getSourceEventId(): string | null { |
||||||
|
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]); |
||||||
|
if (eTag && eTag[1]) { |
||||||
|
return eTag[1]; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract source address from a-tag |
||||||
|
function getSourceAddress(): string | null { |
||||||
|
const aTag = highlight.tags.find(t => t[0] === 'a' && t[1]); |
||||||
|
if (aTag && aTag[1]) { |
||||||
|
return aTag[1]; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await loadSourceEvent(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadSourceEvent() { |
||||||
|
const sourceEventId = getSourceEventId(); |
||||||
|
if (!sourceEventId || loadingSource) return; |
||||||
|
|
||||||
|
loadingSource = true; |
||||||
|
try { |
||||||
|
const relays = relayManager.getFeedReadRelays(); |
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ ids: [sourceEventId], limit: 1 }], |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (events.length > 0) { |
||||||
|
sourceEvent = events[0]; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading source event:', error); |
||||||
|
} finally { |
||||||
|
loadingSource = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getRelativeTime(): string { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const diff = now - highlight.created_at; |
||||||
|
const hours = Math.floor(diff / 3600); |
||||||
|
const days = Math.floor(diff / 86400); |
||||||
|
const minutes = Math.floor(diff / 60); |
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`; |
||||||
|
if (hours > 0) return `${hours}h ago`; |
||||||
|
if (minutes > 0) return `${minutes}m ago`; |
||||||
|
return 'just now'; |
||||||
|
} |
||||||
|
|
||||||
|
function getClientName(): string | null { |
||||||
|
const clientTag = highlight.tags.find((t) => t[0] === 'client'); |
||||||
|
return clientTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function handleCardClick(e: MouseEvent) { |
||||||
|
// Don't open drawer if clicking on interactive elements |
||||||
|
const target = e.target as HTMLElement; |
||||||
|
if ( |
||||||
|
target.tagName === 'BUTTON' || |
||||||
|
target.tagName === 'A' || |
||||||
|
target.closest('button') || |
||||||
|
target.closest('a') || |
||||||
|
target.closest('.post-actions') |
||||||
|
) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Open source event if available |
||||||
|
if (onOpenEvent && sourceEvent) { |
||||||
|
onOpenEvent(sourceEvent); |
||||||
|
} else if (sourceEvent) { |
||||||
|
// Navigate to source event page |
||||||
|
window.location.href = `/event/${sourceEvent.id}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleCardKeydown(e: KeyboardEvent) { |
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const target = e.target as HTMLElement; |
||||||
|
if ( |
||||||
|
target.tagName === 'BUTTON' || |
||||||
|
target.tagName === 'A' || |
||||||
|
target.closest('button') || |
||||||
|
target.closest('a') || |
||||||
|
target.closest('.post-actions') |
||||||
|
) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
e.preventDefault(); |
||||||
|
|
||||||
|
if (onOpenEvent && sourceEvent) { |
||||||
|
onOpenEvent(sourceEvent); |
||||||
|
} else if (sourceEvent) { |
||||||
|
window.location.href = `/event/${sourceEvent.id}`; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<article |
||||||
|
class="highlight-card" |
||||||
|
data-highlight-id={highlight.id} |
||||||
|
id="highlight-{highlight.id}" |
||||||
|
onclick={handleCardClick} |
||||||
|
onkeydown={handleCardKeydown} |
||||||
|
class:cursor-pointer={!!sourceEvent} |
||||||
|
{...(sourceEvent ? { role: "button", tabindex: 0 } : {})} |
||||||
|
> |
||||||
|
<div class="highlight-header"> |
||||||
|
<div class="highlight-badge">✨ Highlight</div> |
||||||
|
<div class="highlight-meta"> |
||||||
|
<ProfileBadge pubkey={highlight.pubkey} /> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span> |
||||||
|
{#if getClientName()} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span> |
||||||
|
{/if} |
||||||
|
<div class="ml-auto"> |
||||||
|
<EventMenu event={highlight} showContentActions={true} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="highlight-content"> |
||||||
|
<MarkdownRenderer content={highlight.content} event={highlight} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if sourceEvent} |
||||||
|
<div class="source-event-link"> |
||||||
|
<a href="/event/{sourceEvent.id}" class="source-link" onclick={(e) => e.stopPropagation()}> |
||||||
|
View source event → |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
{:else if loadingSource} |
||||||
|
<div class="source-event-link"> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">Loading source event...</span> |
||||||
|
</div> |
||||||
|
{:else if getSourceEventId()} |
||||||
|
<div class="source-event-link"> |
||||||
|
<a href="/event/{getSourceEventId()}" class="source-link" onclick={(e) => e.stopPropagation()}> |
||||||
|
View source event → |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="kind-badge"> |
||||||
|
<span class="kind-number">{getKindInfo(highlight.kind).number}</span> |
||||||
|
<span class="kind-description">{getKindInfo(highlight.kind).description}</span> |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
|
||||||
|
<style> |
||||||
|
.highlight-card { |
||||||
|
padding: 1rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
position: relative; |
||||||
|
border-left: 4px solid #fbbf24; /* Yellow accent for highlights */ |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .highlight-card { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
border-left-color: #fbbf24; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-header { |
||||||
|
margin-bottom: 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
background: #fef3c7; |
||||||
|
color: #92400e; |
||||||
|
border-radius: 0.25rem; |
||||||
|
font-size: 0.75rem; |
||||||
|
font-weight: 600; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .highlight-badge { |
||||||
|
background: #78350f; |
||||||
|
color: #fef3c7; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-meta { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
flex-wrap: wrap; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-content { |
||||||
|
margin-bottom: 0.75rem; |
||||||
|
padding: 0.75rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-radius: 0.25rem; |
||||||
|
border-left: 3px solid #fbbf24; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .highlight-content { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
border-left-color: #fbbf24; |
||||||
|
} |
||||||
|
|
||||||
|
.source-event-link { |
||||||
|
padding-top: 0.5rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .source-event-link { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.source-link { |
||||||
|
color: var(--fog-accent, #64748b); |
||||||
|
text-decoration: none; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.source-link:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .source-link { |
||||||
|
color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-card.cursor-pointer { |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-card.cursor-pointer:hover { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .highlight-card.cursor-pointer:hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.kind-badge { |
||||||
|
position: absolute; |
||||||
|
bottom: 0.5rem; |
||||||
|
right: 0.5rem; |
||||||
|
display: flex; |
||||||
|
flex-direction: row; |
||||||
|
align-items: center; |
||||||
|
gap: 0.25rem; |
||||||
|
font-size: 0.625rem; |
||||||
|
line-height: 1; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind-badge { |
||||||
|
color: var(--fog-dark-text-light, #6b7280); |
||||||
|
} |
||||||
|
|
||||||
|
.kind-number { |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.kind-description { |
||||||
|
font-size: 0.625rem; |
||||||
|
opacity: 0.8; |
||||||
|
} |
||||||
|
</style> |
||||||
Loading…
Reference in new issue