diff --git a/frontend/src/components/gallery/PhotoCard.tsx b/frontend/src/components/gallery/PhotoCard.tsx new file mode 100644 index 00000000..6c9cd73e --- /dev/null +++ b/frontend/src/components/gallery/PhotoCard.tsx @@ -0,0 +1,432 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Download, Maximize2, Check, MessageSquare, Heart } from 'lucide-react'; +import { useInView } from 'react-intersection-observer'; +import { AuthenticatedImage } from '../common'; +import { FeedbackIdentityModal } from './FeedbackIdentityModal'; +import { feedbackService } from '../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; +import type { Photo } from '../../types'; + +export interface PhotoCardFeedbackOptions { + allowLikes?: boolean; + allowFavorites?: boolean; + allowRatings?: boolean; + allowComments?: boolean; + requireNameEmail?: boolean; +} + +export interface PhotoCardProps { + photo: Photo; + isSelected: boolean; + isSelectionMode: boolean; + onClick: (e: React.MouseEvent) => void; + onDownload: (e: React.MouseEvent) => void; + onToggleSelect: () => void; + /** Full container className (layout-specific positioning/animation/rounding). */ + className: string; + style?: React.CSSProperties; + /** Extra attributes for the container div (e.g. role/tabIndex/onKeyDown). */ + containerProps?: React.HTMLAttributes; + /** Props passed verbatim to AuthenticatedImage. */ + imageProps: React.ComponentProps; + /** Lazy-render via IntersectionObserver with a skeleton placeholder. */ + lazy?: boolean; + inViewRootMargin?: string; + skeletonClassName?: string; + /** Keep container at opacity 0 until in view (only meaningful with `lazy`). */ + fadeInWhenVisible?: boolean; + /** Tap-to-reveal overlay state machine for touch devices (Grid/Justified). */ + touchAware?: boolean; + /** Static overlay classes; `touchAware` appends computed visibility classes. */ + overlayBaseClassName: string; + /** 'light' = white/90 buttons with dark icons; 'dark' = white/20 buttons with white icons. */ + actionVariant?: 'light' | 'dark'; + allowDownloads?: boolean; + feedbackEnabled?: boolean; + feedbackOptions?: PhotoCardFeedbackOptions; + slug?: string; + onQuickComment?: () => void; + onFeedbackChange?: () => void; + liked?: boolean; + onLikeSuccess?: () => void; + /** 'self': card owns the identity modal; 'parent': delegate via onRequireIdentity. */ + identityMode?: 'self' | 'parent'; + savedIdentity?: { name: string; email: string } | null; + onRequireIdentity?: (action: 'like', photoId: number) => void; + /** Use Like/Unlike toggle labels on the like button (Masonry columns). */ + likeToggleLabels?: boolean; + /** Render the Like button before the Comment button (Mosaic/Timeline). */ + likeBeforeComment?: boolean; + /** Render data-testid on the selection checkbox. */ + checkboxTestId?: boolean; + /** Rendered between the image and the hover overlay. */ + beforeOverlay?: React.ReactNode; + /** Rendered between the overlay and the selection checkbox. */ + afterOverlay?: React.ReactNode; + /** Rendered after the selection checkbox. */ + children?: React.ReactNode; +} + +export const PhotoCard: React.FC = ({ + photo, + isSelected, + isSelectionMode, + onClick, + onDownload, + onToggleSelect, + className, + style, + containerProps, + imageProps, + lazy = false, + inViewRootMargin, + skeletonClassName = 'skeleton w-full h-full rounded-lg', + fadeInWhenVisible = false, + touchAware = false, + overlayBaseClassName, + actionVariant = 'light', + allowDownloads = true, + feedbackEnabled = false, + feedbackOptions, + slug, + onQuickComment, + onFeedbackChange, + liked = false, + onLikeSuccess, + identityMode = 'parent', + savedIdentity, + onRequireIdentity, + likeToggleLabels = false, + likeBeforeComment = false, + checkboxTestId = false, + beforeOverlay, + afterOverlay, + children, +}) => { + const guestIdentity = useGuestIdentityOptional(); + const [overlayVisible, setOverlayVisible] = useState(false); + const [isTouchDevice, setIsTouchDevice] = useState(false); + const overlayTimeoutRef = useRef(null); + + // Self-managed identity modal state (identityMode === 'self') + const [showIdentityModal, setShowIdentityModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [selfIdentity, setSelfIdentity] = useState<{ name: string; email: string } | null>(null); + + const savedIdentityValue = identityMode === 'self' ? selfIdentity : savedIdentity; + + // Detect touch device (touch-aware overlay only) + useEffect(() => { + if (!touchAware || typeof window === 'undefined') return; + + const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)'); + const updateTouchState = () => { + const hasNavigator = typeof navigator !== 'undefined'; + setIsTouchDevice( + mediaQuery.matches || + ('ontouchstart' in window) || + (hasNavigator && navigator.maxTouchPoints > 0) + ); + }; + + updateTouchState(); + + const listener = (event: MediaQueryListEvent) => { + setIsTouchDevice(event.matches); + }; + + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', listener); + } else if (mediaQuery.addListener) { + mediaQuery.addListener(listener); + } + + return () => { + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener('change', listener); + } else if (mediaQuery.removeListener) { + mediaQuery.removeListener(listener); + } + }; + }, [touchAware]); + + const hideOverlay = useCallback(() => { + if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(overlayTimeoutRef.current); + } + overlayTimeoutRef.current = null; + setOverlayVisible(false); + }, []); + + const showOverlayTemporarily = useCallback(() => { + setOverlayVisible(true); + if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(overlayTimeoutRef.current); + } + if (typeof window !== 'undefined') { + overlayTimeoutRef.current = window.setTimeout(() => { + overlayTimeoutRef.current = null; + setOverlayVisible(false); + }, 2500); + } + }, []); + + useEffect(() => { + return () => { + if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(overlayTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (isSelectionMode) { + hideOverlay(); + } + }, [isSelectionMode, hideOverlay]); + + // Lazy loading with intersection observer + const { ref, inView: observedInView } = useInView({ + triggerOnce: true, + threshold: 0.1, + rootMargin: inViewRootMargin, + }); + const inView = !lazy || observedInView; + + const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions); + + const overlayVisibilityClass = overlayVisible + ? 'opacity-100 md:opacity-100' + : 'opacity-0 md:opacity-0'; + + const overlayClassName = touchAware + ? `${overlayBaseClassName} ${overlayVisibilityClass} md:group-hover:opacity-100` + : overlayBaseClassName; + + const checkboxVisibilityClass = touchAware + ? `${ + isSelected || isSelectionMode || overlayVisible + ? 'opacity-100 md:opacity-100' + : 'opacity-0 md:opacity-0' + } md:group-hover:opacity-100` + : isSelected + ? 'opacity-100' + : 'opacity-0 group-hover:opacity-100'; + + const buttonType = actionVariant === 'dark' ? ('button' as const) : undefined; + const actionButtonClass = + actionVariant === 'dark' + ? 'p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors' + : 'p-2 bg-white/90 rounded-full hover:bg-white transition-colors'; + const actionIconClass = actionVariant === 'dark' ? 'w-5 h-5 text-white' : 'w-5 h-5 text-neutral-800'; + + const handlePhotoClick = (e: React.MouseEvent) => { + if (!touchAware) { + onClick(e); + return; + } + + if (isTouchDevice && !overlayVisible && !isSelectionMode) { + e.preventDefault(); + e.stopPropagation(); + showOverlayTemporarily(); + return; + } + + onClick(e); + if (isTouchDevice) { + hideOverlay(); + } + }; + + const handleLike = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + hideOverlay(); + return; + } + // Optimistic UI: mark as liked immediately + if (onLikeSuccess) onLikeSuccess(); + try { + await feedbackService.submitFeedback(slug!, String(photo.id), { + feedback_type: 'like', + }); + } catch (err) { + console.warn('Like submit failed, keeping optimistic UI', err); + } + if (onFeedbackChange) onFeedbackChange(); + hideOverlay(); + return; + } + if ( + feedbackOptions?.requireNameEmail && + !savedIdentityValue && + (identityMode === 'self' || onRequireIdentity) + ) { + if (identityMode === 'self') { + setPendingAction({ type: 'like', photoId: photo.id }); + setShowIdentityModal(true); + } else if (onRequireIdentity) { + onRequireIdentity('like', photo.id); + } + hideOverlay(); + return; + } + // Optimistic UI: mark as liked immediately + if (onLikeSuccess) onLikeSuccess(); + try { + await feedbackService.submitFeedback(slug!, String(photo.id), { + feedback_type: 'like', + guest_name: savedIdentityValue?.name, + guest_email: savedIdentityValue?.email, + }); + } catch (err) { + // Keep optimistic state; a refresh will reconcile + console.warn('Like submit failed, keeping optimistic UI', err); + } + if (onFeedbackChange) onFeedbackChange(); + hideOverlay(); + }; + + const commentButton = + showFeedbackActions && feedbackOptions?.allowComments && onQuickComment ? ( + + ) : null; + + const likeButton = + showFeedbackActions && feedbackOptions?.allowLikes ? ( + + ) : null; + + return ( +
+ {inView ? ( + <> + + + {beforeOverlay} + + {/* Hover Overlay */} +
+ {!isSelectionMode && ( + <> + + {allowDownloads && ( + + )} + {likeBeforeComment ? ( + <> + {likeButton} + {commentButton} + + ) : ( + <> + {commentButton} + {likeButton} + + )} + + )} +
+ + {/* Identity Modal (self-managed mode) */} + {identityMode === 'self' && ( + { setShowIdentityModal(false); setPendingAction(null); }} + onSubmit={async (name, email) => { + setSelfIdentity({ name, email }); + setShowIdentityModal(false); + if (pendingAction) { + if (pendingAction.type === 'like' && onLikeSuccess) { + onLikeSuccess(); + } + await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { + feedback_type: pendingAction.type, + guest_name: name, + guest_email: email, + }); + setPendingAction(null); + } + }} + feedbackType="like" + /> + )} + + {afterOverlay} + + {/* Selection Checkbox (visible on hover or when selected) */} + + + {children} + + ) : ( +
+ )} +
+ ); +}; diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 0d6365a2..7b50cd56 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, Eye, EyeOff } from 'lucide-react'; -import { useInView } from 'react-intersection-observer'; +import { MessageSquare, Star, Heart, Video, Eye, EyeOff } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../../../contexts/ThemeContext'; -import { AuthenticatedImage } from '../../common'; +import { PhotoCard } from '../PhotoCard'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; -import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -62,312 +60,107 @@ const GridPhoto: React.FC = ({ onLikeSuccess }) => { const { t } = useTranslation(); - const guestIdentity = useGuestIdentityOptional(); - const [overlayVisible, setOverlayVisible] = React.useState(false); - const [isTouchDevice, setIsTouchDevice] = React.useState(false); - const overlayTimeoutRef = React.useRef(null); - React.useEffect(() => { - if (typeof window === 'undefined') return; - - const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)'); - const updateTouchState = () => { - const hasNavigator = typeof navigator !== 'undefined'; - setIsTouchDevice( - mediaQuery.matches || - ('ontouchstart' in window) || - (hasNavigator && navigator.maxTouchPoints > 0) - ); - }; - - updateTouchState(); - - const listener = (event: MediaQueryListEvent) => { - setIsTouchDevice(event.matches); - }; - - if (mediaQuery.addEventListener) { - mediaQuery.addEventListener('change', listener); - } else if (mediaQuery.addListener) { - mediaQuery.addListener(listener); - } - - return () => { - if (mediaQuery.removeEventListener) { - mediaQuery.removeEventListener('change', listener); - } else if (mediaQuery.removeListener) { - mediaQuery.removeListener(listener); - } - }; - }, []); - - const hideOverlay = React.useCallback(() => { - if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { - window.clearTimeout(overlayTimeoutRef.current); - } - overlayTimeoutRef.current = null; - setOverlayVisible(false); - }, []); - - const showOverlayTemporarily = React.useCallback(() => { - setOverlayVisible(true); - if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { - window.clearTimeout(overlayTimeoutRef.current); - } - if (typeof window !== 'undefined') { - overlayTimeoutRef.current = window.setTimeout(() => { - overlayTimeoutRef.current = null; - setOverlayVisible(false); - }, 2500); - } - }, []); - - React.useEffect(() => { - return () => { - if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { - window.clearTimeout(overlayTimeoutRef.current); - } - }; - }, []); - - React.useEffect(() => { - if (isSelectionMode) { - hideOverlay(); - } - }, [isSelectionMode, hideOverlay]); - - // handled by parent layout; kept here for type completeness but not used - const { ref, inView } = useInView({ - triggerOnce: true, - threshold: 0.1, - }); - - const animationClass = animationType === 'scale' - ? 'transition-transform duration-300 hover:scale-105' + const animationClass = animationType === 'scale' + ? 'transition-transform duration-300 hover:scale-105' : animationType === 'fade' ? 'transition-opacity duration-300' : ''; const likeCount = photo.like_count ?? 0; const averageRating = photo.average_rating ?? 0; const commentCount = photo.comment_count ?? 0; - const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions); - - const overlayVisibilityClass = overlayVisible - ? 'opacity-100 md:opacity-100' - : 'opacity-0 md:opacity-0'; - - const checkboxVisibilityClass = - isSelected || isSelectionMode || overlayVisible - ? 'opacity-100 md:opacity-100' - : 'opacity-0 md:opacity-0'; const isVideo = (photo.media_type === 'video') || (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video'; - const handlePhotoClick = (e: React.MouseEvent) => { - if (isTouchDevice && !overlayVisible && !isSelectionMode) { - e.preventDefault(); - e.stopPropagation(); - showOverlayTemporarily(); - return; - } - - onClick(); - if (isTouchDevice) { - hideOverlay(); - } - }; - return ( -
{ + console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`); + }, }} + overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2" + allowDownloads={allowDownloads} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} + slug={slug} + onQuickComment={onQuickComment} + onFeedbackChange={onFeedbackChange} + liked={liked} + onLikeSuccess={onLikeSuccess} + savedIdentity={savedIdentity} + onRequireIdentity={onRequireIdentity} + checkboxTestId > - {inView ? ( - <> - { - console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`); - }} - /> - -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - {showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && ( - - )} - {/* Quick feedback actions */} - {showFeedbackActions && feedbackOptions?.allowLikes && ( - - )} - - )} -
- - {/* Selection Checkbox (visible on hover or when selected) */} - - - {/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */} - {(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && ( -
- {(likeCount > 0 || liked) && ( - - - - )} - {averageRating > 0 && ( - - - - )} - {commentCount > 0 && ( - - - - )} -
+ {/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */} + {(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && ( +
+ {(likeCount > 0 || liked) && ( + + + )} - - {isVideo && ( -
- - -
+ {averageRating > 0 && ( + + + )} - - {photo.type === 'collage' && ( -
- - Collage - -
+ {commentCount > 0 && ( + + + )} - - ) : ( -
+
)} -
+ + {isVideo && ( +
+ + +
+ )} + + {photo.type === 'collage' && ( +
+ + Collage + +
+ )} + ); }; diff --git a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx index a837c022..5268029a 100644 --- a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx @@ -1,14 +1,13 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, ChevronDown, Calendar, Clock } from 'lucide-react'; -import { useInView } from 'react-intersection-observer'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { MessageSquare, Star, Heart, Video, ChevronDown, Calendar, Clock } from 'lucide-react'; import { parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; +import { PhotoCard } from '../PhotoCard'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; -import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import { buildResourceUrl } from '../../../utils/url'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -82,88 +81,6 @@ const JustifiedPhoto: React.FC = ({ liked = false, onLikeSuccess, }) => { - const guestIdentity = useGuestIdentityOptional(); - const [overlayVisible, setOverlayVisible] = useState(false); - const [isTouchDevice, setIsTouchDevice] = useState(false); - const overlayTimeoutRef = useRef(null); - - // Detect touch device - useEffect(() => { - if (typeof window === 'undefined') return; - - const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)'); - const updateTouchState = () => { - const hasNavigator = typeof navigator !== 'undefined'; - setIsTouchDevice( - mediaQuery.matches || - 'ontouchstart' in window || - (hasNavigator && navigator.maxTouchPoints > 0) - ); - }; - - updateTouchState(); - - const listener = (event: MediaQueryListEvent) => { - setIsTouchDevice(event.matches); - }; - - if (mediaQuery.addEventListener) { - mediaQuery.addEventListener('change', listener); - } else if (mediaQuery.addListener) { - mediaQuery.addListener(listener); - } - - return () => { - if (mediaQuery.removeEventListener) { - mediaQuery.removeEventListener('change', listener); - } else if (mediaQuery.removeListener) { - mediaQuery.removeListener(listener); - } - }; - }, []); - - const hideOverlay = useCallback(() => { - if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { - window.clearTimeout(overlayTimeoutRef.current); - } - overlayTimeoutRef.current = null; - setOverlayVisible(false); - }, []); - - const showOverlayTemporarily = useCallback(() => { - setOverlayVisible(true); - if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { - window.clearTimeout(overlayTimeoutRef.current); - } - if (typeof window !== 'undefined') { - overlayTimeoutRef.current = window.setTimeout(() => { - overlayTimeoutRef.current = null; - setOverlayVisible(false); - }, 2500); - } - }, []); - - useEffect(() => { - return () => { - if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') { - window.clearTimeout(overlayTimeoutRef.current); - } - }; - }, []); - - useEffect(() => { - if (isSelectionMode) { - hideOverlay(); - } - }, [isSelectionMode, hideOverlay]); - - // Lazy loading with intersection observer - const { ref, inView } = useInView({ - triggerOnce: true, - threshold: 0.1, - rootMargin: '100px', - }); - const animationClass = animationType === 'scale' ? 'transition-transform duration-300 hover:scale-[1.02]' @@ -174,262 +91,128 @@ const JustifiedPhoto: React.FC = ({ const likeCount = photo.like_count ?? 0; const averageRating = photo.average_rating ?? 0; const commentCount = photo.comment_count ?? 0; - const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions); - - const overlayVisibilityClass = overlayVisible - ? 'opacity-100 md:opacity-100' - : 'opacity-0 md:opacity-0'; - - const checkboxVisibilityClass = - isSelected || isSelectionMode || overlayVisible - ? 'opacity-100 md:opacity-100' - : 'opacity-0 md:opacity-0'; const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video'; - const handlePhotoClick = (e: React.MouseEvent) => { - if (isTouchDevice && !overlayVisible && !isSelectionMode) { - e.preventDefault(); - e.stopPropagation(); - showOverlayTemporarily(); - return; - } - - onClick(); - if (isTouchDevice) { - hideOverlay(); - } - }; - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } + containerProps={{ + role: 'button', + tabIndex: 0, + 'aria-label': `View photo ${photo.filename}`, + onKeyDown: (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }, }} + lazy + inViewRootMargin="100px" + fadeInWhenVisible={animationType === 'fade'} + skeletonClassName="skeleton w-full h-full rounded-lg" + touchAware + imageProps={{ + src: photo.thumbnail_url || photo.url, + alt: photo.filename, + className: 'w-full h-full object-cover', + loading: 'lazy', + isGallery: true, + slug, + photoId: photo.id, + requiresToken: photo.requires_token, + secureUrlTemplate: photo.secure_url_template, + protectFromDownload: !allowDownloads || useEnhancedProtection, + protectionLevel, + useEnhancedProtection, + useCanvasRendering: useCanvasRendering || protectionLevel === 'maximum', + fragmentGrid: protectionLevel === 'enhanced' || protectionLevel === 'maximum', + blockKeyboardShortcuts: useEnhancedProtection, + detectPrintScreen: useEnhancedProtection, + detectDevTools: protectionLevel === 'maximum', + watermarkText: useEnhancedProtection ? 'Protected' : undefined, + onProtectionViolation: (violationType: string) => { + console.warn(`Protection violation on justified photo ${photo.id}: ${violationType}`); + }, + }} + overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 flex items-center justify-center gap-2" + allowDownloads={allowDownloads} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} + slug={slug} + onQuickComment={onQuickComment} + onFeedbackChange={onFeedbackChange} + liked={liked} + onLikeSuccess={onLikeSuccess} + savedIdentity={savedIdentity} + onRequireIdentity={onRequireIdentity} + checkboxTestId > - {inView ? ( - <> - { - console.warn(`Protection violation on justified photo ${photo.id}: ${violationType}`); - }} - /> - - {/* Hover Overlay */} -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - {showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && ( - - )} - {showFeedbackActions && feedbackOptions?.allowLikes && ( - - )} - - )} -
- - {/* Selection Checkbox */} - - - {/* Feedback Indicators */} - {(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && ( -
+ + )} + {averageRating > 0 && ( + - {(likeCount > 0 || liked) && ( - - - - )} - {averageRating > 0 && ( - - - - )} - {commentCount > 0 && ( - - - - )} -
+ + )} - - {/* Video Badge */} - {isVideo && ( -
- - -
+ {commentCount > 0 && ( + + + )} - - {/* Collage Badge */} - {photo.type === 'collage' && ( -
- Collage -
- )} - - ) : ( -
+
)} -
+ + {/* Video Badge */} + {isVideo && ( +
+ + +
+ )} + + {/* Collage Badge */} + {photo.type === 'collage' && ( +
+ Collage +
+ )} + ); }; diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index 7cea8d3c..fa617942 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -1,10 +1,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react'; -import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; +import { MessageSquare, Star, Heart } from 'lucide-react'; import { useTheme } from '../../../contexts/ThemeContext'; -import { AuthenticatedImage } from '../../common'; -import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; -import { feedbackService } from '../../../services/feedback.service'; -import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; +import { PhotoCard } from '../PhotoCard'; import { calculateJustifiedLayout, createJustifiedPhotos, @@ -15,6 +12,36 @@ import justifiedLayout from 'justified-layout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; +// Count-style feedback indicators shared by all masonry modes (top-left). +// `withTitles` matches the columns-mode markup, which carries title attributes. +const FeedbackCountIndicators: React.FC<{ photo: Photo; withTitles?: boolean }> = ({ photo, withTitles = false }) => { + if (!((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0)) { + return null; + } + return ( +
+ {(photo.comment_count ?? 0) > 0 && ( +
+ + {photo.comment_count ?? 0} +
+ )} + {(photo.average_rating ?? 0) > 0 && ( +
+ + {Number(photo.average_rating ?? 0).toFixed(1)} +
+ )} + {(photo.like_count ?? 0) > 0 && ( +
+ + {photo.like_count ?? 0} +
+ )} +
+ ); +}; + interface MasonryPhotoProps { photo: Photo; isSelected: boolean; @@ -56,11 +83,6 @@ const MasonryPhoto: React.FC = ({ liked = false, onLikeSuccess, }) => { - const [showIdentityModal, setShowIdentityModal] = useState(false); - const [pendingAction, setPendingAction] = useState(null); - const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); - const guestIdentity = useGuestIdentityOptional(); - // Calculate height based on actual photo aspect ratio // This preserves the photo's natural proportions in the masonry layout const imageHeight = useMemo(() => { @@ -79,177 +101,40 @@ const MasonryPhoto: React.FC = ({ }, [photo.width, photo.height, columnWidth]); return ( -
: undefined} > - - - {/* Feedback Indicators */} - {feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && ( -
- {(photo.comment_count ?? 0) > 0 && ( -
- - {photo.comment_count ?? 0} -
- )} - {(photo.average_rating ?? 0) > 0 && ( -
- - {Number(photo.average_rating ?? 0).toFixed(1)} -
- )} - {(photo.like_count ?? 0) > 0 && ( -
- - {photo.like_count ?? 0} -
- )} -
- )} - -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - {feedbackEnabled && feedbackOptions?.allowComments && onQuickComment && ( - - )} - {feedbackEnabled && feedbackOptions?.allowLikes && ( - - )} - - )} -
- - {/* Identity Modal */} - { setShowIdentityModal(false); setPendingAction(null); }} - onSubmit={async (name, email) => { - setSavedIdentity({ name, email }); - setShowIdentityModal(false); - if (pendingAction) { - if (pendingAction.type === 'like' && onLikeSuccess) { - onLikeSuccess(); - } - await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { - feedback_type: pendingAction.type, - guest_name: name, - guest_email: email, - }); - setPendingAction(null); - } - }} - feedbackType="like" - /> - - {/* Selection Checkbox (visible on hover or when selected) */} - - {photo.type === 'collage' && (
@@ -257,7 +142,7 @@ const MasonryPhoto: React.FC = ({
)} -
+ ); }; @@ -470,8 +355,14 @@ export const MasonryGalleryLayout: React.FC = ({ if (!layoutItem) return null; return ( -
onPhotoClick(index)} + onDownload={(e) => onDownload(photo, e)} + onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)} className="photo-card absolute group cursor-pointer transition-all duration-300 hover:z-10" style={{ left: layoutItem.x, @@ -479,83 +370,19 @@ export const MasonryGalleryLayout: React.FC = ({ width: layoutItem.width, height: layoutItem.height, }} - onClick={() => onPhotoClick(index)} - > - - - {/* Feedback Indicators */} - {feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && ( -
- {(photo.comment_count ?? 0) > 0 && ( -
- - {photo.comment_count ?? 0} -
- )} - {(photo.average_rating ?? 0) > 0 && ( -
- - {Number(photo.average_rating ?? 0).toFixed(1)} -
- )} - {(photo.like_count ?? 0) > 0 && ( -
- - {photo.like_count ?? 0} -
- )} -
- )} - - {/* Hover overlay with actions */} -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - - )} -
- - {/* Selection Checkbox */} - -
+ imageProps={{ + src: photo.thumbnail_url || photo.url, + alt: photo.filename, + className: 'w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]', + loading: 'lazy', + isGallery: true, + protectFromDownload: !allowDownloads, + }} + overlayBaseClassName="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" + actionVariant="dark" + allowDownloads={allowDownloads} + beforeOverlay={feedbackEnabled ? : undefined} + /> ); })}
@@ -586,8 +413,14 @@ export const MasonryGalleryLayout: React.FC = ({ if (!box) return null; return ( -
onPhotoClick(index)} + onDownload={(e) => onDownload(photo, e)} + onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)} className="photo-card absolute group cursor-pointer transition-all duration-300 hover:z-10" style={{ left: box.left, @@ -595,89 +428,25 @@ export const MasonryGalleryLayout: React.FC = ({ width: box.width, height: box.height, }} - onClick={() => onPhotoClick(index)} + imageProps={{ + src: photo.thumbnail_url || photo.url, + alt: photo.filename, + className: 'w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]', + loading: 'lazy', + isGallery: true, + protectFromDownload: !allowDownloads, + }} + overlayBaseClassName="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" + actionVariant="dark" + allowDownloads={allowDownloads} + beforeOverlay={feedbackEnabled ? : undefined} > - - - {/* Feedback Indicators */} - {feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && ( -
- {(photo.comment_count ?? 0) > 0 && ( -
- - {photo.comment_count ?? 0} -
- )} - {(photo.average_rating ?? 0) > 0 && ( -
- - {Number(photo.average_rating ?? 0).toFixed(1)} -
- )} - {(photo.like_count ?? 0) > 0 && ( -
- - {photo.like_count ?? 0} -
- )} -
- )} - - {/* Hover overlay with actions */} -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - - )} -
- - {/* Selection Checkbox */} - - {photo.type === 'collage' && (
Collage
)} -
+ ); })}
@@ -717,92 +486,34 @@ export const MasonryGalleryLayout: React.FC = ({ const spanClasses = getSpanClasses(photo); return ( -
onPhotoClick(index)} + onDownload={(e) => onDownload(photo, e)} + onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)} + className={`photo-card group cursor-pointer relative overflow-hidden rounded-lg bg-neutral-100 ${spanClasses}`} + imageProps={{ + src: photo.thumbnail_url || photo.url, + alt: photo.filename, + className: 'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105', + loading: 'lazy', + isGallery: true, + protectFromDownload: !allowDownloads, + }} + overlayBaseClassName="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2" + actionVariant="dark" + allowDownloads={allowDownloads} + beforeOverlay={feedbackEnabled ? : undefined} > - - - {/* Feedback Indicators */} - {feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && ( -
- {(photo.comment_count ?? 0) > 0 && ( -
- - {photo.comment_count ?? 0} -
- )} - {(photo.average_rating ?? 0) > 0 && ( -
- - {Number(photo.average_rating ?? 0).toFixed(1)} -
- )} - {(photo.like_count ?? 0) > 0 && ( -
- - {photo.like_count ?? 0} -
- )} -
- )} - - {/* Hover overlay with actions */} -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - - )} -
- - {/* Selection Checkbox */} - - {photo.type === 'collage' && (
Collage
)} -
+ ); })} diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index 1e2b06cf..ecef30f4 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react'; +import { Heart } from 'lucide-react'; import { useTheme } from '../../../contexts/ThemeContext'; -import { AuthenticatedImage } from '../../common'; +import { PhotoCard } from '../PhotoCard'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; -import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -54,141 +53,64 @@ const MosaicPhoto: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = React.useState(false); const [pendingAction, setPendingAction] = React.useState(null); const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); - const guestIdentity = useGuestIdentityOptional(); // Seed from server is_liked (#590 follow-up). useState's initializer // fires once on mount, so subsequent prop updates don't reseed. const [likedLocal, setLikedLocal] = React.useState(photo.is_liked ?? false); - const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment); // Calculate aspect ratio from photo dimensions (fallback to 1 if unknown) const aspectRatio = (photo.width && photo.height) ? photo.width / photo.height : 1; return ( <> -
{ + e.stopPropagation(); + onClick(e); + }} + onDownload={onDownload} + onToggleSelect={onToggleSelect} className="photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 mb-2" style={{ breakInside: 'avoid', aspectRatio: aspectRatio.toString() }} - onClick={(e) => { - e.stopPropagation(); - onClick(e); + imageProps={{ + src: photo.thumbnail_url || photo.url, + alt: photo.filename, + className: 'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105', + loading: 'lazy', + isGallery: true, + protectFromDownload: !allowDownloads, }} - > - - -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - {feedbackEnabled && feedbackOptions?.allowLikes && ( - - )} - {canComment && ( - - )} - - )} -
- - {/* Feedback Indicators (bottom-left) */} - {((photo.like_count ?? 0) > 0 || likedLocal) && ( + overlayBaseClassName="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2" + allowDownloads={allowDownloads} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} + slug={slug} + onQuickComment={onQuickComment} + liked={likedLocal} + onLikeSuccess={() => { + // Toggle — server /feedback like is a toggle (#590). + setLikedLocal(prev => !prev); + }} + savedIdentity={savedIdentity} + onRequireIdentity={(action, photoId) => { + setPendingAction({ type: action, photoId }); + setShowIdentityModal(true); + }} + likeBeforeComment + checkboxTestId + afterOverlay={((photo.like_count ?? 0) > 0 || likedLocal) ? (
- )} - - {/* Selection Checkbox (visible on hover or when selected) */} - - + ) : undefined} + > {photo.type === 'collage' && (
@@ -196,7 +118,7 @@ const MosaicPhoto: React.FC = ({
)} -
+ { setShowIdentityModal(false); setPendingAction(null); }} diff --git a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx index 20cebebc..63253138 100644 --- a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx @@ -1,13 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react'; +import { Calendar, Heart } from 'lucide-react'; import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns'; import { useTheme } from '../../../contexts/ThemeContext'; -import { AuthenticatedImage } from '../../common'; +import { PhotoCard } from '../PhotoCard'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; -import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; export const TimelineGalleryLayout: React.FC = ({ @@ -37,7 +36,6 @@ export const TimelineGalleryLayout: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); - const guestIdentity = useGuestIdentityOptional(); const gallerySettings = theme.gallerySettings || {}; const grouping = gallerySettings.timelineGrouping || 'day'; const showDates = gallerySettings.timelineShowDates !== false; @@ -110,141 +108,59 @@ export const TimelineGalleryLayout: React.FC = ({ {group.photos.map((photo) => { const actualIndex = photos.findIndex(p => p.id === photo.id); return ( -
onPhotoClick(actualIndex)} - > - - - {/* Time label */} -
- {fmtTime(photo.uploaded_at)} -
- -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( - - )} - {feedbackEnabled && feedbackOptions?.allowLikes && ( - - )} - {canQuickComment && ( - - )} - - )} -
- - {((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && ( + onDownload={(e) => onDownload(photo, e)} + onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)} + className="photo-card relative group cursor-pointer aspect-square" + imageProps={{ + src: photo.thumbnail_url || photo.url, + alt: photo.filename, + className: 'w-full h-full object-cover rounded-lg', + loading: 'lazy', + isGallery: true, + protectFromDownload: !allowDownloads, + }} + overlayBaseClassName="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" + allowDownloads={allowDownloads} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} + slug={slug} + onQuickComment={canQuickComment ? () => onOpenPhotoWithFeedback?.(actualIndex) : undefined} + liked={likedIds.has(photo.id)} + onLikeSuccess={() => { + // Toggle — server /feedback like is a toggle (#590). + setLikedIds(prev => { + const next = new Set(prev); + if (next.has(photo.id)) next.delete(photo.id); + else next.add(photo.id); + return next; + }); + }} + savedIdentity={savedIdentity} + onRequireIdentity={(action, photoId) => { + setPendingAction({ type: action, photoId }); + setShowIdentityModal(true); + }} + likeBeforeComment + checkboxTestId + beforeOverlay={ +
+ {fmtTime(photo.uploaded_at)} +
+ } + afterOverlay={((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) ? (
- )} - - {/* Selection Checkbox (visible on hover or when selected) */} - -
+ ) : undefined} + /> ); })}