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:
2025-09-15 15:30:38 +02:00
parent d64e7d08de
commit 6368f1027f
10 changed files with 534 additions and 29 deletions
@@ -544,6 +544,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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}
@@ -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<PhotoGridWithLayoutsProps> = ({
@@ -47,6 +54,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
isSelectionMode: parentSelectionMode,
selectedPhotos: parentSelectedPhotos,
feedbackEnabled,
feedbackOptions,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
@@ -186,6 +194,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
eventDate,
expiresAt,
feedbackEnabled,
feedbackOptions,
};
let LayoutComponent;
@@ -15,6 +15,7 @@ interface PhotoLightboxProps {
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
initialShowFeedback?: boolean;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -26,6 +27,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
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<PhotoLightboxProps> = ({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
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 currentPhoto = photos[currentIndex];
@@ -231,13 +240,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<ChevronLeft className="w-6 h-6 text-white" />
</button>
<button
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"
aria-label="Next photo"
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
{!showFeedback || !isSmallScreen ? (
<button
onClick={goToNext}
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"
style={{ right: showFeedback && !isSmallScreen ? '26rem' : '1rem' }}
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
) : null}
{/* Bottom toolbar */}
<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';
useEnhancedProtection?: 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> {
@@ -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<BaseGalleryLayoutProps> = ({
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<BaseGalleryLayoutProps> = ({
if (photos.length === 0) return null;
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 (
<div className="relative">
@@ -136,6 +142,50 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5" />
</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>
@@ -181,6 +231,24 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
</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>{`
@keyframes progress {
from { width: 0%; }
@@ -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<GridPhotoProps> = ({
@@ -33,8 +44,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
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<GridPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</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>
@@ -174,7 +230,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
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<BaseGalleryLayoutProps> = ({
const spacing = gallerySettings.spacing || 'normal';
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 gridClass = `grid ${spacingClass}
@@ -207,8 +268,31 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
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>
);
};
@@ -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<HeroGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
@@ -27,13 +30,18 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
eventLogo,
eventDate,
expiresAt,
allowDownloads = true
allowDownloads = true,
feedbackEnabled = false,
feedbackOptions
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
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 overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
@@ -188,6 +196,50 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</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>
@@ -213,5 +265,22 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
})}
</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 { 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<MasonryPhotoProps> = ({
@@ -26,9 +34,14 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
onToggleSelect,
style,
allowDownloads = true,
feedbackEnabled = false
feedbackEnabled = false,
slug,
feedbackOptions
}) => {
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
useEffect(() => {
@@ -102,10 +115,73 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</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>
{/* 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) */}
<button
type="button"
@@ -136,13 +212,15 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
allowDownloads = true,
feedbackEnabled = false
feedbackEnabled = false,
feedbackOptions
}) => {
const { theme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
@@ -198,6 +276,8 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
allowDownloads={allowDownloads}
feedbackEnabled={feedbackEnabled}
slug={slug}
feedbackOptions={feedbackOptions}
/>
);
})}
@@ -1,7 +1,9 @@
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 { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -14,6 +16,13 @@ interface MosaicPhotoProps {
onToggleSelect: () => void;
className?: string;
allowDownloads?: boolean;
slug?: string;
feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
allowFavorites?: boolean;
requireNameEmail?: boolean;
};
}
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
@@ -24,8 +33,14 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
onDownload,
onToggleSelect,
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 (
<div
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" />
</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>
@@ -96,17 +155,37 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
</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> = ({
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<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-1"
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
/>
)}
<div className="grid grid-rows-2 gap-2">
@@ -165,6 +247,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
/>
)}
{photo2 && (
@@ -177,6 +262,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
/>
)}
</div>
@@ -228,6 +316,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-2"
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
/>
)}
<div className="grid grid-rows-2 gap-2">
@@ -241,6 +332,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
/>
)}
{photo2 && (
@@ -253,6 +347,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
/>
)}
</div>
@@ -285,6 +382,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
className="aspect-square"
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
/>
);
})}
@@ -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<BaseGalleryLayoutProps> = ({
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 | { type: 'like' | 'favorite'; photoId: number }>(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<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</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>
@@ -158,6 +210,23 @@ export const TimelineGalleryLayout: 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'}
/>
</div>
);
};