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}
|
||||
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<GalleryViewProps> = ({ slug, event }) => {
|
||||
</GalleryLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
@@ -392,4 +404,4 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
abstract render(): React.ReactNode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%; }
|
||||
@@ -189,4 +257,4 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user