feat(gallery): add quick Like/Favorite actions on thumbnails across layouts
- Grid, Masonry, Mosaic, Timeline, Hero, and Carousel layouts now expose inline Like/Favorite buttons when feedback is enabled - Respect requireNameEmail; prompt via identity modal before submitting feedback - Wire feedback settings from GalleryView -> layouts via feedbackOptions feat(lightbox): keep feedback usable while navigating - Add initialShowFeedback prop; preserve panel state across navigation - Offset Next button when feedback panel is open so it remains accessible - Hide/avoid overlapping nav on small screens Refs: #19
This commit is contained in:
@@ -544,6 +544,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
categoryId={selectedCategoryId}
|
categoryId={selectedCategoryId}
|
||||||
feedbackEnabled={feedbackEnabled}
|
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}
|
isSelectionMode={isSelectionMode}
|
||||||
selectedPhotos={selectedPhotos}
|
selectedPhotos={selectedPhotos}
|
||||||
onSelectionChange={setSelectedPhotos}
|
onSelectionChange={setSelectedPhotos}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
|
feedbackOptions?: {
|
||||||
|
allowLikes?: boolean;
|
||||||
|
allowFavorites?: boolean;
|
||||||
|
allowRatings?: boolean;
|
||||||
|
allowComments?: boolean;
|
||||||
|
requireNameEmail?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||||
@@ -47,6 +54,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
isSelectionMode: parentSelectionMode,
|
isSelectionMode: parentSelectionMode,
|
||||||
selectedPhotos: parentSelectedPhotos,
|
selectedPhotos: parentSelectedPhotos,
|
||||||
feedbackEnabled,
|
feedbackEnabled,
|
||||||
|
feedbackOptions,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
@@ -186,6 +194,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
eventDate,
|
eventDate,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
feedbackEnabled,
|
feedbackEnabled,
|
||||||
|
feedbackOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
let LayoutComponent;
|
let LayoutComponent;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface PhotoLightboxProps {
|
|||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
|
initialShowFeedback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||||
@@ -26,6 +27,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
|
initialShowFeedback = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
@@ -33,7 +35,14 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||||
const [showFeedback, setShowFeedback] = useState(false);
|
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
|
||||||
|
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(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 downloadPhotoMutation = useDownloadPhoto();
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
@@ -231,13 +240,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
<ChevronLeft className="w-6 h-6 text-white" />
|
<ChevronLeft className="w-6 h-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!showFeedback || !isSmallScreen ? (
|
||||||
<button
|
<button
|
||||||
onClick={goToNext}
|
onClick={goToNext}
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
className="absolute top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-30"
|
||||||
aria-label="Next photo"
|
aria-label="Next photo"
|
||||||
|
style={{ right: showFeedback && !isSmallScreen ? '26rem' : '1rem' }}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-6 h-6 text-white" />
|
<ChevronRight className="w-6 h-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Bottom toolbar */}
|
{/* Bottom toolbar */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20">
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export interface BaseGalleryLayoutProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
|
feedbackOptions?: {
|
||||||
|
allowLikes?: boolean;
|
||||||
|
allowFavorites?: boolean;
|
||||||
|
allowRatings?: boolean;
|
||||||
|
allowComments?: boolean;
|
||||||
|
requireNameEmail?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
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 { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage, Button } from '../../common';
|
import { AuthenticatedImage, Button } from '../../common';
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
|
|
||||||
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||||
photos,
|
photos,
|
||||||
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
// selectedPhotos = new Set(),
|
feedbackEnabled = false,
|
||||||
// isSelectionMode = false
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
@@ -59,6 +62,9 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
if (photos.length === 0) return null;
|
if (photos.length === 0) return null;
|
||||||
|
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
||||||
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -136,6 +142,50 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
<Download className="w-5 h-5" />
|
<Download className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'like', photoId: currentPhoto.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
title="Like photo"
|
||||||
|
>
|
||||||
|
<Heart className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'favorite', photoId: currentPhoto.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||||
|
feedback_type: 'favorite',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
title="Favorite photo"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,6 +231,24 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FeedbackIdentityModal
|
||||||
|
isOpen={showIdentityModal}
|
||||||
|
onClose={() => { 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'}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes progress {
|
@keyframes progress {
|
||||||
from { width: 0%; }
|
from { width: 0%; }
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
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 { useInView } from 'react-intersection-observer';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
|
|
||||||
@@ -19,6 +21,15 @@ interface GridPhotoProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
feedbackEnabled?: 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<GridPhotoProps> = ({
|
const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||||
@@ -33,8 +44,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
slug,
|
slug,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
feedbackEnabled = false
|
feedbackEnabled = false,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
|
// handled by parent layout; kept here for type completeness but not used
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
triggerOnce: true,
|
triggerOnce: true,
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
@@ -103,6 +116,49 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Quick feedback actions */}
|
||||||
|
{feedbackOptions?.allowLikes && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||||
|
onRequireIdentity('like', photo.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Like photo"
|
||||||
|
title="Like"
|
||||||
|
>
|
||||||
|
<Heart className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{feedbackOptions?.allowFavorites && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||||
|
onRequireIdentity('favorite', photo.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'favorite',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Favorite photo"
|
||||||
|
title="Favorite"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +230,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
feedbackEnabled = false
|
feedbackEnabled = false,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
@@ -182,6 +239,10 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
const spacing = gallerySettings.spacing || 'normal';
|
const spacing = gallerySettings.spacing || 'normal';
|
||||||
const animation = gallerySettings.photoAnimation || 'fade';
|
const animation = gallerySettings.photoAnimation || 'fade';
|
||||||
|
|
||||||
|
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like' | 'favorite'; photoId: number }>(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 spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||||
|
|
||||||
const gridClass = `grid ${spacingClass}
|
const gridClass = `grid ${spacingClass}
|
||||||
@@ -207,8 +268,31 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
|
savedIdentity={savedIdentity}
|
||||||
|
onRequireIdentity={(action, photoId) => {
|
||||||
|
setPendingAction({ type: action, photoId });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<FeedbackIdentityModal
|
||||||
|
isOpen={showIdentityModal}
|
||||||
|
onClose={() => { 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'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||||
@@ -8,6 +8,8 @@ import { AuthenticatedImage } from '../../common';
|
|||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
import { buildResourceUrl } from '../../../utils/url';
|
import { buildResourceUrl } from '../../../utils/url';
|
||||||
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
|
|
||||||
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
@@ -18,6 +20,7 @@ interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
|||||||
|
|
||||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||||
photos,
|
photos,
|
||||||
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
@@ -27,13 +30,18 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
eventLogo,
|
eventLogo,
|
||||||
eventDate,
|
eventDate,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
allowDownloads = true
|
allowDownloads = true,
|
||||||
|
feedbackEnabled = false,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
||||||
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||||
|
|
||||||
@@ -188,6 +196,50 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'like', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Like photo"
|
||||||
|
title="Like"
|
||||||
|
>
|
||||||
|
<Heart className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'favorite', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'favorite',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Favorite photo"
|
||||||
|
title="Favorite"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,5 +265,22 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackIdentityModal
|
||||||
|
isOpen={showIdentityModal}
|
||||||
|
onClose={() => { 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'}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
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 { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
|
|
||||||
@@ -15,6 +17,12 @@ interface MasonryPhotoProps {
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
|
slug?: string;
|
||||||
|
feedbackOptions?: {
|
||||||
|
allowLikes?: boolean;
|
||||||
|
allowFavorites?: boolean;
|
||||||
|
requireNameEmail?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||||
@@ -26,9 +34,14 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
style,
|
style,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
feedbackEnabled = false
|
feedbackEnabled = false,
|
||||||
|
slug,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
const [imageHeight, setImageHeight] = useState<number>(200);
|
const [imageHeight, setImageHeight] = useState<number>(200);
|
||||||
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
||||||
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
|
|
||||||
// Generate random heights for masonry effect
|
// Generate random heights for masonry effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,10 +115,73 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{feedbackOptions?.allowLikes && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'like', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Like photo"
|
||||||
|
title="Like"
|
||||||
|
>
|
||||||
|
<Heart className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{feedbackOptions?.allowFavorites && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'favorite', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'favorite',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Favorite photo"
|
||||||
|
title="Favorite"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Identity Modal */}
|
||||||
|
<FeedbackIdentityModal
|
||||||
|
isOpen={showIdentityModal}
|
||||||
|
onClose={() => { 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) */}
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -136,13 +212,15 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
|
|
||||||
export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||||
photos,
|
photos,
|
||||||
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
onPhotoSelect,
|
onPhotoSelect,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
feedbackEnabled = false
|
feedbackEnabled = false,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -198,6 +276,8 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
slug={slug}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Download, Maximize2, Check } from 'lucide-react';
|
import { Download, Maximize2, Check, Heart, Bookmark } from 'lucide-react';
|
||||||
// import { useTheme } from '../../../contexts/ThemeContext';
|
// import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
|
|
||||||
@@ -14,6 +16,13 @@ interface MosaicPhotoProps {
|
|||||||
onToggleSelect: () => void;
|
onToggleSelect: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
|
slug?: string;
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
|
feedbackOptions?: {
|
||||||
|
allowLikes?: boolean;
|
||||||
|
allowFavorites?: boolean;
|
||||||
|
requireNameEmail?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||||
@@ -24,8 +33,14 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
onDownload,
|
onDownload,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
className = '',
|
className = '',
|
||||||
allowDownloads = true
|
allowDownloads = true,
|
||||||
|
slug,
|
||||||
|
feedbackEnabled,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
||||||
|
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||||
@@ -67,6 +82,50 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'like', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Like photo"
|
||||||
|
title="Like"
|
||||||
|
>
|
||||||
|
<Heart className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'favorite', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'favorite',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Favorite photo"
|
||||||
|
title="Favorite"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -96,17 +155,37 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackIdentityModal
|
||||||
|
isOpen={showIdentityModal}
|
||||||
|
onClose={() => { 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<BaseGalleryLayoutProps> = ({
|
export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||||
photos,
|
photos,
|
||||||
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
onPhotoSelect,
|
onPhotoSelect,
|
||||||
allowDownloads = true
|
allowDownloads = true,
|
||||||
|
feedbackEnabled = false,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
// const { theme } = useTheme();
|
// const { theme } = useTheme();
|
||||||
// const gallerySettings = theme.gallerySettings || {};
|
// const gallerySettings = theme.gallerySettings || {};
|
||||||
@@ -152,6 +231,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
||||||
className="col-span-1"
|
className="col-span-1"
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-rows-2 gap-2">
|
<div className="grid grid-rows-2 gap-2">
|
||||||
@@ -165,6 +247,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
|
||||||
className=""
|
className=""
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{photo2 && (
|
{photo2 && (
|
||||||
@@ -177,6 +262,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
|
||||||
className=""
|
className=""
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,6 +316,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
||||||
className="col-span-2"
|
className="col-span-2"
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-rows-2 gap-2">
|
<div className="grid grid-rows-2 gap-2">
|
||||||
@@ -241,6 +332,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
|
||||||
className=""
|
className=""
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{photo2 && (
|
{photo2 && (
|
||||||
@@ -253,6 +347,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
|
||||||
className=""
|
className=""
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,6 +382,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
className="aspect-square"
|
className="aspect-square"
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Download, Maximize2, Check, Calendar } from 'lucide-react';
|
import { Download, Maximize2, Check, Calendar, Heart, Bookmark } from 'lucide-react';
|
||||||
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
|
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
|
|
||||||
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||||
photos,
|
photos,
|
||||||
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
onPhotoSelect,
|
onPhotoSelect,
|
||||||
allowDownloads = true
|
allowDownloads = true,
|
||||||
|
feedbackEnabled = false,
|
||||||
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
||||||
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
const grouping = gallerySettings.timelineGrouping || 'day';
|
const grouping = gallerySettings.timelineGrouping || 'day';
|
||||||
const showDates = gallerySettings.timelineShowDates !== false;
|
const showDates = gallerySettings.timelineShowDates !== false;
|
||||||
@@ -131,6 +139,50 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'like', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Like photo"
|
||||||
|
title="Like"
|
||||||
|
>
|
||||||
|
<Heart className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
|
setPendingAction({ type: 'favorite', photoId: photo.id });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
|
feedback_type: 'favorite',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Favorite photo"
|
||||||
|
title="Favorite"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +210,23 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackIdentityModal
|
||||||
|
isOpen={showIdentityModal}
|
||||||
|
onClose={() => { 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'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user