diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 31e6105..a46eb38 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -544,6 +544,13 @@ export const GalleryView: React.FC = ({ slug, event }) => { slug={slug} categoryId={selectedCategoryId} feedbackEnabled={feedbackEnabled} + feedbackOptions={{ + allowLikes: !!feedbackSettings?.allow_likes, + allowFavorites: !!feedbackSettings?.allow_favorites, + allowRatings: !!feedbackSettings?.allow_ratings, + allowComments: !!feedbackSettings?.allow_comments, + requireNameEmail: !!feedbackSettings?.require_name_email, + }} isSelectionMode={isSelectionMode} selectedPhotos={selectedPhotos} onSelectionChange={setSelectedPhotos} @@ -575,4 +582,4 @@ export const GalleryView: React.FC = ({ slug, event }) => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index cbf9558..6254997 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -38,6 +38,13 @@ interface PhotoGridWithLayoutsProps { allowDownloads?: boolean; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; useEnhancedProtection?: boolean; + feedbackOptions?: { + allowLikes?: boolean; + allowFavorites?: boolean; + allowRatings?: boolean; + allowComments?: boolean; + requireNameEmail?: boolean; + }; } export const PhotoGridWithLayouts: React.FC = ({ @@ -47,6 +54,7 @@ export const PhotoGridWithLayouts: React.FC = ({ isSelectionMode: parentSelectionMode, selectedPhotos: parentSelectedPhotos, feedbackEnabled, + feedbackOptions, allowDownloads = true, protectionLevel = 'standard', useEnhancedProtection = false, @@ -186,6 +194,7 @@ export const PhotoGridWithLayouts: React.FC = ({ eventDate, expiresAt, feedbackEnabled, + feedbackOptions, }; let LayoutComponent; diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 1eb4490..95380d1 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -15,6 +15,7 @@ interface PhotoLightboxProps { allowDownloads?: boolean; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; useEnhancedProtection?: boolean; + initialShowFeedback?: boolean; } export const PhotoLightbox: React.FC = ({ @@ -26,6 +27,7 @@ export const PhotoLightbox: React.FC = ({ allowDownloads = true, protectionLevel = 'standard', useEnhancedProtection = false, + initialShowFeedback = false, }) => { const [currentIndex, setCurrentIndex] = useState(initialIndex); const [zoom, setZoom] = useState(1); @@ -33,7 +35,14 @@ export const PhotoLightbox: React.FC = ({ const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [touchDistance, setTouchDistance] = useState(null); - const [showFeedback, setShowFeedback] = useState(false); + const [showFeedback, setShowFeedback] = useState(initialShowFeedback); + const [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 640 : false); + + useEffect(() => { + const onResize = () => setIsSmallScreen(window.innerWidth < 640); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); const downloadPhotoMutation = useDownloadPhoto(); const currentPhoto = photos[currentIndex]; @@ -231,13 +240,16 @@ export const PhotoLightbox: React.FC = ({ - + {!showFeedback || !isSmallScreen ? ( + + ) : null} {/* Bottom toolbar */}
@@ -392,4 +404,4 @@ export const PhotoLightbox: React.FC = ({ )}
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx index ce29c16..89baba8 100644 --- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx @@ -17,8 +17,15 @@ export interface BaseGalleryLayoutProps { protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; useEnhancedProtection?: boolean; feedbackEnabled?: boolean; + feedbackOptions?: { + allowLikes?: boolean; + allowFavorites?: boolean; + allowRatings?: boolean; + allowComments?: boolean; + requireNameEmail?: boolean; + }; } export abstract class BaseGalleryLayout extends React.Component { abstract render(): React.ReactNode; -} \ No newline at end of file +} diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx index 7064a3e..a44c6cc 100644 --- a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx @@ -1,16 +1,19 @@ import React, { useState, useEffect, useRef } from 'react'; -import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, Bookmark } from 'lucide-react'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage, Button } from '../../common'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; +import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; +import { feedbackService } from '../../../services/feedback.service'; export const CarouselGalleryLayout: React.FC = ({ photos, + slug, onPhotoClick, onDownload, allowDownloads = true, - // selectedPhotos = new Set(), - // isSelectionMode = false + feedbackEnabled = false, + feedbackOptions }) => { const { theme } = useTheme(); const [currentIndex, setCurrentIndex] = useState(0); @@ -59,6 +62,9 @@ export const CarouselGalleryLayout: React.FC = ({ if (photos.length === 0) return null; const currentPhoto = photos[currentIndex]; + const [showIdentityModal, setShowIdentityModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); return (
@@ -136,6 +142,50 @@ export const CarouselGalleryLayout: React.FC = ({ )} + {feedbackEnabled && feedbackOptions?.allowLikes && ( + + )} + {feedbackEnabled && feedbackOptions?.allowFavorites && ( + + )}
@@ -181,6 +231,24 @@ export const CarouselGalleryLayout: React.FC = ({ )} + { setShowIdentityModal(false); setPendingAction(null); }} + onSubmit={async (name, email) => { + setSavedIdentity({ name, email }); + setShowIdentityModal(false); + if (pendingAction) { + await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { + feedback_type: pendingAction.type, + guest_name: name, + guest_email: email, + }); + setPendingAction(null); + } + }} + feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'} + /> + ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index ea06553..4a200f4 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; +import { Download, Maximize2, Check, MessageSquare, Star, Heart, Bookmark } from 'lucide-react'; import { useInView } from 'react-intersection-observer'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; +import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; +import { feedbackService } from '../../../services/feedback.service'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -19,6 +21,15 @@ interface GridPhotoProps { protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; useEnhancedProtection?: boolean; feedbackEnabled?: boolean; + feedbackOptions?: { + allowLikes?: boolean; + allowFavorites?: boolean; + allowRatings?: boolean; + allowComments?: boolean; + requireNameEmail?: boolean; + }; + savedIdentity?: { name: string; email: string } | null; + onRequireIdentity?: (action: 'like' | 'favorite', photoId: number) => void; } const GridPhoto: React.FC = ({ @@ -33,8 +44,10 @@ const GridPhoto: React.FC = ({ slug, protectionLevel = 'standard', useEnhancedProtection = false, - feedbackEnabled = false + feedbackEnabled = false, + feedbackOptions }) => { + // handled by parent layout; kept here for type completeness but not used const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.1, @@ -103,6 +116,49 @@ const GridPhoto: React.FC = ({ )} + {/* Quick feedback actions */} + {feedbackOptions?.allowLikes && ( + + )} + {feedbackOptions?.allowFavorites && ( + + )} )} @@ -174,7 +230,8 @@ export const GridGalleryLayout: React.FC = ({ allowDownloads = true, protectionLevel = 'standard', useEnhancedProtection = false, - feedbackEnabled = false + feedbackEnabled = false, + feedbackOptions }) => { const { theme } = useTheme(); const gallerySettings = theme.gallerySettings || {}; @@ -182,6 +239,10 @@ export const GridGalleryLayout: React.FC = ({ const spacing = gallerySettings.spacing || 'normal'; const animation = gallerySettings.photoAnimation || 'fade'; + const [showIdentityModal, setShowIdentityModal] = React.useState(false); + const [pendingAction, setPendingAction] = React.useState(null); + const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); + const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4'; const gridClass = `grid ${spacingClass} @@ -207,8 +268,31 @@ export const GridGalleryLayout: React.FC = ({ protectionLevel={protectionLevel} useEnhancedProtection={useEnhancedProtection} feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} + savedIdentity={savedIdentity} + onRequireIdentity={(action, photoId) => { + setPendingAction({ type: action, photoId }); + setShowIdentityModal(true); + }} /> ))} + { setShowIdentityModal(false); setPendingAction(null); }} + onSubmit={async (name, email) => { + setSavedIdentity({ name, email }); + setShowIdentityModal(false); + if (pendingAction) { + await feedbackService.submitFeedback(slug, String(pendingAction.photoId), { + feedback_type: pendingAction.type, + guest_name: name, + guest_email: email, + }); + setPendingAction(null); + } + }} + feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'} + /> ); }; diff --git a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx index c4e0c54..a5a44e4 100644 --- a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Download, Maximize2, Check, ChevronDown, Calendar, Clock } from 'lucide-react'; +import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, Bookmark } from 'lucide-react'; import { parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; @@ -8,6 +8,8 @@ import { AuthenticatedImage } from '../../common'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; import { buildResourceUrl } from '../../../utils/url'; +import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; +import { feedbackService } from '../../../services/feedback.service'; interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps { eventName?: string; @@ -18,6 +20,7 @@ interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps { export const HeroGalleryLayout: React.FC = ({ photos, + slug, onPhotoClick, onDownload, selectedPhotos = new Set(), @@ -27,13 +30,18 @@ export const HeroGalleryLayout: React.FC = ({ eventLogo, eventDate, expiresAt, - allowDownloads = true + allowDownloads = true, + feedbackEnabled = false, + feedbackOptions }) => { const { t } = useTranslation(); const { format } = useLocalizedDate(); const { theme } = useTheme(); const [heroPhoto, setHeroPhoto] = useState(null); const [hasInitialized, setHasInitialized] = useState(false); + const [showIdentityModal, setShowIdentityModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const gallerySettings = theme.gallerySettings || {}; const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3; @@ -188,6 +196,50 @@ export const HeroGalleryLayout: React.FC = ({ )} + {feedbackEnabled && feedbackOptions?.allowLikes && ( + + )} + {feedbackEnabled && feedbackOptions?.allowFavorites && ( + + )} )} @@ -213,5 +265,22 @@ export const HeroGalleryLayout: React.FC = ({ })} + { setShowIdentityModal(false); setPendingAction(null); }} + onSubmit={async (name, email) => { + setSavedIdentity({ name, email }); + setShowIdentityModal(false); + if (pendingAction) { + await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { + feedback_type: pendingAction.type, + guest_name: name, + guest_email: email, + }); + setPendingAction(null); + } + }} + feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'} + /> ); }; diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index 6741910..25cb65e 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -1,7 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; +import { Download, Maximize2, Check, MessageSquare, Star, Heart, Bookmark } from 'lucide-react'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; +import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; +import { feedbackService } from '../../../services/feedback.service'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -15,6 +17,12 @@ interface MasonryPhotoProps { style?: React.CSSProperties; allowDownloads?: boolean; feedbackEnabled?: boolean; + slug?: string; + feedbackOptions?: { + allowLikes?: boolean; + allowFavorites?: boolean; + requireNameEmail?: boolean; + }; } const MasonryPhoto: React.FC = ({ @@ -26,9 +34,14 @@ const MasonryPhoto: React.FC = ({ onToggleSelect, style, allowDownloads = true, - feedbackEnabled = false + feedbackEnabled = false, + slug, + feedbackOptions }) => { const [imageHeight, setImageHeight] = useState(200); + const [showIdentityModal, setShowIdentityModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); // Generate random heights for masonry effect useEffect(() => { @@ -102,10 +115,73 @@ const MasonryPhoto: React.FC = ({ )} + {feedbackOptions?.allowLikes && ( + + )} + {feedbackOptions?.allowFavorites && ( + + )} )} + {/* Identity Modal */} + { setShowIdentityModal(false); setPendingAction(null); }} + onSubmit={async (name, email) => { + setSavedIdentity({ name, email }); + setShowIdentityModal(false); + if (pendingAction) { + await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { + feedback_type: pendingAction.type, + guest_name: name, + guest_email: email, + }); + setPendingAction(null); + } + }} + feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'} + /> + {/* Selection Checkbox (visible on hover or when selected) */} )} + {feedbackEnabled && feedbackOptions?.allowLikes && ( + + )} + {feedbackEnabled && feedbackOptions?.allowFavorites && ( + + )} )} @@ -96,17 +155,37 @@ const MosaicPhoto: React.FC = ({ )} + { setShowIdentityModal(false); setPendingAction(null); }} + onSubmit={async (name, email) => { + setSavedIdentity({ name, email }); + setShowIdentityModal(false); + if (pendingAction) { + await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { + feedback_type: pendingAction.type, + guest_name: name, + guest_email: email, + }); + setPendingAction(null); + } + }} + feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'} + /> ); }; export const MosaicGalleryLayout: React.FC = ({ photos, + slug, onPhotoClick, onDownload, selectedPhotos = new Set(), isSelectionMode = false, onPhotoSelect, - allowDownloads = true + allowDownloads = true, + feedbackEnabled = false, + feedbackOptions }) => { // const { theme } = useTheme(); // const gallerySettings = theme.gallerySettings || {}; @@ -152,6 +231,9 @@ export const MosaicGalleryLayout: React.FC = ({ onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)} className="col-span-1" allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} /> )}
@@ -165,6 +247,9 @@ export const MosaicGalleryLayout: React.FC = ({ onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)} className="" allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} /> )} {photo2 && ( @@ -177,6 +262,9 @@ export const MosaicGalleryLayout: React.FC = ({ onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)} className="" allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} /> )}
@@ -228,6 +316,9 @@ export const MosaicGalleryLayout: React.FC = ({ onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)} className="col-span-2" allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} /> )}
@@ -241,6 +332,9 @@ export const MosaicGalleryLayout: React.FC = ({ onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)} className="" allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} /> )} {photo2 && ( @@ -253,6 +347,9 @@ export const MosaicGalleryLayout: React.FC = ({ onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)} className="" allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} /> )}
@@ -285,6 +382,9 @@ export const MosaicGalleryLayout: React.FC = ({ onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)} className="aspect-square" allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} /> ); })} diff --git a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx index 098a204..4a2af25 100644 --- a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx @@ -1,21 +1,29 @@ -import React, { useMemo } from 'react'; -import { Download, Maximize2, Check, Calendar } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { Download, Maximize2, Check, Calendar, Heart, Bookmark } from 'lucide-react'; import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; +import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; +import { feedbackService } from '../../../services/feedback.service'; export const TimelineGalleryLayout: React.FC = ({ photos, + slug, onPhotoClick, onDownload, selectedPhotos = new Set(), isSelectionMode = false, onPhotoSelect, - allowDownloads = true + allowDownloads = true, + feedbackEnabled = false, + feedbackOptions }) => { const { theme } = useTheme(); + const [showIdentityModal, setShowIdentityModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const gallerySettings = theme.gallerySettings || {}; const grouping = gallerySettings.timelineGrouping || 'day'; const showDates = gallerySettings.timelineShowDates !== false; @@ -131,6 +139,50 @@ export const TimelineGalleryLayout: React.FC = ({ )} + {feedbackEnabled && feedbackOptions?.allowLikes && ( + + )} + {feedbackEnabled && feedbackOptions?.allowFavorites && ( + + )} )} @@ -158,6 +210,23 @@ export const TimelineGalleryLayout: React.FC = ({ ))} + { setShowIdentityModal(false); setPendingAction(null); }} + onSubmit={async (name, email) => { + setSavedIdentity({ name, email }); + setShowIdentityModal(false); + if (pendingAction) { + await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { + feedback_type: pendingAction.type, + guest_name: name, + guest_email: email, + }); + setPendingAction(null); + } + }} + feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'} + /> ); };