Untitled
unknown
plain_text
18 days ago
31 kB
5
Indexable
// /components/gallery.tsx "use client" import { useState, useEffect, useRef, createRef } from "react" import { Download, MessageCircle, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, X, Heart, Check } from "lucide-react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { PhotoComments } from "./photo-comments" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Slider } from "@/components/ui/slider" import { toast } from "sonner" import { ScrollToolbar } from "./ScrollToolbar" import { useInView } from "framer-motion" interface Section { title: string; description?: string; images: { src: string; alt: string; width: number; height: number; thumbnailSrc?: string; }[]; } interface GalleryProps { sections: Section[]; clientConfig: any; isSelectionView?: boolean; } interface CommentCounts { [key: string]: number; } interface ImageDialogProps { sections: Section[]; currentSectionIndex: number; currentImageIndex: number; commentCounts: CommentCounts; selectedTab: string; setSelectedTab: (tab: string) => void; isFullImageLoaded: boolean; setIsFullImageLoaded: (loaded: boolean) => void; handleDownload: (src: string) => Promise<void>; isDownloading: boolean; onCommentAdded: (photoId: string) => void; onNavigate: (direction: 'prev' | 'next') => void; onClose: () => void; favorites: {[key: string]: boolean}; toggleFavorite: (photoId: string, e: React.MouseEvent) => void; } function ImageDialog({ sections, currentSectionIndex, currentImageIndex, commentCounts, selectedTab, setSelectedTab, isFullImageLoaded, setIsFullImageLoaded, handleDownload, isDownloading, onCommentAdded, onNavigate, onClose, favorites, toggleFavorite }: ImageDialogProps) { const section = sections[currentSectionIndex]; const image = section.images[currentImageIndex]; const fullSizeImageSrc = image.src; const thumbnailSrc = image.thumbnailSrc; const photoId = `${section.title}-${currentImageIndex}`; const commentCount = commentCounts[photoId] || 0; const isFavorite = favorites[photoId] || false; const hasPrevious = currentImageIndex > 0; const hasNext = currentImageIndex < section.images.length - 1; const handleImageClick = () => { if (isFullImageLoaded) { window.open(fullSizeImageSrc, '_blank'); } }; return ( <div className="flex flex-col h-full"> <button onClick={onClose} className="absolute right-2 top-2 z-50 p-1 rounded-full bg-background/80 backdrop-blur-sm border border-border hover:bg-muted transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary" aria-label="Close dialog" > <X className="h-3.5 w-3.5" /> </button> <Tabs value={selectedTab} onValueChange={setSelectedTab} className="flex flex-col h-full"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> <TabsList className="h-8 sm:h-9 p-1 w-auto"> <TabsTrigger value="preview" className="text-xs sm:text-sm px-2 py-1 sm:px-3 h-6 sm:h-7 data-[state=active]:shadow-sm rounded-md mx-0.5" > Vorschau </TabsTrigger> <TabsTrigger value="comments" className="text-xs sm:text-sm px-2 py-1 sm:px-3 h-6 sm:h-7 data-[state=active]:shadow-sm rounded-md mx-0.5" > Kommentare {commentCount > 0 && ( <span className="ml-1 bg-primary/10 text-primary rounded-full px-1 py-0.5 text-xs"> {commentCount} </span> )} </TabsTrigger> </TabsList> <div className="flex items-center gap-2"> <Button variant="ghost" size="sm" className={`h-8 sm:h-9 aspect-square p-0 ${isFavorite ? 'text-pink-500' : 'text-muted-foreground hover:text-white'}`} onClick={(e) => toggleFavorite(photoId, e)} > <Heart className="h-5 w-5 transition-transform duration-200 ease-out" fill={isFavorite ? 'currentColor' : 'none'} style={{ transform: isFavorite ? 'scale(1.1)' : 'scale(1)' }} /> </Button> <Button onClick={() => handleDownload(fullSizeImageSrc)} disabled={isDownloading} size="sm" className="h-8 sm:h-9 text-sm" > <Download className="mr-2 h-4 w-4" /> Bild herunterladen </Button> </div> </div> <div className="flex-1 min-h-0 relative rounded-lg bg-background"> <TabsContent value="preview" className="h-full" > <div className="h-full flex items-center justify-center p-4"> {hasPrevious && ( <button onClick={(e) => { e.stopPropagation(); onNavigate('prev'); }} className="absolute left-4 top-1/2 -translate-y-1/2 z-20 flex items-center justify-center w-10 h-10 bg-black/40 hover:bg-black/60 text-white rounded-full transition-all duration-150" aria-label="Previous image" > <ChevronLeft className="h-6 w-6" /> </button> )} {hasNext && ( <button onClick={(e) => { e.stopPropagation(); onNavigate('next'); }} className="absolute right-4 top-1/2 -translate-y-1/2 z-20 flex items-center justify-center w-10 h-10 bg-black/40 hover:bg-black/60 text-white rounded-full transition-all duration-150" aria-label="Next image" > <ChevronRight className="h-6 w-6" /> </button> )} <img key={`thumb-${photoId}`} src={thumbnailSrc} alt={image.alt} className="max-w-full max-h-full w-auto h-auto object-contain transition-opacity duration-300" style={{ opacity: isFullImageLoaded ? 0 : 1 }} /> <img key={`full-${photoId}`} src={fullSizeImageSrc} alt={image.alt} className="max-w-full max-h-full w-auto h-auto object-contain transition-opacity duration-300 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" style={{ opacity: isFullImageLoaded ? 1 : 0, cursor: isFullImageLoaded ? "zoom-in" : "default" }} onLoad={() => setIsFullImageLoaded(true)} onClick={handleImageClick} title={isFullImageLoaded ? "Klicken, um in voller Größe zu öffnen" : ""} /> </div> </TabsContent> <TabsContent value="comments" className="absolute inset-0 overflow-y-auto" > <div className="p-4 w-full"> <style jsx global>{` .rounded-lg.border.bg-card { max-width: 100%; overflow: hidden; } .rounded-lg.border.bg-card > div { width: 100%; } .rounded-lg.border.bg-card > div > div { flex-wrap: wrap; gap: 0.5rem; } .rounded-lg.border.bg-card > div > div > span { order: 2; width: 100%; text-align: left; padding-left: 3.25rem; } `}</style> <PhotoComments key={`comments-${photoId}`} photoId={photoId} onCommentAdded={() => onCommentAdded(photoId)} /> <div className="flex justify-between items-center mt-4"> {hasPrevious ? ( <Button variant="outline" size="sm" onClick={() => onNavigate('prev')} className="flex items-center gap-1" > <ChevronLeft className="h-4 w-4" /> Vorheriges Bild </Button> ) : <div />} {hasNext && ( <Button variant="outline" size="sm" onClick={() => onNavigate('next')} className="flex items-center gap-1 ml-auto" > Nächstes Bild <ChevronRight className="h-4 w-4" /> </Button> )} </div> </div> </TabsContent> </div> <div className="flex items-center justify-center gap-2 mt-4"> <span className="text-sm text-muted-foreground"> {currentImageIndex + 1} / {section.images.length} </span> </div> </Tabs> </div> ); } export function Gallery({ sections, clientConfig, isSelectionView = false }: GalleryProps) { const [currentSectionIndex, setCurrentSectionIndex] = useState<number>(0); const [currentImageIndex, setCurrentImageIndex] = useState<number>(0); const [isDownloading, setIsDownloading] = useState(false); const [isFullImageLoaded, setIsFullImageLoaded] = useState(false); const [selectedTab, setSelectedTab] = useState<string>("preview"); const [isDialogOpen, setIsDialogOpen] = useState(false); const [commentCounts, setCommentCounts] = useState<{[key: string]: number}>({}); const defaultGridSize = clientConfig.gallery?.defaultGridSize || 3; const [gridSize, setGridSize] = useState<number>(defaultGridSize); const roundedImages = clientConfig.gallery?.roundedImages !== undefined ? clientConfig.gallery.roundedImages : true; const [favorites, setFavorites] = useState<{[key: string]: boolean}>({}); const [showFavoritesOnly, setShowFavoritesOnly] = useState<boolean>(false); const [showCommentsOnly, setShowCommentsOnly] = useState<boolean>(false); const imageRefs = useRef<{[key: string]: React.RefObject<HTMLDivElement>}>({}); const footerRef = useRef(null); const inView = useInView(footerRef, { amount: 0.1 }); const [activeDialogId, setActiveDialogId] = useState<string | null>(null); useEffect(() => { sections.forEach((section) => { section.images.forEach((_, imageIndex) => { const photoId = `${section.title}-${imageIndex}`; if (!imageRefs.current[photoId]) { imageRefs.current[photoId] = createRef(); } }); }); }, [sections]); const scrollToCurrentImage = () => { const photoId = `${sections[currentSectionIndex].title}-${currentImageIndex}`; const ref = imageRefs.current[photoId]; if (ref && ref.current) { setTimeout(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); } }; useEffect(() => { const storedFavorites = localStorage.getItem('favorites'); if (storedFavorites) { try { setFavorites(JSON.parse(storedFavorites)); } catch (e) { console.error('Failed to parse favorites from localStorage:', e); } } }, []); useEffect(() => { if (isSelectionView && sections.length > 0) { const selectionFavorites = { ...favorites }; sections.forEach((section) => { section.images.forEach((_, imageIndex) => { const photoId = `${section.title}-${imageIndex}`; if (selectionFavorites[photoId] === undefined) { selectionFavorites[photoId] = true; } }); }); setFavorites(selectionFavorites); } }, [isSelectionView, sections]); const toggleFavorite = (photoId: string, e: React.MouseEvent) => { e.stopPropagation(); const newFavorites = { ...favorites }; newFavorites[photoId] = !newFavorites[photoId]; setFavorites(newFavorites); localStorage.setItem('favorites', JSON.stringify(newFavorites)); }; const handleResetAllFavorites = () => { setFavorites({}); localStorage.removeItem('favorites'); toast.success('Alle Favoriten wurden zurückgesetzt'); if (showFavoritesOnly) { setShowFavoritesOnly(false); } }; const handleCreateSharedLink = async (password: string, requirePassword: boolean) => { const clientId = window.location.pathname.split('/')[1]; const selectedFavorites: any = {}; sections.forEach((section) => { section.images.forEach((image, imageIndex) => { const photoId = `${section.title}-${imageIndex}`; if (favorites[photoId]) { if (!selectedFavorites[section.title]) { selectedFavorites[section.title] = []; } selectedFavorites[section.title].push(imageIndex); } }); }); const selectionData = { favorites: selectedFavorites, requirePassword, password: requirePassword ? password : "", createdAt: new Date().toISOString(), parentClientId: clientId }; try { let nextSelectionId = 1; try { const response = await fetch(`/api/selections?clientId=${encodeURIComponent(clientId)}`); if (response.ok) { const selections = await response.json(); if (selections && selections.length > 0) { const numericIds = selections .map(s => parseInt(s.selection_id)) .filter(id => !isNaN(id)); if (numericIds.length > 0) { nextSelectionId = Math.max(...numericIds) + 1; } } } } catch (error) { console.error("Error fetching existing selections:", error); } const response = await fetch(`/api/selections`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ clientId, selectionId: nextSelectionId.toString(), data: selectionData }), }); if (!response.ok) throw new Error("Failed to create selection"); const origin = window.location.origin; const shareableUrl = `${origin}/${clientId}/auswahl/${nextSelectionId}`; const fullUrl = requirePassword ? `${shareableUrl}#password=${password}` : shareableUrl; return fullUrl; } catch (error) { console.error("Error creating shared link:", error); throw error; } }; useEffect(() => { if (!isDialogOpen) { setSelectedTab("preview"); setIsFullImageLoaded(false); setActiveDialogId(null); setTimeout(scrollToCurrentImage, 200); } }, [isDialogOpen]); useEffect(() => { const fetchCommentCounts = async () => { const clientId = window.location.pathname.split('/')[1]; try { const response = await fetch(`/api/comments?clientId=${encodeURIComponent(clientId)}&index=true`); if (response.ok) { const counts = await response.json(); setCommentCounts(counts); } } catch (error) { console.error('Error fetching comment counts:', error); } }; fetchCommentCounts(); }, []); useEffect(() => { setIsFullImageLoaded(false); }, [currentImageIndex]); const handleDownloadAllFavorites = async () => { const favoritePhotos = []; sections.forEach((section) => { section.images.forEach((image, imageIndex) => { const photoId = `${section.title}-${imageIndex}`; if (favorites[photoId]) { favoritePhotos.push({ src: image.src, filename: image.src.split('/').pop() || `favorite-${photoId}.jpg` }); } }); }); if (favoritePhotos.length === 0) { toast.error("Keine Favoriten zum Herunterladen gefunden"); return; } toast.info(`Download von ${favoritePhotos.length} Favoriten wird gestartet...`); let downloadedCount = 0; let failedCount = 0; for (const photo of favoritePhotos) { try { await new Promise(resolve => setTimeout(resolve, 300)); const response = await fetch(photo.src, { method: 'GET', headers: { 'Cache-Control': 'no-cache' } }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const blob = await response.blob(); if (blob.size === 0) throw new Error('Downloaded file is empty'); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = photo.filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); downloadedCount++; if (downloadedCount % 3 === 0 || downloadedCount === favoritePhotos.length) { toast.info(`${downloadedCount} von ${favoritePhotos.length} Favoriten heruntergeladen...`); } } catch (error) { console.error(`Failed to download ${photo.filename}:`, error); failedCount++; } } if (failedCount > 0) { toast.warning(`${downloadedCount} von ${favoritePhotos.length} Favoriten heruntergeladen, ${failedCount} fehlgeschlagen.`); } else { toast.success(`Alle ${favoritePhotos.length} Favoriten erfolgreich heruntergeladen!`); } }; const handleDownload = async (imageSrc: string) => { try { setIsDownloading(true); const fileName = imageSrc.split('/').pop() || 'image'; const response = await fetch(imageSrc, { method: 'GET', headers: { 'Cache-Control': 'no-cache' } }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const contentType = response.headers.get('content-type'); if (!contentType?.includes('image')) throw new Error(`Invalid content type: ${contentType}`); const blob = await response.blob(); if (blob.size === 0) throw new Error('Downloaded file is empty'); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); toast.success('Download erfolgreich'); } catch (error) { console.error('Download error:', error); toast.error(`Download fehlgeschlagen: ${error.message}`); } finally { setIsDownloading(false); } }; const handleNavigate = (direction: 'prev' | 'next') => { if (direction === 'prev' && currentImageIndex > 0) { setCurrentImageIndex(currentImageIndex - 1); setTimeout(scrollToCurrentImage, 100); } else if (direction === 'next' && currentImageIndex < sections[currentSectionIndex].images.length - 1) { setCurrentImageIndex(currentImageIndex + 1); setTimeout(scrollToCurrentImage, 100); } }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isDialogOpen) return; if (e.key === 'ArrowLeft') handleNavigate('prev'); else if (e.key === 'ArrowRight') handleNavigate('next'); else if (e.key === 'Escape') setIsDialogOpen(false); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isDialogOpen, currentImageIndex, currentSectionIndex]); const getGridClasses = () => { const sizeMap = { 1: 'grid-cols-1', 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4', 5: 'grid-cols-5', 6: 'grid-cols-6', }; return sizeMap[gridSize] || 'grid-cols-3'; }; const getImageContainerStyle = (image: any) => { const aspectRatio = image.width / image.height; return { aspectRatio: aspectRatio.toString(), gridRowEnd: aspectRatio < 0.8 ? 'span 2' : aspectRatio > 1.3 ? 'span 2' : 'span 1' }; }; return ( <div className="space-y-12"> <Dialog open={isDialogOpen} onOpenChange={(open) => setIsDialogOpen(open)} > <DialogContent className="w-[90%] h-[90vh] max-w-7xl p-4 sm:p-6" closeButtonClassName="sr-only hidden"> <DialogHeader className="mb-4"> <DialogTitle>Bildvorschau</DialogTitle> <DialogDescription> Ansehen und herunterladen des Bildes in voller Auflösung </DialogDescription> </DialogHeader> <div className="flex-1 h-[calc(90vh-8rem)]"> <ImageDialog sections={sections} currentSectionIndex={currentSectionIndex} currentImageIndex={currentImageIndex} commentCounts={commentCounts} selectedTab={selectedTab} setSelectedTab={setSelectedTab} isFullImageLoaded={isFullImageLoaded} setIsFullImageLoaded={setIsFullImageLoaded} handleDownload={handleDownload} isDownloading={isDownloading} onNavigate={handleNavigate} onClose={() => setIsDialogOpen(false)} favorites={favorites} toggleFavorite={toggleFavorite} onCommentAdded={(photoId) => { setCommentCounts(prev => ({ ...prev, [photoId]: (prev[photoId] || 0) + 1 })); }} /> </div> </DialogContent> </Dialog> {sections.map((section, sectionIndex) => { const sectionFavorites = section.images.reduce((count, _, imageIndex) => { const photoId = `${section.title}-${imageIndex}`; return favorites[photoId] ? count + 1 : count; }, 0); const sectionComments = section.images.reduce((count, _, imageIndex) => { const photoId = `${section.title}-${imageIndex}`; return commentCounts[photoId] ? count + 1 : count; }, 0); if ((showFavoritesOnly && sectionFavorites === 0) || (showCommentsOnly && sectionComments === 0)) { return null; } return ( <div key={sectionIndex} id={`section-${section.title}`} className="space-y-4"> <div className="space-y-2"> <h2 className="text-2xl font-bold flex items-center gap-2"> {section.title} {showFavoritesOnly && sectionFavorites > 0 && ( <span className="text-sm text-pink-500 font-normal"> ({sectionFavorites} {sectionFavorites === 1 ? 'Favorit' : 'Favoriten'}) </span> )} {showCommentsOnly && sectionComments > 0 && ( <span className="text-sm text-blue-500 font-normal"> ({sectionComments} {sectionComments === 1 ? 'Kommentar' : 'Kommentare'}) </span> )} </h2> {section.description && ( <p className="text-muted-foreground">{section.description}</p> )} </div> <div className={`grid ${getGridClasses()} gap-4 w-full`}> {section.images.map((image, imageIndex) => { const photoId = `${section.title}-${imageIndex}`; const commentCount = commentCounts[photoId] || 0; const isFavorite = favorites[photoId] || false; if ((showFavoritesOnly && !isFavorite) || (showCommentsOnly && commentCount === 0)) { return null; } return ( <div key={imageIndex} ref={imageRefs.current[photoId]} className={` relative overflow-hidden ${roundedImages ? 'rounded-lg' : ''} bg-muted/30 transition-transform duration-200 hover:scale-[1.02] ${isFavorite ? 'ring-2 ring-pink-500/50 ring-offset-2 ring-offset-pink-500/30 shadow-lg shadow-pink-500/10' : ''} ${commentCount > 0 && showCommentsOnly ? 'ring-2 ring-blue-500/50 ring-offset-2 ring-offset-blue-500/30 shadow-lg shadow-blue-500/10' : ''} `} style={getImageContainerStyle(image)} > <div className="absolute inset-0"> <img src={image.thumbnailSrc} alt={image.alt} className="h-full w-full object-cover" loading="lazy" /> </div> <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 cursor-zoom-in" onClick={() => { setCurrentSectionIndex(sectionIndex); setCurrentImageIndex(imageIndex); setSelectedTab("preview"); setIsFullImageLoaded(false); setIsDialogOpen(true); }} > <Button variant="secondary" size="icon" className="h-9 w-9 z-10" onClick={(e) => { e.stopPropagation(); handleDownload(image.src); }} > <Download className="h-4 w-4" /> </Button> </div> <div className="absolute bottom-2 left-2 z-10" onClick={(e) => toggleFavorite(photoId, e)} > <div className={` rounded-full p-1.5 flex items-center justify-center cursor-pointer ${isFavorite ? 'bg-pink-500/20 backdrop-blur-sm' : 'bg-black/30 hover:bg-black/50 backdrop-blur-sm'} transition-all duration-300 `}> <Heart className={` ${isFavorite ? 'text-pink-500 filter drop-shadow-sm' : 'text-white/90'} h-4 w-4 transition-all duration-300 ${isFavorite ? 'scale-110' : 'scale-100'} `} fill={isFavorite ? 'currentColor' : 'none'} /> </div> </div> {commentCount > 0 && ( <div className="absolute bottom-2 right-2 z-10" onClick={(e) => { e.stopPropagation(); setCurrentSectionIndex(sectionIndex); setCurrentImageIndex(imageIndex); setSelectedTab("comments"); setIsDialogOpen(true); }} > <div className={` rounded-full p-1.5 flex items-center justify-center cursor-pointer bg-black/30 hover:bg-black/50 backdrop-blur-sm transition-all duration-300 `}> <MessageCircle className="h-4 w-4 text-white/90" /> <span className="ml-1 text-white text-xs">{commentCount}</span> </div> </div> )} </div> ); })} </div> </div> ); })} {showFavoritesOnly && !Object.values(favorites).some(v => v) && ( <div className="bg-muted/30 rounded-lg p-8 text-center"> <div className="inline-flex items-center justify-center p-4 rounded-full bg-pink-500/10 mb-4"> <Heart className="h-8 w-8 text-pink-500/50" /> </div> <h3 className="text-xl font-semibold mb-2">Keine Favoriten gefunden</h3> <p className="text-muted-foreground"> Klicken Sie auf das Herz-Symbol bei den Bildern, um sie zu deinen Favoriten hinzuzufügen. </p> </div> )} {showCommentsOnly && !Object.keys(commentCounts).length && ( <div className="bg-muted/30 rounded-lg p-8 text-center"> <div className="inline-flex items-center justify-center p-4 rounded-full bg-blue-500/10 mb-4"> <MessageCircle className="h-8 w-8 text-blue-500/50" /> </div> <h3 className="text-xl font-semibold mb-2">Keine kommentierte Bilder gefunden</h3> <p className="text-muted-foreground"> Schreibe Kommentare zu Bildern, um sie hier anzuzeigen. </p> </div> )} <ScrollToolbar gridSize={gridSize} setGridSize={setGridSize} sections={sections} inView={inView} clientConfig={clientConfig} favorites={favorites} showFavoritesOnly={showFavoritesOnly} setShowFavoritesOnly={setShowFavoritesOnly} commentCounts={commentCounts} showCommentsOnly={showCommentsOnly} setShowCommentsOnly={setShowCommentsOnly} handleDownloadAllFavorites={handleDownloadAllFavorites} handleResetAllFavorites={handleResetAllFavorites} handleCreateSharedLink={handleCreateSharedLink} clientId={window.location.pathname.split('/')[1]} /> <div ref={footerRef} className="h-1" /> </div> ); }
Editor is loading...
Leave a Comment