Refine gallery feedback actions
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped

This commit is contained in:
2025-09-17 22:27:13 +02:00
parent b03760ab01
commit 19f8facc49
21 changed files with 719 additions and 376 deletions
@@ -5,6 +5,10 @@ export interface BaseGalleryLayoutProps {
photos: Photo[];
slug: string;
onPhotoClick: (index: number) => void;
// Optional: open the lightbox with feedback panel visible
onOpenPhotoWithFeedback?: (index: number) => void;
// Notify parent that feedback (like/favorite/rating/comment) changed
onFeedbackChange?: () => void;
onDownload: (photo: Photo, e: React.MouseEvent) => void;
selectedPhotos?: Set<number>;
isSelectionMode?: boolean;
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, Bookmark } from 'lucide-react';
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, MessageSquare } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
@@ -10,6 +10,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onDownload,
allowDownloads = true,
feedbackEnabled = false,
@@ -63,8 +64,10 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const currentPhoto = photos[currentIndex];
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
return (
<div className="relative">
@@ -142,7 +145,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5" />
</Button>
)}
{feedbackEnabled && feedbackOptions?.allowLikes && (
{feedbackOptions?.allowLikes && (
<Button
variant="ghost"
size="sm"
@@ -152,38 +155,32 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
className="text-white hover:bg-white/20"
className={`hover:bg-white/20 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
title="Like photo"
aria-pressed={likedIds.has(currentPhoto.id)}
>
<Heart className="w-5 h-5" />
</Button>
)}
{feedbackEnabled && feedbackOptions?.allowFavorites && (
{canQuickComment && (
<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,
});
}}
onClick={() => { onOpenPhotoWithFeedback?.(currentIndex); }}
className="text-white hover:bg-white/20"
title="Favorite photo"
title="Comment"
aria-label="Comment on photo"
>
<Bookmark className="w-5 h-5" />
<MessageSquare className="w-5 h-5" />
</Button>
)}
</div>
@@ -246,7 +243,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setPendingAction(null);
}
}}
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
feedbackType="like"
/>
<style>{`
@@ -1,5 +1,5 @@
import React from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Bookmark } from 'lucide-react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
@@ -23,13 +23,17 @@ interface GridPhotoProps {
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;
onRequireIdentity?: (action: 'like', photoId: number) => void;
onQuickComment?: () => void;
onFeedbackChange?: () => void;
// Immediate UI like state and callback
liked?: boolean;
onLikeSuccess?: () => void;
}
const GridPhoto: React.FC<GridPhotoProps> = ({
@@ -45,7 +49,13 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
protectionLevel = 'standard',
useEnhancedProtection = false,
feedbackEnabled = false,
feedbackOptions
feedbackOptions,
savedIdentity,
onRequireIdentity,
onQuickComment,
onFeedbackChange,
liked = false,
onLikeSuccess
}) => {
// handled by parent layout; kept here for type completeness but not used
const { ref, inView } = useInView({
@@ -94,7 +104,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
}}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
<div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
@@ -116,47 +126,45 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare 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"
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
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,
});
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (err) {
// Keep optimistic state; a refresh will reconcile
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
}}
aria-label="Like photo"
aria-pressed={liked}
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" />
<Heart className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
</>
@@ -180,10 +188,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
</div>
</button>
{/* Feedback Indicators (always visible, bottom-left) */}
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
{/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */}
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0 || liked) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}>
{photo.like_count > 0 && (
{(photo.like_count > 0 || liked) && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
@@ -220,6 +228,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onFeedbackChange,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
@@ -237,7 +247,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const animation = gallerySettings.photoAnimation || 'fade';
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [likedPhotoIds, setLikedPhotoIds] = React.useState<Set<number>>(new Set());
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
@@ -271,6 +282,16 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setPendingAction({ type: action, photoId });
setShowIdentityModal(true);
}}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/>
))}
<FeedbackIdentityModal
@@ -285,10 +306,18 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
guest_name: name,
guest_email: email,
});
// Immediately reflect like UI
if (pendingAction.type === 'like') {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(pendingAction.photoId);
return next;
});
}
setPendingAction(null);
}
}}
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
feedbackType="like"
/>
</div>
);
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, Bookmark } from 'lucide-react';
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
@@ -16,12 +16,15 @@ interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
// Use a static hero photo independent of current filter
heroPhotoOverride?: Photo | null;
}
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
@@ -30,6 +33,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
eventLogo,
eventDate,
expiresAt,
heroPhotoOverride,
allowDownloads = true,
feedbackEnabled = false,
feedbackOptions
@@ -40,10 +44,20 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
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 [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const gallerySettings = theme.gallerySettings || {};
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
// If an override is provided, always use it and skip initialization logic
useEffect(() => {
if (heroPhotoOverride) {
setHeroPhoto(heroPhotoOverride);
setHasInitialized(true);
}
}, [heroPhotoOverride]);
// Reset initialization when heroImageId changes
useEffect(() => {
@@ -54,29 +68,28 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
// Select hero photo (admin-selected or first photo only if gallery was empty)
useEffect(() => {
// When an override is provided, the effect above has already set the hero.
if (heroPhotoOverride) return;
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
// Process hero layout with provided photos
// If admin has selected a specific hero image, always use it
// If admin has selected a specific hero image, always use it when available
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
// Hero photo selected by admin
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasInitialized(true);
return;
}
}
// Only auto-select first photo on initial load when gallery was empty
// This prevents changing the hero when new photos are uploaded
// Only auto-select first photo on initial load
if (!hasInitialized) {
setHeroPhoto(photos[0]);
setHasInitialized(true);
}
}
}, [photos, gallerySettings.heroImageId, hasInitialized]);
}, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]);
if (!heroPhoto) return null;
@@ -198,9 +211,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackEnabled && feedbackOptions?.allowLikes && (
{feedbackOptions?.allowLikes && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
@@ -208,38 +221,30 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedIds.has(photo.id)}
title="Like"
>
<Heart className="w-5 h-5 text-neutral-800" />
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{feedbackEnabled && feedbackOptions?.allowFavorites && (
{canQuickComment && (
<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"
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
aria-label="Comment on photo"
title="Comment"
>
<Bookmark className="w-5 h-5 text-neutral-800" />
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</>
@@ -263,18 +268,16 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
</div>
</button>
{/* Feedback indicators (always visible, bottom-left) */}
{(feedbackEnabled && (photo.like_count > 0 || (photo.average_rating || 0) > 0 || (photo.comment_count || 0) > 0)) && (
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
{(photo.like_count > 0 || likedIds.has(photo.id) || (photo.average_rating || 0) > 0 || (photo.comment_count || 0) > 0) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
{photo.like_count > 0 && (
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
)}
{(photo.average_rating || 0) > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
<Bookmark className="hidden" />
{/* Using star icon to indicate rating */}
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
</span>
)}
@@ -305,7 +308,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
setPendingAction(null);
}
}}
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
feedbackType="like"
/>
</>
);
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Bookmark } from 'lucide-react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
@@ -20,9 +20,10 @@ interface MasonryPhotoProps {
slug?: string;
feedbackOptions?: {
allowLikes?: boolean;
allowFavorites?: boolean;
allowComments?: boolean;
requireNameEmail?: boolean;
};
onQuickComment?: () => void;
}
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
@@ -36,11 +37,12 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
allowDownloads = true,
feedbackEnabled = false,
slug,
feedbackOptions
feedbackOptions,
onQuickComment
}) => {
const [imageHeight, setImageHeight] = useState<number>(200);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
// Generate random heights for masonry effect
@@ -115,6 +117,16 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare 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"
@@ -137,28 +149,6 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<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>
@@ -179,7 +169,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
setPendingAction(null);
}
}}
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
feedbackType="like"
/>
{/* Selection Checkbox (visible on hover or when selected) */}
@@ -214,6 +204,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
@@ -278,6 +269,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackEnabled={feedbackEnabled}
slug={slug}
feedbackOptions={feedbackOptions}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
/>
);
})}
@@ -1,5 +1,5 @@
import React from 'react';
import { Download, Maximize2, Check, Heart, Bookmark } from 'lucide-react';
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
// import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
@@ -20,9 +20,10 @@ interface MosaicPhotoProps {
feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
allowFavorites?: boolean;
allowComments?: boolean;
requireNameEmail?: boolean;
};
onQuickComment?: () => void;
}
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
@@ -35,12 +36,16 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
className = '',
allowDownloads = true,
slug,
feedbackEnabled,
feedbackOptions
feedbackEnabled = false,
feedbackOptions,
onQuickComment
}) => {
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const [likedLocal, setLikedLocal] = React.useState(false);
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
return (
<>
<div
@@ -83,9 +88,9 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackEnabled && feedbackOptions?.allowLikes && (
{feedbackOptions?.allowLikes && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
@@ -93,44 +98,45 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
setLikedLocal(true);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedLocal}
title="Like"
>
<Heart className="w-5 h-5 text-neutral-800" />
<Heart className={`w-5 h-5 ${likedLocal ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{feedbackEnabled && feedbackOptions?.allowFavorites && (
{canComment && (
<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"
onClick={(e) => { e.stopPropagation(); onQuickComment?.(); }}
aria-label="Comment on photo"
title="Comment"
>
<Bookmark className="w-5 h-5 text-neutral-800" />
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</>
)}
</div>
{/* Feedback Indicators (bottom-left) */}
{(photo.like_count > 0 || likedLocal) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
</div>
)}
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
@@ -171,7 +177,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
setPendingAction(null);
}
}}
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
feedbackType="like"
/>
</>
);
@@ -181,6 +187,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
@@ -236,6 +243,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
/>
)}
<div className="grid grid-rows-2 gap-2">
@@ -244,14 +252,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo1}
isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
/>
)}
{photo2 && (
@@ -259,14 +268,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo2}
isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
/>
)}
</div>
@@ -290,6 +300,10 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onDownload={(e) => onDownload(photo, e)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(currentIndex); }}
/>
) : null;
})}
@@ -321,6 +335,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
/>
)}
<div className="grid grid-rows-2 gap-2">
@@ -329,14 +344,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo1}
isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
/>
)}
{photo2 && (
@@ -344,14 +360,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo2}
isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
/>
)}
</div>
@@ -387,6 +404,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(index); }}
/>
);
})}
@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Download, Maximize2, Check, Calendar, Heart, Bookmark } from 'lucide-react';
import { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react';
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
@@ -12,6 +12,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
@@ -21,12 +22,14 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackOptions
}) => {
const { theme } = useTheme();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; 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;
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
// Group photos by date
const groupedPhotos = useMemo(() => {
@@ -139,9 +142,9 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackEnabled && feedbackOptions?.allowLikes && (
{feedbackOptions?.allowLikes && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
@@ -149,44 +152,44 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedIds.has(photo.id)}
title="Like"
>
<Heart className="w-5 h-5 text-neutral-800" />
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{feedbackEnabled && feedbackOptions?.allowFavorites && (
{canQuickComment && (
<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"
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
aria-label="Comment on photo"
title="Comment"
>
<Bookmark className="w-5 h-5 text-neutral-800" />
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</>
)}
</div>
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
</div>
)}
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
@@ -225,7 +228,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setPendingAction(null);
}
}}
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
feedbackType="like"
/>
</div>
);