|
|
|
|
@ -81,27 +81,16 @@
@@ -81,27 +81,16 @@
|
|
|
|
|
return false; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractMedia(): MediaItem[] { |
|
|
|
|
const media: MediaItem[] = []; |
|
|
|
|
const seen = new Set<string>(); |
|
|
|
|
|
|
|
|
|
// 1. Image tag (NIP-23) - cover image |
|
|
|
|
const imageTag = event.tags.find((t) => t[0] === 'image'); |
|
|
|
|
if (imageTag && imageTag[1]) { |
|
|
|
|
const normalized = normalizeUrl(imageTag[1]); |
|
|
|
|
if (!seen.has(normalized)) { |
|
|
|
|
media.push({ |
|
|
|
|
url: imageTag[1], |
|
|
|
|
type: 'image', |
|
|
|
|
source: 'image-tag' |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 2. imeta tags (NIP-92) - only display if NOT already in content |
|
|
|
|
for (const tag of event.tags) { |
|
|
|
|
if (tag[0] === 'imeta') { |
|
|
|
|
// Helper function to parse imeta tag and return metadata |
|
|
|
|
function parseImetaTag(tag: string[]): { |
|
|
|
|
url?: string; |
|
|
|
|
mimeType?: string; |
|
|
|
|
width?: number; |
|
|
|
|
height?: number; |
|
|
|
|
alt?: string; |
|
|
|
|
thumbnailUrl?: string; |
|
|
|
|
blurhash?: string; |
|
|
|
|
} { |
|
|
|
|
let url: string | undefined; |
|
|
|
|
let mimeType: string | undefined; |
|
|
|
|
let width: number | undefined; |
|
|
|
|
@ -139,37 +128,199 @@
@@ -139,37 +128,199 @@
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (url) { |
|
|
|
|
return { url, mimeType, width, height, alt, thumbnailUrl, blurhash }; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Helper function to get imeta metadata for a URL |
|
|
|
|
function getImetaForUrl(url: string): { |
|
|
|
|
mimeType?: string; |
|
|
|
|
width?: number; |
|
|
|
|
height?: number; |
|
|
|
|
alt?: string; |
|
|
|
|
thumbnailUrl?: string; |
|
|
|
|
blurhash?: string; |
|
|
|
|
} | null { |
|
|
|
|
const normalized = normalizeUrl(url); |
|
|
|
|
for (const tag of event.tags) { |
|
|
|
|
if (tag[0] === 'imeta') { |
|
|
|
|
const imeta = parseImetaTag(tag); |
|
|
|
|
if (imeta.url && normalizeUrl(imeta.url) === normalized) { |
|
|
|
|
return { |
|
|
|
|
mimeType: imeta.mimeType, |
|
|
|
|
width: imeta.width, |
|
|
|
|
height: imeta.height, |
|
|
|
|
alt: imeta.alt, |
|
|
|
|
thumbnailUrl: imeta.thumbnailUrl, |
|
|
|
|
blurhash: imeta.blurhash |
|
|
|
|
}; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractMedia(): MediaItem[] { |
|
|
|
|
const media: MediaItem[] = []; |
|
|
|
|
const seen = new Set<string>(); |
|
|
|
|
|
|
|
|
|
// 1. Image tag (NIP-23) - cover image |
|
|
|
|
const imageTag = event.tags.find((t) => t[0] === 'image'); |
|
|
|
|
if (imageTag && imageTag[1]) { |
|
|
|
|
const normalized = normalizeUrl(imageTag[1]); |
|
|
|
|
if (!seen.has(normalized)) { |
|
|
|
|
// Check for imeta metadata for this URL |
|
|
|
|
const imeta = getImetaForUrl(imageTag[1]); |
|
|
|
|
|
|
|
|
|
let type: 'image' | 'video' | 'audio' = 'image'; |
|
|
|
|
if (imeta?.mimeType) { |
|
|
|
|
if (imeta.mimeType.startsWith('video/')) type = 'video'; |
|
|
|
|
else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
media.push({ |
|
|
|
|
url: imageTag[1], |
|
|
|
|
type, |
|
|
|
|
source: 'image-tag', |
|
|
|
|
...(imeta || {}) |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 2. Extract from markdown content (images in markdown syntax) |
|
|
|
|
const imageRegex = /!\[.*?\]\((.*?)\)/g; |
|
|
|
|
let match; |
|
|
|
|
while ((match = imageRegex.exec(event.content)) !== null) { |
|
|
|
|
const url = match[1]; |
|
|
|
|
const normalized = normalizeUrl(url); |
|
|
|
|
if (!seen.has(normalized)) { |
|
|
|
|
// Check for imeta metadata for this URL |
|
|
|
|
const imeta = getImetaForUrl(url); |
|
|
|
|
|
|
|
|
|
let type: 'image' | 'video' | 'audio' = 'image'; |
|
|
|
|
if (imeta?.mimeType) { |
|
|
|
|
if (imeta.mimeType.startsWith('video/')) type = 'video'; |
|
|
|
|
else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
media.push({ |
|
|
|
|
url, |
|
|
|
|
type, |
|
|
|
|
source: 'content', |
|
|
|
|
...(imeta || {}) |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 2b. Extract from AsciiDoc content (images in AsciiDoc syntax: image::url[] or image:url[]) |
|
|
|
|
const asciidocImageRegex = /image::?([^\s\[\]]+)(?:\[[^\]]*\])?/g; |
|
|
|
|
asciidocImageRegex.lastIndex = 0; // Reset regex |
|
|
|
|
let asciidocMatch; |
|
|
|
|
while ((asciidocMatch = asciidocImageRegex.exec(event.content)) !== null) { |
|
|
|
|
const url = asciidocMatch[1]; |
|
|
|
|
// Skip if it's not a URL (could be a relative path, but we only want absolute URLs) |
|
|
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) { |
|
|
|
|
continue; |
|
|
|
|
} |
|
|
|
|
const normalized = normalizeUrl(url); |
|
|
|
|
if (!seen.has(normalized)) { |
|
|
|
|
// Check for imeta metadata for this URL |
|
|
|
|
const imeta = getImetaForUrl(url); |
|
|
|
|
|
|
|
|
|
let type: 'image' | 'video' | 'audio' = 'image'; |
|
|
|
|
if (imeta?.mimeType) { |
|
|
|
|
if (imeta.mimeType.startsWith('video/')) type = 'video'; |
|
|
|
|
else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
media.push({ |
|
|
|
|
url, |
|
|
|
|
type, |
|
|
|
|
source: 'content', |
|
|
|
|
...(imeta || {}) |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 3. Extract plain image URLs from content (not just markdown) |
|
|
|
|
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi; |
|
|
|
|
urlRegex.lastIndex = 0; // Reset regex |
|
|
|
|
while ((match = urlRegex.exec(event.content)) !== null) { |
|
|
|
|
const url = match[1]; |
|
|
|
|
const normalized = normalizeUrl(url); |
|
|
|
|
// Skip if already added (from markdown or image tag) |
|
|
|
|
if (seen.has(normalized)) { |
|
|
|
|
continue; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check for imeta metadata for this URL |
|
|
|
|
const imeta = getImetaForUrl(url); |
|
|
|
|
|
|
|
|
|
let type: 'image' | 'video' | 'audio' = 'image'; |
|
|
|
|
const ext = match[2].toLowerCase(); |
|
|
|
|
if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) { |
|
|
|
|
type = 'video'; |
|
|
|
|
} else if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) { |
|
|
|
|
type = 'audio'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Override type from imeta if available |
|
|
|
|
if (imeta?.mimeType) { |
|
|
|
|
if (imeta.mimeType.startsWith('video/')) type = 'video'; |
|
|
|
|
else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; |
|
|
|
|
else if (imeta.mimeType.startsWith('image/')) type = 'image'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
media.push({ |
|
|
|
|
url, |
|
|
|
|
type, |
|
|
|
|
source: 'content', |
|
|
|
|
...(imeta || {}) |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 4. imeta tags (NIP-92) - only display if NOT already in content/image tag |
|
|
|
|
// (imeta is metadata, so if URL is already extracted above, skip adding it again) |
|
|
|
|
for (const tag of event.tags) { |
|
|
|
|
if (tag[0] === 'imeta') { |
|
|
|
|
const imeta = parseImetaTag(tag); |
|
|
|
|
if (imeta.url) { |
|
|
|
|
const normalized = normalizeUrl(imeta.url); |
|
|
|
|
// Skip if already added from content/image tag |
|
|
|
|
if (seen.has(normalized)) { |
|
|
|
|
continue; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Skip if already displayed in content (imeta is just metadata reference) |
|
|
|
|
// UNLESS forceRender is true (for media kinds where media is the primary content) |
|
|
|
|
if (!forceRender && isUrlInContent(url)) { |
|
|
|
|
if (!forceRender && isUrlInContent(imeta.url)) { |
|
|
|
|
continue; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (!seen.has(normalized)) { |
|
|
|
|
let type: 'image' | 'video' | 'audio' = 'image'; |
|
|
|
|
if (mimeType) { |
|
|
|
|
if (mimeType.startsWith('video/')) type = 'video'; |
|
|
|
|
else if (mimeType.startsWith('audio/')) type = 'audio'; |
|
|
|
|
if (imeta.mimeType) { |
|
|
|
|
if (imeta.mimeType.startsWith('video/')) type = 'video'; |
|
|
|
|
else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
media.push({ |
|
|
|
|
url, |
|
|
|
|
thumbnailUrl, |
|
|
|
|
blurhash, |
|
|
|
|
url: imeta.url, |
|
|
|
|
thumbnailUrl: imeta.thumbnailUrl, |
|
|
|
|
blurhash: imeta.blurhash, |
|
|
|
|
type, |
|
|
|
|
mimeType, |
|
|
|
|
width, |
|
|
|
|
height, |
|
|
|
|
alt, |
|
|
|
|
mimeType: imeta.mimeType, |
|
|
|
|
width: imeta.width, |
|
|
|
|
height: imeta.height, |
|
|
|
|
alt: imeta.alt, |
|
|
|
|
source: 'imeta' |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Also check for standalone alt tag (fallback if not in imeta) |
|
|
|
|
const altTag = event.tags.find(t => t[0] === 'alt' && t[1]); |
|
|
|
|
@ -268,25 +419,6 @@
@@ -268,25 +419,6 @@
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 5. Extract from markdown content (images in markdown syntax) |
|
|
|
|
const imageRegex = /!\[.*?\]\((.*?)\)/g; |
|
|
|
|
let match; |
|
|
|
|
while ((match = imageRegex.exec(event.content)) !== null) { |
|
|
|
|
const url = match[1]; |
|
|
|
|
const normalized = normalizeUrl(url); |
|
|
|
|
if (!seen.has(normalized)) { |
|
|
|
|
media.push({ |
|
|
|
|
url, |
|
|
|
|
type: 'image', |
|
|
|
|
source: 'content' |
|
|
|
|
}); |
|
|
|
|
seen.add(normalized); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 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 |
|
|
|
|
|
|
|
|
|
// Final deduplication pass: ensure no duplicates by normalized URL |
|
|
|
|
const deduplicated: MediaItem[] = []; |
|
|
|
|
@ -592,7 +724,7 @@
@@ -592,7 +724,7 @@
|
|
|
|
|
|
|
|
|
|
.media-gallery { |
|
|
|
|
display: grid; |
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); |
|
|
|
|
gap: 1rem; |
|
|
|
|
margin-top: 1rem; |
|
|
|
|
} |
|
|
|
|
|