6 changed files with 466 additions and 55 deletions
@ -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> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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…
Reference in new issue