|
|
|
|
@ -1,13 +1,17 @@
@@ -1,13 +1,17 @@
|
|
|
|
|
<script lang="ts"> |
|
|
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
|
import { decode } from 'blurhash'; |
|
|
|
|
import { onMount } from 'svelte'; |
|
|
|
|
|
|
|
|
|
interface Props { |
|
|
|
|
event: NostrEvent; |
|
|
|
|
forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds) |
|
|
|
|
onMediaClick?: (url: string, event: MouseEvent) => void; // Optional callback when media is clicked |
|
|
|
|
isFeedView?: boolean; // Enable blur for feed view |
|
|
|
|
thumbnailWidth?: number; // Thumbnail width in pixels (default 250) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let { event, forceRender = false, onMediaClick }: Props = $props(); |
|
|
|
|
let { event, forceRender = false, onMediaClick, isFeedView = false, thumbnailWidth = 250 }: Props = $props(); |
|
|
|
|
|
|
|
|
|
function handleMediaClick(e: MouseEvent, url: string) { |
|
|
|
|
e.stopPropagation(); // Don't trigger parent click handlers |
|
|
|
|
@ -17,14 +21,16 @@
@@ -17,14 +21,16 @@
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
interface MediaItem { |
|
|
|
|
url: string; |
|
|
|
|
url: string; // Full-size URL |
|
|
|
|
thumbnailUrl?: string; // From imeta "thumb" field or NIP-94 "thumb" tag |
|
|
|
|
blurhash?: string; // From imeta "blurhash" or "bh" field, or NIP-94 "blurhash" tag |
|
|
|
|
type: 'image' | 'video' | 'audio' | 'file'; |
|
|
|
|
mimeType?: string; |
|
|
|
|
width?: number; |
|
|
|
|
height?: number; |
|
|
|
|
width?: number; // From imeta "x" field or "dim WIDTHxHEIGHT" or NIP-94 "dim" tag |
|
|
|
|
height?: number; // From imeta "y" field or "dim WIDTHxHEIGHT" or NIP-94 "dim" tag |
|
|
|
|
size?: number; |
|
|
|
|
alt?: string; // Alt text for images |
|
|
|
|
source: 'image-tag' | 'imeta' | 'file-tag' | 'content'; |
|
|
|
|
source: 'image-tag' | 'imeta' | 'file-tag' | 'content' | 'nip94'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function normalizeUrl(url: string): string { |
|
|
|
|
@ -101,6 +107,8 @@
@@ -101,6 +107,8 @@
|
|
|
|
|
let width: number | undefined; |
|
|
|
|
let height: number | undefined; |
|
|
|
|
let alt: string | undefined; |
|
|
|
|
let thumbnailUrl: string | undefined; |
|
|
|
|
let blurhash: string | undefined; |
|
|
|
|
|
|
|
|
|
for (let i = 1; i < tag.length; i++) { |
|
|
|
|
const item = tag[i]; |
|
|
|
|
@ -114,6 +122,20 @@
@@ -114,6 +122,20 @@
|
|
|
|
|
height = parseInt(item.substring(2).trim(), 10); |
|
|
|
|
} else if (item.startsWith('alt ')) { |
|
|
|
|
alt = item.substring(4).trim(); |
|
|
|
|
} else if (item.startsWith('thumb ')) { |
|
|
|
|
thumbnailUrl = item.substring(6).trim(); |
|
|
|
|
} else if (item.startsWith('blurhash ')) { |
|
|
|
|
blurhash = item.substring(9).trim(); |
|
|
|
|
} else if (item.startsWith('bh ')) { |
|
|
|
|
blurhash = item.substring(3).trim(); |
|
|
|
|
} else if (item.startsWith('dim ')) { |
|
|
|
|
// Parse "dim WIDTHxHEIGHT" |
|
|
|
|
const dimStr = item.substring(4).trim(); |
|
|
|
|
const dimMatch = dimStr.match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i); |
|
|
|
|
if (dimMatch) { |
|
|
|
|
width = parseInt(dimMatch[1], 10); |
|
|
|
|
height = parseInt(dimMatch[2], 10); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -134,6 +156,8 @@
@@ -134,6 +156,8 @@
|
|
|
|
|
|
|
|
|
|
media.push({ |
|
|
|
|
url, |
|
|
|
|
thumbnailUrl, |
|
|
|
|
blurhash, |
|
|
|
|
type, |
|
|
|
|
mimeType, |
|
|
|
|
width, |
|
|
|
|
@ -154,7 +178,68 @@
@@ -154,7 +178,68 @@
|
|
|
|
|
media[0].alt = altTag[1]; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 3. file tags (NIP-94) |
|
|
|
|
// 3. NIP-94 tags (kind 1063) - separate tag arrays |
|
|
|
|
if (event.kind === 1063) { |
|
|
|
|
let url: string | undefined; |
|
|
|
|
let mimeType: string | undefined; |
|
|
|
|
let width: number | undefined; |
|
|
|
|
let height: number | undefined; |
|
|
|
|
let alt: string | undefined; |
|
|
|
|
let thumbnailUrl: string | undefined; |
|
|
|
|
let blurhash: string | undefined; |
|
|
|
|
let size: number | undefined; |
|
|
|
|
|
|
|
|
|
for (const tag of event.tags) { |
|
|
|
|
if (tag[0] === 'url' && tag[1]) { |
|
|
|
|
url = tag[1]; |
|
|
|
|
} else if (tag[0] === 'm' && tag[1]) { |
|
|
|
|
mimeType = tag[1]; |
|
|
|
|
} else if (tag[0] === 'thumb' && tag[1]) { |
|
|
|
|
thumbnailUrl = tag[1]; |
|
|
|
|
} else if (tag[0] === 'blurhash' && tag[1]) { |
|
|
|
|
blurhash = tag[1]; |
|
|
|
|
} else if (tag[0] === 'alt' && tag[1]) { |
|
|
|
|
alt = tag[1]; |
|
|
|
|
} else if (tag[0] === 'dim' && tag[1]) { |
|
|
|
|
// Parse "WIDTHxHEIGHT" |
|
|
|
|
const dimMatch = tag[1].match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i); |
|
|
|
|
if (dimMatch) { |
|
|
|
|
width = parseInt(dimMatch[1], 10); |
|
|
|
|
height = parseInt(dimMatch[2], 10); |
|
|
|
|
} |
|
|
|
|
} else if (tag[0] === 'size' && tag[1]) { |
|
|
|
|
size = parseInt(tag[1], 10); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (url) { |
|
|
|
|
const normalized = normalizeUrl(url); |
|
|
|
|
if (!seen.has(normalized)) { |
|
|
|
|
let type: 'image' | 'video' | 'audio' | 'file' = 'file'; |
|
|
|
|
if (mimeType) { |
|
|
|
|
if (mimeType.startsWith('image/')) type = 'image'; |
|
|
|
|
else if (mimeType.startsWith('video/')) type = 'video'; |
|
|
|
|
else if (mimeType.startsWith('audio/')) type = 'audio'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
media.push({ |
|
|
|
|
url, |
|
|
|
|
thumbnailUrl, |
|
|
|
|
blurhash, |
|
|
|
|
type, |
|
|
|
|
mimeType, |
|
|
|
|
width, |
|
|
|
|
height, |
|
|
|
|
size, |
|
|
|
|
alt, |
|
|
|
|
source: 'nip94' |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 4. Legacy file tags (fallback) |
|
|
|
|
for (const tag of event.tags) { |
|
|
|
|
if (tag[0] === 'file' && tag[1]) { |
|
|
|
|
const normalized = normalizeUrl(tag[1]); |
|
|
|
|
@ -183,7 +268,7 @@
@@ -183,7 +268,7 @@
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 4. Extract from markdown content (images in markdown syntax) |
|
|
|
|
// 5. Extract from markdown content (images in markdown syntax) |
|
|
|
|
const imageRegex = /!\[.*?\]\((.*?)\)/g; |
|
|
|
|
let match; |
|
|
|
|
while ((match = imageRegex.exec(event.content)) !== null) { |
|
|
|
|
@ -199,7 +284,7 @@
@@ -199,7 +284,7 @@
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 5. Don't extract plain image URLs from content - let markdown render them inline |
|
|
|
|
// 6. Don't extract plain image URLs from content - let markdown render them inline |
|
|
|
|
// This ensures images appear where the URL is in the content, not at the top |
|
|
|
|
// Only extract images from tags (image, imeta, file) which are handled above |
|
|
|
|
|
|
|
|
|
@ -223,10 +308,69 @@
@@ -223,10 +308,69 @@
|
|
|
|
|
const otherMedia = $derived(mediaItems.filter((m) => m.source !== 'image-tag')); |
|
|
|
|
|
|
|
|
|
let containerRef = $state<HTMLElement | null>(null); |
|
|
|
|
|
|
|
|
|
// Track loaded images for blurhash fade |
|
|
|
|
let loadedImages = $state<Set<string>>(new Set()); |
|
|
|
|
|
|
|
|
|
// Helper function to get thumbnail URL with fallbacks |
|
|
|
|
function getThumbnailUrl(item: MediaItem): string { |
|
|
|
|
if (item.thumbnailUrl) { |
|
|
|
|
return item.thumbnailUrl; |
|
|
|
|
} |
|
|
|
|
// Try query parameter for resizing (if server supports) |
|
|
|
|
if (item.type === 'image') { |
|
|
|
|
try { |
|
|
|
|
const url = new URL(item.url); |
|
|
|
|
url.searchParams.set('w', thumbnailWidth.toString()); |
|
|
|
|
return url.toString(); |
|
|
|
|
} catch { |
|
|
|
|
// If URL parsing fails, return original |
|
|
|
|
return item.url; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
return item.url; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Helper function to render blurhash as data URL |
|
|
|
|
function renderBlurhash(blurhash: string, width: number = 32, height: number = 32): string | null { |
|
|
|
|
try { |
|
|
|
|
const pixels = decode(blurhash, width, height); |
|
|
|
|
const canvas = document.createElement('canvas'); |
|
|
|
|
canvas.width = width; |
|
|
|
|
canvas.height = height; |
|
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
if (!ctx) return null; |
|
|
|
|
|
|
|
|
|
const imageData = ctx.createImageData(width, height); |
|
|
|
|
for (let i = 0; i < pixels.length; i += 4) { |
|
|
|
|
imageData.data[i] = pixels[i]; // R |
|
|
|
|
imageData.data[i + 1] = pixels[i + 1]; // G |
|
|
|
|
imageData.data[i + 2] = pixels[i + 2]; // B |
|
|
|
|
imageData.data[i + 3] = pixels[i + 3]; // A |
|
|
|
|
} |
|
|
|
|
ctx.putImageData(imageData, 0, 0); |
|
|
|
|
return canvas.toDataURL(); |
|
|
|
|
} catch (error) { |
|
|
|
|
console.warn('Failed to render blurhash:', error); |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Get blurhash data URL for an item |
|
|
|
|
function getBlurhashDataUrl(item: MediaItem): string | null { |
|
|
|
|
if (!item.blurhash) return null; |
|
|
|
|
// Use item dimensions if available, otherwise use aspect ratio or default |
|
|
|
|
const width = item.width && item.height ? Math.min(32, Math.floor((item.width / item.height) * 32)) : 32; |
|
|
|
|
const height = item.width && item.height ? Math.min(32, Math.floor((item.height / item.width) * 32)) : 32; |
|
|
|
|
return renderBlurhash(item.blurhash, width, height); |
|
|
|
|
} |
|
|
|
|
</script> |
|
|
|
|
|
|
|
|
|
<div bind:this={containerRef}> |
|
|
|
|
{#if coverImage} |
|
|
|
|
{@const thumbnailUrl = getThumbnailUrl(coverImage)} |
|
|
|
|
{@const blurhashDataUrl = getBlurhashDataUrl(coverImage)} |
|
|
|
|
{@const shouldBlur = isFeedView && !coverImage.blurhash} |
|
|
|
|
<div class="cover-image mb-4"> |
|
|
|
|
{#if onMediaClick} |
|
|
|
|
<button |
|
|
|
|
@ -235,22 +379,56 @@
@@ -235,22 +379,56 @@
|
|
|
|
|
onclick={(e) => handleMediaClick(e, coverImage.url)} |
|
|
|
|
aria-label={coverImage.alt || 'View image'} |
|
|
|
|
> |
|
|
|
|
<div class="image-container" class:feed-blur={shouldBlur}> |
|
|
|
|
{#if blurhashDataUrl} |
|
|
|
|
<img |
|
|
|
|
src={blurhashDataUrl} |
|
|
|
|
alt="" |
|
|
|
|
class="blurhash-placeholder" |
|
|
|
|
aria-hidden="true" |
|
|
|
|
/> |
|
|
|
|
{/if} |
|
|
|
|
<img |
|
|
|
|
src={thumbnailUrl} |
|
|
|
|
alt={coverImage.alt || ''} |
|
|
|
|
class="w-full max-h-96 object-cover rounded clickable-media" |
|
|
|
|
class:feed-blur={shouldBlur} |
|
|
|
|
class:loaded={loadedImages.has(coverImage.url)} |
|
|
|
|
loading="lazy" |
|
|
|
|
decoding="async" |
|
|
|
|
style="width: {thumbnailWidth}px; max-width: 100%;" |
|
|
|
|
onload={() => { |
|
|
|
|
loadedImages.add(coverImage.url); |
|
|
|
|
loadedImages = loadedImages; // Trigger reactivity |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
</button> |
|
|
|
|
{:else} |
|
|
|
|
<div class="image-container" class:feed-blur={shouldBlur}> |
|
|
|
|
{#if blurhashDataUrl} |
|
|
|
|
<img |
|
|
|
|
src={blurhashDataUrl} |
|
|
|
|
alt="" |
|
|
|
|
class="blurhash-placeholder" |
|
|
|
|
aria-hidden="true" |
|
|
|
|
/> |
|
|
|
|
{/if} |
|
|
|
|
<img |
|
|
|
|
src={coverImage.url} |
|
|
|
|
src={thumbnailUrl} |
|
|
|
|
alt={coverImage.alt || ''} |
|
|
|
|
class="w-full max-h-96 object-cover rounded clickable-media" |
|
|
|
|
class="w-full max-h-96 object-cover rounded" |
|
|
|
|
class:feed-blur={shouldBlur} |
|
|
|
|
class:loaded={loadedImages.has(coverImage.url)} |
|
|
|
|
loading="lazy" |
|
|
|
|
decoding="async" |
|
|
|
|
style="width: {thumbnailWidth}px; max-width: 100%;" |
|
|
|
|
onload={() => { |
|
|
|
|
loadedImages.add(coverImage.url); |
|
|
|
|
loadedImages = loadedImages; // Trigger reactivity |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</button> |
|
|
|
|
{:else} |
|
|
|
|
<img |
|
|
|
|
src={coverImage.url} |
|
|
|
|
alt={coverImage.alt || ''} |
|
|
|
|
class="w-full max-h-96 object-cover rounded" |
|
|
|
|
loading="lazy" |
|
|
|
|
decoding="async" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
{#if coverImage.alt} |
|
|
|
|
<div class="image-alt-text">{coverImage.alt}</div> |
|
|
|
|
@ -262,6 +440,9 @@
@@ -262,6 +440,9 @@
|
|
|
|
|
<div class="media-gallery mb-4"> |
|
|
|
|
{#each otherMedia as item} |
|
|
|
|
{#if item.type === 'image'} |
|
|
|
|
{@const thumbnailUrl = getThumbnailUrl(item)} |
|
|
|
|
{@const blurhashDataUrl = getBlurhashDataUrl(item)} |
|
|
|
|
{@const shouldBlur = isFeedView && !item.blurhash} |
|
|
|
|
<div class="media-item"> |
|
|
|
|
{#if onMediaClick} |
|
|
|
|
<button |
|
|
|
|
@ -270,22 +451,56 @@
@@ -270,22 +451,56 @@
|
|
|
|
|
onclick={(e) => handleMediaClick(e, item.url)} |
|
|
|
|
aria-label={item.alt || 'View image'} |
|
|
|
|
> |
|
|
|
|
<div class="image-container" class:feed-blur={shouldBlur}> |
|
|
|
|
{#if blurhashDataUrl} |
|
|
|
|
<img |
|
|
|
|
src={blurhashDataUrl} |
|
|
|
|
alt="" |
|
|
|
|
class="blurhash-placeholder" |
|
|
|
|
aria-hidden="true" |
|
|
|
|
/> |
|
|
|
|
{/if} |
|
|
|
|
<img |
|
|
|
|
src={thumbnailUrl} |
|
|
|
|
alt={item.alt || ''} |
|
|
|
|
class="max-w-full rounded clickable-media" |
|
|
|
|
class:feed-blur={shouldBlur} |
|
|
|
|
class:loaded={loadedImages.has(item.url)} |
|
|
|
|
loading="lazy" |
|
|
|
|
decoding="async" |
|
|
|
|
style="width: {thumbnailWidth}px; max-width: 100%;" |
|
|
|
|
onload={() => { |
|
|
|
|
loadedImages.add(item.url); |
|
|
|
|
loadedImages = loadedImages; // Trigger reactivity |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
</button> |
|
|
|
|
{:else} |
|
|
|
|
<div class="image-container" class:feed-blur={shouldBlur}> |
|
|
|
|
{#if blurhashDataUrl} |
|
|
|
|
<img |
|
|
|
|
src={blurhashDataUrl} |
|
|
|
|
alt="" |
|
|
|
|
class="blurhash-placeholder" |
|
|
|
|
aria-hidden="true" |
|
|
|
|
/> |
|
|
|
|
{/if} |
|
|
|
|
<img |
|
|
|
|
src={item.url} |
|
|
|
|
src={thumbnailUrl} |
|
|
|
|
alt={item.alt || ''} |
|
|
|
|
class="max-w-full rounded clickable-media" |
|
|
|
|
class="max-w-full rounded" |
|
|
|
|
class:feed-blur={shouldBlur} |
|
|
|
|
class:loaded={loadedImages.has(item.url)} |
|
|
|
|
loading="lazy" |
|
|
|
|
decoding="async" |
|
|
|
|
style="width: {thumbnailWidth}px; max-width: 100%;" |
|
|
|
|
onload={() => { |
|
|
|
|
loadedImages.add(item.url); |
|
|
|
|
loadedImages = loadedImages; // Trigger reactivity |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</button> |
|
|
|
|
{:else} |
|
|
|
|
<img |
|
|
|
|
src={item.url} |
|
|
|
|
alt={item.alt || ''} |
|
|
|
|
class="max-w-full rounded" |
|
|
|
|
loading="lazy" |
|
|
|
|
decoding="async" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
{#if item.alt} |
|
|
|
|
<div class="image-alt-text">{item.alt}</div> |
|
|
|
|
@ -450,4 +665,41 @@
@@ -450,4 +665,41 @@
|
|
|
|
|
opacity: 0.9; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.image-container { |
|
|
|
|
position: relative; |
|
|
|
|
display: inline-block; |
|
|
|
|
width: 100%; |
|
|
|
|
max-width: 100%; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.blurhash-placeholder { |
|
|
|
|
position: absolute; |
|
|
|
|
top: 0; |
|
|
|
|
left: 0; |
|
|
|
|
width: 100%; |
|
|
|
|
height: 100%; |
|
|
|
|
object-fit: cover; |
|
|
|
|
opacity: 0; |
|
|
|
|
transition: opacity 0.3s; |
|
|
|
|
pointer-events: none; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.image-container img:not(.blurhash-placeholder) { |
|
|
|
|
position: relative; |
|
|
|
|
z-index: 1; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.image-container:has(img:not(.blurhash-placeholder):not(.loaded)) .blurhash-placeholder { |
|
|
|
|
opacity: 1; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.feed-blur { |
|
|
|
|
filter: blur(8px); |
|
|
|
|
transition: filter 0.3s; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.feed-blur:hover { |
|
|
|
|
filter: blur(4px); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
</style> |
|
|
|
|
|