From e37d8d39cff61137d13e5ae032733c34aafddc2e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 29 Oct 2025 22:23:49 +0100 Subject: [PATCH] more parsing --- .../ImageCarousel/ImageCarousel.tsx | 184 +++++++++++++++++ .../Note/AsciidocArticle/AsciidocArticle.tsx | 42 ++-- .../Note/LongFormArticlePreview.tsx | 63 +++--- .../Note/MarkdownArticle/MarkdownArticle.tsx | 40 +++- src/components/Note/index.tsx | 2 + src/lib/image-extraction.ts | 190 ++++++++++++++++++ 6 files changed, 466 insertions(+), 55 deletions(-) create mode 100644 src/components/ImageCarousel/ImageCarousel.tsx create mode 100644 src/lib/image-extraction.ts diff --git a/src/components/ImageCarousel/ImageCarousel.tsx b/src/components/ImageCarousel/ImageCarousel.tsx new file mode 100644 index 0000000..6fe31ec --- /dev/null +++ b/src/components/ImageCarousel/ImageCarousel.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' +import { ChevronLeft, ChevronRight, X } from 'lucide-react' +import ImageWithLightbox from '@/components/ImageWithLightbox' +import { TImetaInfo } from '@/types' + +interface ImageCarouselProps { + images: TImetaInfo[] + className?: string +} + +export default function ImageCarousel({ images, className = '' }: ImageCarouselProps) { + const [currentIndex, setCurrentIndex] = useState(0) + const [isFullscreen, setIsFullscreen] = useState(false) + + if (!images || images.length === 0) { + return null + } + + const goToPrevious = () => { + setCurrentIndex((prevIndex) => + prevIndex === 0 ? images.length - 1 : prevIndex - 1 + ) + } + + const goToNext = () => { + setCurrentIndex((prevIndex) => + prevIndex === images.length - 1 ? 0 : prevIndex + 1 + ) + } + + const openFullscreen = () => { + setIsFullscreen(true) + } + + const closeFullscreen = () => { + setIsFullscreen(false) + } + + const currentImage = images[currentIndex] + + return ( + <> +
+ {/* Thumbnail grid */} +
+ {images.map((image, index) => ( +
setCurrentIndex(index)} + > + {image.m?.startsWith('video/') ? ( +
+ ))} +
+ + {/* Main image display */} + {images.length > 0 && ( +
+
+ {currentImage.m?.startsWith('video/') ? ( +
+
+ )} +
+ + {/* Fullscreen modal */} + {isFullscreen && ( +
+ + +
+ {currentImage.m?.startsWith('video/') ? ( +
+
+ )} + + ) +} diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index bbbf371..e585f0d 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -1,7 +1,9 @@ import { useSecondaryPage } from '@/PageManager' import ImageWithLightbox from '@/components/ImageWithLightbox' +import ImageCarousel from '@/components/ImageCarousel/ImageCarousel' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' +import { extractAllImagesFromEvent } from '@/lib/image-extraction' import { ChevronDown, ChevronRight } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useMemo, useState, useEffect, useRef } from 'react' @@ -22,6 +24,10 @@ export default function AsciidocArticle({ const { push } = useSecondaryPage() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const [isInfoOpen, setIsInfoOpen] = useState(false) + const [isImagesOpen, setIsImagesOpen] = useState(false) + + // Extract all images from the event + const allImages = useMemo(() => extractAllImagesFromEvent(event), [event]) // Determine if this is an article-type event that should show ToC and Article Info const isArticleType = useMemo(() => { @@ -302,8 +308,23 @@ export default function AsciidocArticle({ dangerouslySetInnerHTML={{ __html: parsedContent?.html || '' }} /> + {/* Image Carousel - Collapsible */} + {allImages.length > 0 && ( + + + + + + + + + )} + {/* Collapsible Article Info - only for article-type events */} - {isArticleType && (parsedContent?.media?.length > 0 || parsedContent?.links?.length > 0 || parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && ( + {isArticleType && (parsedContent?.links?.length > 0 || parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && ( - {/* Media thumbnails */} - {parsedContent?.media?.length > 0 && ( -
-

Images in this article:

-
- {parsedContent?.media?.map((media, index) => ( -
- -
- ))} -
-
- )} {/* Links summary with OpenGraph previews */} {parsedContent?.links?.length > 0 && ( diff --git a/src/components/Note/LongFormArticlePreview.tsx b/src/components/Note/LongFormArticlePreview.tsx index 44e42ba..6bc997b 100644 --- a/src/components/Note/LongFormArticlePreview.tsx +++ b/src/components/Note/LongFormArticlePreview.tsx @@ -1,5 +1,5 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' -import { toNoteList } from '@/lib/link' +import { toNote, toNoteList } from '@/lib/link' import { useSecondaryPage } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -19,6 +19,11 @@ export default function LongFormArticlePreview({ const { autoLoadMedia } = useContentPolicy() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) + const handleCardClick = (e: React.MouseEvent) => { + e.stopPropagation() + push(toNote(event.id)) + } + const titleComponent =
{metadata.title}
const tagsComponent = metadata.tags.length > 0 && ( @@ -45,17 +50,22 @@ export default function LongFormArticlePreview({ if (isSmallScreen) { return (
- {metadata.image && autoLoadMedia && ( - - )} -
- {titleComponent} - {summaryComponent} - {tagsComponent} +
+ {metadata.image && autoLoadMedia && ( + + )} +
+ {titleComponent} + {summaryComponent} + {tagsComponent} +
) @@ -63,18 +73,23 @@ export default function LongFormArticlePreview({ return (
-
- {metadata.image && autoLoadMedia && ( - - )} -
- {titleComponent} - {summaryComponent} - {tagsComponent} +
+
+ {metadata.image && autoLoadMedia && ( + + )} +
+ {titleComponent} + {summaryComponent} + {tagsComponent} +
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 4e51481..eb74384 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -1,10 +1,12 @@ import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' import ImageWithLightbox from '@/components/ImageWithLightbox' +import ImageCarousel from '@/components/ImageCarousel/ImageCarousel' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList, toProfile } from '@/lib/link' -import { ExternalLink } from 'lucide-react' +import { extractAllImagesFromEvent } from '@/lib/image-extraction' +import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import React, { useMemo, useEffect, useRef } from 'react' +import React, { useMemo, useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' @@ -13,6 +15,8 @@ import 'katex/dist/katex.min.css' import NostrNode from './NostrNode' import { remarkNostr } from './remarkNostr' import { Components } from './types' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' export default function MarkdownArticle({ event, @@ -23,6 +27,10 @@ export default function MarkdownArticle({ }) { const { push } = useSecondaryPage() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) + const [isImagesOpen, setIsImagesOpen] = useState(false) + + // Extract all images from the event + const allImages = useMemo(() => extractAllImagesFromEvent(event), [event]) const contentRef = useRef(null) // Initialize highlight.js for syntax highlighting @@ -156,15 +164,10 @@ export default function MarkdownArticle({ return <>{children} }, - img: (props) => ( - - ) + img: () => { + // Don't render inline images - they'll be shown in the carousel + return null + } }) as Components, [] ) @@ -269,6 +272,21 @@ export default function MarkdownArticle({ > {event.content} + + {/* Image Carousel - Collapsible */} + {allImages.length > 0 && ( + + + + + + + + + )} {metadata.tags.length > 0 && (
{metadata.tags.map((tag) => ( diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 04896d5..f4587d1 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -106,6 +106,8 @@ export default function Note({ ) } else if (event.kind === ExtendedKind.PUBLICATION) { + content = + } else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) { content = showFull ? ( ) : ( diff --git a/src/lib/image-extraction.ts b/src/lib/image-extraction.ts new file mode 100644 index 0000000..6ee5de2 --- /dev/null +++ b/src/lib/image-extraction.ts @@ -0,0 +1,190 @@ +import { Event } from 'nostr-tools' +import { TImetaInfo } from '@/types' +import { getImetaInfosFromEvent } from '@/lib/event' + +/** + * Extract and normalize all images from an event + * This includes images from: + * - imeta tags + * - content (markdown images, HTML img tags, etc.) + * - metadata (title image, etc.) + */ +export function extractAllImagesFromEvent(event: Event): TImetaInfo[] { + const images: TImetaInfo[] = [] + const seenUrls = new Set() + + // Helper function to add media if not already seen + const addMedia = (url: string, pubkey: string = event.pubkey) => { + if (!url || seenUrls.has(url)) return + + // Normalize URL + const normalizedUrl = normalizeImageUrl(url) + if (!normalizedUrl) return + + // Check if it's media (image or video) + const isVideo = isVideoUrl(normalizedUrl) + const isImage = isImageUrl(normalizedUrl) + + if (!isImage && !isVideo) return + + images.push({ + url: normalizedUrl, + pubkey, + m: isVideo ? 'video/*' : 'image/*' + }) + seenUrls.add(normalizedUrl) + } + + // 1. Extract from imeta tags + const imetaMedia = getImetaInfosFromEvent(event) + imetaMedia.forEach((item: TImetaInfo) => { + if (item.m?.startsWith('image/') || item.m?.startsWith('video/')) { + addMedia(item.url, item.pubkey) + } + }) + + // 2. Extract from content - markdown images + const markdownImageRegex = /!\[.*?\]\((.*?)\)/g + let match + while ((match = markdownImageRegex.exec(event.content)) !== null) { + addMedia(match[1]) + } + + // 3. Extract from content - HTML img tags + const htmlImgRegex = /]+src=["']([^"']+)["'][^>]*>/gi + while ((match = htmlImgRegex.exec(event.content)) !== null) { + addMedia(match[1]) + } + + // 4. Extract from content - HTML video tags + const htmlVideoRegex = /]+src=["']([^"']+)["'][^>]*>/gi + while ((match = htmlVideoRegex.exec(event.content)) !== null) { + addMedia(match[1]) + } + + // 5. Extract from content - AsciiDoc images + const asciidocImageRegex = /image::([^\s\[]+)(?:\[.*?\])?/g + while ((match = asciidocImageRegex.exec(event.content)) !== null) { + addMedia(match[1]) + } + + // 6. Extract from metadata + const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) + + if (imageTag?.[1]) { + addMedia(imageTag[1]) + } + + // 7. Extract from content - general URL patterns that look like media + const mediaUrlRegex = /https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv)(?:\?[^\s<>"']*)?/gi + while ((match = mediaUrlRegex.exec(event.content)) !== null) { + addMedia(match[0]) + } + + return images +} + +/** + * Normalize image URL + */ +function normalizeImageUrl(url: string): string | null { + if (!url) return null + + // Remove common tracking parameters + const cleanUrl = url + .replace(/[?&](utm_[^&]*)/g, '') + .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') + .replace(/[?&]w=\d+/g, '') + .replace(/[?&]h=\d+/g, '') + .replace(/[?&]q=\d+/g, '') + .replace(/[?&]f=\w+/g, '') + .replace(/[?&]auto=\w+/g, '') + .replace(/[?&]format=\w+/g, '') + .replace(/[?&]fit=\w+/g, '') + .replace(/[?&]crop=\w+/g, '') + .replace(/[?&]&+/g, '&') + .replace(/[?&]$/, '') + .replace(/\?$/, '') + + // Ensure it's a valid URL + try { + new URL(cleanUrl) + return cleanUrl + } catch { + return null + } +} + +/** + * Check if URL is likely an image + */ +function isImageUrl(url: string): boolean { + const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico)(\?.*)?$/i + const imageDomains = [ + 'i.nostr.build', + 'image.nostr.build', + 'nostr.build', + 'imgur.com', + 'imgur.io', + 'i.imgur.com', + 'cdn.discordapp.com', + 'media.discordapp.net', + 'pbs.twimg.com', + 'abs.twimg.com', + 'images.unsplash.com', + 'source.unsplash.com', + 'picsum.photos', + 'via.placeholder.com', + 'placehold.co', + 'placehold.it' + ] + + // Check file extension + if (imageExtensions.test(url)) { + return true + } + + // Check known image domains + try { + const urlObj = new URL(url) + return imageDomains.some(domain => + urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain) + ) + } catch { + return false + } +} + +/** + * Check if URL is likely a video + */ +function isVideoUrl(url: string): boolean { + const videoExtensions = /\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v|3gp|ogv)(\?.*)?$/i + const videoDomains = [ + 'youtube.com', + 'youtu.be', + 'vimeo.com', + 'dailymotion.com', + 'twitch.tv', + 'streamable.com', + 'gfycat.com', + 'redgifs.com', + 'cdn.discordapp.com', + 'media.discordapp.net' + ] + + // Check file extension + if (videoExtensions.test(url)) { + return true + } + + // Check known video domains + try { + const urlObj = new URL(url) + return videoDomains.some(domain => + urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain) + ) + } catch { + return false + } +}