You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
272 lines
7.7 KiB
272 lines
7.7 KiB
<script lang="ts"> |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import EventMenu from '../EventMenu.svelte'; |
|
import { getEventLink } from '../../services/event-links.js'; |
|
import { goto } from '$app/navigation'; |
|
import IconButton from '../ui/IconButton.svelte'; |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import MediaAttachments from './MediaAttachments.svelte'; |
|
import { KIND } from '../../types/kind-lookup.js'; |
|
import { page } from '$app/stores'; |
|
|
|
interface Props { |
|
event: NostrEvent; |
|
showMenu?: boolean; |
|
hideTitle?: boolean; // If true, don't show title (already displayed elsewhere) |
|
hideImageIfInMedia?: boolean; // If true, check if image is already in MediaAttachments |
|
} |
|
|
|
let { event, showMenu = true, hideTitle = false, hideImageIfInMedia = true }: Props = $props(); |
|
|
|
// Normalize URL for comparison (same logic as MediaAttachments) |
|
function normalizeUrl(url: string): string { |
|
try { |
|
const parsed = new URL(url); |
|
// Remove query params and fragments for comparison |
|
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, ''); |
|
} catch { |
|
return url; |
|
} |
|
} |
|
|
|
// Check if image URL is already in imeta tags |
|
function isImageInImeta(imageUrl: string): boolean { |
|
if (!imageUrl) return false; |
|
const normalizedImageUrl = normalizeUrl(imageUrl); |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'imeta') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const item = tag[i]; |
|
if (item.startsWith('url ')) { |
|
const imetaUrl = item.substring(4).trim(); |
|
if (normalizeUrl(imetaUrl) === normalizedImageUrl) { |
|
return true; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
return false; |
|
} |
|
|
|
// Extract metadata tags (using $derived for reactivity) |
|
const rawImage = $derived(event.tags.find(t => t[0] === 'image' && t[1])?.[1]); |
|
const image = $derived(rawImage && (!hideImageIfInMedia || !isImageInImeta(rawImage)) ? rawImage : null); |
|
const description = $derived(event.tags.find(t => t[0] === 'description' && t[1])?.[1]); |
|
const summary = $derived(event.tags.find(t => t[0] === 'summary' && t[1])?.[1]); |
|
const author = $derived(event.tags.find(t => t[0] === 'author' && t[1])?.[1]); |
|
const title = $derived( |
|
hideTitle ? null : |
|
(event.tags.find(t => t[0] === 'title' && t[1])?.[1] || |
|
(() => { |
|
// Fallback to d-tag in Title Case |
|
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]; |
|
if (dTag) { |
|
return dTag.split('-').map(word => |
|
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() |
|
).join(' '); |
|
} |
|
return null; |
|
})()) |
|
); |
|
|
|
const hasMetadata = $derived(image || description || summary || author || title); |
|
const hasContent = $derived(event.content && event.content.trim().length > 0); |
|
const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content |
|
|
|
// Media kinds that should auto-render media (except on /feed) |
|
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; |
|
const isMediaKind = $derived(MEDIA_KINDS.includes(event.kind)); |
|
const isOnFeedPage = $derived($page.url.pathname === '/feed'); |
|
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage); |
|
</script> |
|
|
|
{#if shouldShowMetadata} |
|
<div class="metadata-card"> |
|
<div class="metadata-header"> |
|
{#if title} |
|
<h2 class="metadata-title">{title}</h2> |
|
{/if} |
|
{#if showMenu} |
|
<div class="flex items-center gap-2"> |
|
<IconButton |
|
icon="eye" |
|
label="View" |
|
size={16} |
|
onclick={() => goto(getEventLink(event))} |
|
/> |
|
{#if sessionManager.isLoggedIn()} |
|
<IconButton |
|
icon="message-square" |
|
label="Reply" |
|
size={16} |
|
onclick={() => {}} |
|
/> |
|
{/if} |
|
<EventMenu event={event} showContentActions={false} /> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
{#if image} |
|
<div class="metadata-image"> |
|
<img |
|
src={image} |
|
alt={title || description || summary || 'Metadata image'} |
|
/> |
|
</div> |
|
{/if} |
|
|
|
{#if shouldAutoRenderMedia} |
|
<MediaAttachments event={event} forceRender={isMediaKind} /> |
|
{/if} |
|
|
|
<div class="metadata-content"> |
|
{#if description} |
|
<p class="metadata-description">{description}</p> |
|
{/if} |
|
|
|
{#if summary} |
|
<p class="metadata-summary">{summary}</p> |
|
{/if} |
|
|
|
{#if author} |
|
<p class="metadata-author">Author: {author}</p> |
|
{/if} |
|
|
|
{#if !hasContent && !isMediaKind} |
|
<!-- Show all tags when there's no content, but skip for media kinds (MediaAttachments handles those) --> |
|
<div class="metadata-tags"> |
|
{#each event.tags as tag} |
|
{#if tag[0] !== 'image' && tag[0] !== 'description' && tag[0] !== 'summary' && tag[0] !== 'author' && tag[0] !== 'title' && tag[0] !== 'd' && tag[0] !== 'imeta' && tag[0] !== 'file' && tag[0] !== 'alt' && tag[0] !== 'x' && tag[0] !== 'm'} |
|
<div class="metadata-tag"> |
|
<span class="metadata-tag-name">{tag[0]}:</span> |
|
{#each tag.slice(1) as value} |
|
{#if value} |
|
<span class="metadata-tag-value">{value}</span> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<style> |
|
.metadata-card { |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
padding: 1.5rem; |
|
margin-bottom: 1.5rem; |
|
background: var(--fog-post, #ffffff); |
|
} |
|
|
|
:global(.dark) .metadata-card { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.metadata-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: flex-start; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.metadata-title { |
|
margin: 0; |
|
font-size: 1.5rem; |
|
font-weight: 600; |
|
color: var(--fog-text, #1f2937); |
|
flex: 1; |
|
} |
|
|
|
:global(.dark) .metadata-title { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.metadata-image { |
|
margin-bottom: 1rem; |
|
border-radius: 0.5rem; |
|
overflow: hidden; |
|
} |
|
|
|
.metadata-image img { |
|
max-width: 600px; |
|
width: 100%; |
|
height: auto; |
|
display: block; |
|
} |
|
|
|
.metadata-content { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.75rem; |
|
} |
|
|
|
.metadata-description, |
|
.metadata-summary { |
|
margin: 0; |
|
color: var(--fog-text, #1f2937); |
|
line-height: 1.6; |
|
} |
|
|
|
:global(.dark) .metadata-description, |
|
:global(.dark) .metadata-summary { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.metadata-author { |
|
margin: 0; |
|
font-size: 0.875rem; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .metadata-author { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.metadata-tags { |
|
margin-top: 1rem; |
|
padding-top: 1rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
} |
|
|
|
:global(.dark) .metadata-tags { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.metadata-tag { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 0.5rem; |
|
align-items: baseline; |
|
font-size: 0.875rem; |
|
} |
|
|
|
.metadata-tag-name { |
|
font-weight: 600; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .metadata-tag-name { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.metadata-tag-value { |
|
color: var(--fog-text-light, #52667a); |
|
font-family: monospace; |
|
word-break: break-all; |
|
} |
|
|
|
:global(.dark) .metadata-tag-value { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
</style>
|
|
|