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