Browse Source

more parsing

imwald
Silberengel 5 months ago
parent
commit
e37d8d39cf
  1. 184
      src/components/ImageCarousel/ImageCarousel.tsx
  2. 42
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  3. 19
      src/components/Note/LongFormArticlePreview.tsx
  4. 40
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 2
      src/components/Note/index.tsx
  6. 190
      src/lib/image-extraction.ts

184
src/components/ImageCarousel/ImageCarousel.tsx

@ -0,0 +1,184 @@ @@ -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 (
<>
<div className={`relative ${className}`}>
{/* Thumbnail grid */}
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
{images.map((image, index) => (
<div
key={index}
className={`aspect-square rounded-lg overflow-hidden cursor-pointer transition-all duration-200 ${
index === currentIndex
? 'ring-2 ring-blue-500 ring-offset-2'
: 'hover:opacity-80'
}`}
onClick={() => setCurrentIndex(index)}
>
{image.m?.startsWith('video/') ? (
<video
src={image.url}
className="w-full h-full object-cover"
controls
preload="metadata"
/>
) : (
<ImageWithLightbox
image={image}
className="w-full h-full object-cover"
classNames={{
wrapper: 'w-full h-full'
}}
/>
)}
</div>
))}
</div>
{/* Main image display */}
{images.length > 0 && (
<div className="mt-4 relative">
<div className="relative rounded-lg overflow-hidden bg-muted">
{currentImage.m?.startsWith('video/') ? (
<video
src={currentImage.url}
className="w-full max-w-[800px] h-auto object-contain mx-auto"
controls
preload="metadata"
onClick={openFullscreen}
/>
) : (
<div onClick={openFullscreen} className="cursor-pointer">
<ImageWithLightbox
image={currentImage}
className="w-full max-w-[800px] h-auto object-contain mx-auto"
/>
</div>
)}
{/* Navigation arrows */}
{images.length > 1 && (
<>
<button
onClick={goToPrevious}
className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={goToNext}
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
aria-label="Next image"
>
<ChevronRight className="w-5 h-5" />
</button>
</>
)}
{/* Image counter */}
{images.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-sm px-2 py-1 rounded">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
</div>
)}
</div>
{/* Fullscreen modal */}
{isFullscreen && (
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4">
<button
onClick={closeFullscreen}
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10"
aria-label="Close fullscreen"
>
<X className="w-8 h-8" />
</button>
<div className="relative max-w-full max-h-full">
{currentImage.m?.startsWith('video/') ? (
<video
src={currentImage.url}
className="max-w-full max-h-full object-contain"
controls
autoPlay
preload="metadata"
/>
) : (
<ImageWithLightbox
image={currentImage}
className="max-w-full max-h-full object-contain"
/>
)}
{/* Fullscreen navigation */}
{images.length > 1 && (
<>
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-3 transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-3 transition-colors"
aria-label="Next image"
>
<ChevronRight className="w-6 h-6" />
</button>
{/* Fullscreen counter */}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black/50 text-white text-lg px-4 py-2 rounded">
{currentIndex + 1} / {images.length}
</div>
</>
)}
</div>
</div>
)}
</>
)
}

42
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -1,7 +1,9 @@ @@ -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({ @@ -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({ @@ -302,8 +308,23 @@ export default function AsciidocArticle({
dangerouslySetInnerHTML={{ __html: parsedContent?.html || '' }}
/>
{/* Image Carousel - Collapsible */}
{allImages.length > 0 && (
<Collapsible open={isImagesOpen} onOpenChange={setIsImagesOpen} className="mt-8">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span>Images in this article ({allImages.length})</span>
{isImagesOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
<ImageCarousel images={allImages} />
</CollapsibleContent>
</Collapsible>
)}
{/* 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) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
@ -312,25 +333,6 @@ export default function AsciidocArticle({ @@ -312,25 +333,6 @@ export default function AsciidocArticle({
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-2">
{/* Media thumbnails */}
{parsedContent?.media?.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Images in this article:</h4>
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1">
{parsedContent?.media?.map((media, index) => (
<div key={index} className="aspect-square">
<ImageWithLightbox
image={media}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
classNames={{
wrapper: 'w-full h-full'
}}
/>
</div>
))}
</div>
</div>
)}
{/* Links summary with OpenGraph previews */}
{parsedContent?.links?.length > 0 && (

19
src/components/Note/LongFormArticlePreview.tsx

@ -1,5 +1,5 @@ @@ -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({ @@ -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 = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div>
const tagsComponent = metadata.tags.length > 0 && (
@ -45,10 +50,14 @@ export default function LongFormArticlePreview({ @@ -45,10 +50,14 @@ export default function LongFormArticlePreview({
if (isSmallScreen) {
return (
<div className={className}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
onClick={handleCardClick}
>
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[400px] aspect-video"
className="w-full max-w-[400px] aspect-video mb-3"
hideIfError
/>
)}
@ -58,11 +67,16 @@ export default function LongFormArticlePreview({ @@ -58,11 +67,16 @@ export default function LongFormArticlePreview({
{tagsComponent}
</div>
</div>
</div>
)
}
return (
<div className={className}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
onClick={handleCardClick}
>
<div className="flex gap-4">
{metadata.image && autoLoadMedia && (
<Image
@ -78,5 +92,6 @@ export default function LongFormArticlePreview({ @@ -78,5 +92,6 @@ export default function LongFormArticlePreview({
</div>
</div>
</div>
</div>
)
}

40
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1,10 +1,12 @@ @@ -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' @@ -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({ @@ -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<HTMLDivElement>(null)
// Initialize highlight.js for syntax highlighting
@ -156,15 +164,10 @@ export default function MarkdownArticle({ @@ -156,15 +164,10 @@ export default function MarkdownArticle({
return <>{children}</>
},
img: (props) => (
<ImageWithLightbox
image={{ url: props.src || '', pubkey: event.pubkey }}
className="max-w-[400px] object-contain my-0"
classNames={{
wrapper: 'w-fit max-w-[400px]'
}}
/>
)
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({ @@ -269,6 +272,21 @@ export default function MarkdownArticle({
>
{event.content}
</Markdown>
{/* Image Carousel - Collapsible */}
{allImages.length > 0 && (
<Collapsible open={isImagesOpen} onOpenChange={setIsImagesOpen} className="mt-8">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span>Images in this article ({allImages.length})</span>
{isImagesOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
<ImageCarousel images={allImages} />
</CollapsibleContent>
</Collapsible>
)}
{metadata.tags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{metadata.tags.map((tag) => (

2
src/components/Note/index.tsx

@ -106,6 +106,8 @@ export default function Note({ @@ -106,6 +106,8 @@ export default function Note({
<WikiCard className="mt-2" event={event} />
)
} else if (event.kind === ExtendedKind.PUBLICATION) {
content = <PublicationCard className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) {
content = showFull ? (
<AsciidocArticle className="mt-2" event={event} />
) : (

190
src/lib/image-extraction.ts

@ -0,0 +1,190 @@ @@ -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<string>()
// 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 = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
while ((match = htmlImgRegex.exec(event.content)) !== null) {
addMedia(match[1])
}
// 4. Extract from content - HTML video tags
const htmlVideoRegex = /<video[^>]+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
}
}
Loading…
Cancel
Save