feat: add justified layout modes and aspect-ratio-aware mosaic (#146)
- Add Flickr justified-layout and react-photo-album as masonry mode options - Implement aspect-ratio-aware mosaic layout that dynamically selects patterns based on photo orientations to minimize cropping - Add 9 mosaic pattern types optimized for different orientation combinations - Add theme customizer options for masonry mode selection (columns/rows/flickr/justified) - Add i18n translations for new layout options
This commit is contained in:
Generated
+29
@@ -27,6 +27,7 @@
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"justified-layout": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
@@ -38,6 +39,7 @@
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-image-gallery": "^1.2.11",
|
||||
"react-intersection-observer": "^9.4.3",
|
||||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-toastify": "11.0.5",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
@@ -4664,6 +4666,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/justified-layout": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz",
|
||||
"integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5813,6 +5821,27 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-photo-album": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz",
|
||||
"integrity": "sha512-pPaCoxEfVDhowpqxECq4SOD5kzP1Uao8PEN11Jasxayv4cZjad3Fy8SlKt6wvtLnVJRtOjsQDU/ZnnUuberwMg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/igordanchenko"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18 || ^19",
|
||||
"react": "^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"justified-layout": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-image-gallery": "^1.2.11",
|
||||
"react-intersection-observer": "^9.4.3",
|
||||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-toastify": "11.0.5",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
|
||||
@@ -380,15 +380,23 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="columns">{t('branding.masonryModeOptions.columns', 'Columns (Pinterest-style)')}</option>
|
||||
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Google Photos-style)')}</option>
|
||||
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Custom justified)')}</option>
|
||||
<option value="flickr">{t('branding.masonryModeOptions.flickr', 'Flickr (Battle-tested justified)')}</option>
|
||||
<option value="justified">{t('branding.masonryModeOptions.justified', 'Google Photos (Knuth-Plass algorithm)')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('branding.masonryModeHint', 'Columns arranges photos vertically, rows fills horizontal lines')}
|
||||
{localTheme.gallerySettings?.masonryMode === 'columns'
|
||||
? t('branding.masonryModeHint.columns', 'Pinterest-style vertical columns with varied heights')
|
||||
: localTheme.gallerySettings?.masonryMode === 'flickr'
|
||||
? t('branding.masonryModeHint.flickr', 'Flickr\'s open-source justified layout algorithm')
|
||||
: localTheme.gallerySettings?.masonryMode === 'justified'
|
||||
? t('branding.masonryModeHint.justified', 'Google Photos-style rows using Knuth-Plass algorithm for optimal breaks')
|
||||
: t('branding.masonryModeHint.rows', 'Custom row-based justified layout')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row-specific settings */}
|
||||
{localTheme.gallerySettings?.masonryMode === 'rows' && (
|
||||
{/* Row-specific settings - show for all row-based modes */}
|
||||
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
@@ -405,20 +413,23 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.lastRowBehavior', 'Last Row Alignment')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
|
||||
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
|
||||
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
|
||||
<option value="justify">{t('branding.lastRowOptions.justify', 'Justified (stretch)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Last row behavior - only for rows and flickr modes */}
|
||||
{['rows', 'flickr'].includes(localTheme.gallerySettings?.masonryMode || '') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.lastRowBehavior', 'Last Row Alignment')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
|
||||
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
|
||||
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
|
||||
<option value="justify">{t('branding.lastRowOptions.justify', 'Justified (stretch)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
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 { parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { buildResourceUrl } from '../../../utils/url';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import {
|
||||
calculateJustifiedLayout,
|
||||
createJustifiedPhotos,
|
||||
type JustifiedLayoutItem,
|
||||
} from '../../../utils/justifiedLayoutCalculator';
|
||||
|
||||
interface JustifiedGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||
// Hero section props (optional)
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
eventDate?: string;
|
||||
expiresAt?: string;
|
||||
heroPhotoOverride?: Photo | null;
|
||||
heroLogoVisible?: boolean;
|
||||
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
heroLogoPosition?: 'top' | 'center' | 'bottom';
|
||||
}
|
||||
|
||||
interface JustifiedPhotoProps {
|
||||
photo: Photo;
|
||||
layoutItem: JustifiedLayoutItem;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: () => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
onToggleSelect: () => void;
|
||||
animationType?: string;
|
||||
allowDownloads?: boolean;
|
||||
slug?: string;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
feedbackEnabled?: boolean;
|
||||
feedbackOptions?: {
|
||||
allowLikes?: boolean;
|
||||
allowRatings?: boolean;
|
||||
allowComments?: boolean;
|
||||
requireNameEmail?: boolean;
|
||||
};
|
||||
savedIdentity?: { name: string; email: string } | null;
|
||||
onRequireIdentity?: (action: 'like', photoId: number) => void;
|
||||
onQuickComment?: () => void;
|
||||
onFeedbackChange?: () => void;
|
||||
liked?: boolean;
|
||||
onLikeSuccess?: () => void;
|
||||
}
|
||||
|
||||
const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
photo,
|
||||
layoutItem,
|
||||
isSelected,
|
||||
isSelectionMode,
|
||||
onClick,
|
||||
onDownload,
|
||||
onToggleSelect,
|
||||
animationType = 'fade',
|
||||
allowDownloads = true,
|
||||
slug,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
savedIdentity,
|
||||
onRequireIdentity,
|
||||
onQuickComment,
|
||||
onFeedbackChange,
|
||||
liked = false,
|
||||
onLikeSuccess,
|
||||
}) => {
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
const overlayTimeoutRef = useRef<number | null>(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]'
|
||||
: 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<HTMLDivElement>) => {
|
||||
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showOverlayTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
onClick();
|
||||
if (isTouchDevice) {
|
||||
hideOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`photo-card absolute group cursor-pointer overflow-hidden rounded-lg ${animationClass}`}
|
||||
style={{
|
||||
top: layoutItem.y,
|
||||
left: layoutItem.x,
|
||||
width: layoutItem.width,
|
||||
height: layoutItem.height,
|
||||
opacity: !inView && animationType === 'fade' ? 0 : 1,
|
||||
}}
|
||||
onClick={handlePhotoClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View photo ${photo.filename}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{inView ? (
|
||||
<>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={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}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hover Overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity duration-200 flex items-center justify-center gap-2 ${overlayVisibilityClass} md:group-hover:opacity-100`}
|
||||
>
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
{allowDownloads && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(e);
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onQuickComment();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Comment on photo"
|
||||
title="Comment"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{showFeedbackActions && feedbackOptions?.allowLikes && (
|
||||
<button
|
||||
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);
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Like photo"
|
||||
aria-pressed={liked}
|
||||
title="Like"
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Select ${photo.filename}`}
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full border-2 ${
|
||||
isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'
|
||||
} flex items-center justify-center transition-colors`}
|
||||
>
|
||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && (
|
||||
<div
|
||||
className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}
|
||||
>
|
||||
{(likeCount > 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>
|
||||
)}
|
||||
{averageRating > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm"
|
||||
title="Rated"
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{commentCount > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm"
|
||||
title="Commented"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Badge */}
|
||||
{isVideo && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
|
||||
<Video className="w-3 h-3" />
|
||||
Video
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collage Badge */}
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="skeleton w-full h-full rounded-lg" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
onPhotoClick,
|
||||
onOpenPhotoWithFeedback,
|
||||
onFeedbackChange,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect,
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
// Hero props
|
||||
eventName,
|
||||
eventLogo,
|
||||
eventDate,
|
||||
expiresAt,
|
||||
heroPhotoOverride,
|
||||
heroLogoVisible = true,
|
||||
heroLogoSize = 'medium',
|
||||
heroLogoPosition = 'top',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||
const [hasHeroInitialized, setHasHeroInitialized] = useState(false);
|
||||
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const spacing = gallerySettings.spacing || 'normal';
|
||||
const animation = gallerySettings.photoAnimation || 'fade';
|
||||
const targetRowHeight = gallerySettings.justifiedRowHeight || 250;
|
||||
const lastRowBehavior = gallerySettings.justifiedLastRowBehavior || 'left';
|
||||
const showHero = gallerySettings.justifiedShowHero || false;
|
||||
const heroHeight = gallerySettings.justifiedHeroHeight || 'medium';
|
||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||
|
||||
// Helper function to get hero height classes
|
||||
const getHeroHeightClass = (height: string): string => {
|
||||
switch (height) {
|
||||
case 'small':
|
||||
return 'h-[40vh] sm:h-[50vh]';
|
||||
case 'medium':
|
||||
return 'h-[50vh] sm:h-[60vh] lg:h-[70vh]';
|
||||
case 'large':
|
||||
return 'h-[60vh] sm:h-[70vh] lg:h-[80vh]';
|
||||
default:
|
||||
return 'h-[50vh] sm:h-[60vh] lg:h-[70vh]';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get logo size classes
|
||||
const getLogoSizeClasses = (size: string): string => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'h-12 sm:h-14 lg:h-16';
|
||||
case 'medium':
|
||||
return 'h-20 sm:h-24 lg:h-32';
|
||||
case 'large':
|
||||
return 'h-28 sm:h-32 lg:h-40';
|
||||
case 'xlarge':
|
||||
return 'h-36 sm:h-40 lg:h-48';
|
||||
default:
|
||||
return 'h-20 sm:h-24 lg:h-32';
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize hero photo when hero is enabled
|
||||
useEffect(() => {
|
||||
if (!showHero) return;
|
||||
|
||||
if (heroPhotoOverride) {
|
||||
setHeroPhoto(heroPhotoOverride);
|
||||
setHasHeroInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (photos.length > 0) {
|
||||
const heroId = gallerySettings.heroImageId;
|
||||
if (heroId) {
|
||||
const adminSelectedHero = photos.find(p => p.id === heroId);
|
||||
if (adminSelectedHero) {
|
||||
setHeroPhoto(adminSelectedHero);
|
||||
setHasHeroInitialized(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasHeroInitialized) {
|
||||
setHeroPhoto(photos[0]);
|
||||
setHasHeroInitialized(true);
|
||||
}
|
||||
}
|
||||
}, [showHero, photos, gallerySettings.heroImageId, hasHeroInitialized, heroPhotoOverride]);
|
||||
|
||||
// Get spacing value in pixels
|
||||
const spacingPixels = spacing === 'tight' ? 8 : spacing === 'relaxed' ? 24 : 16;
|
||||
|
||||
// Identity modal state
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(
|
||||
null
|
||||
);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
// Track container width with ResizeObserver
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const width = container.offsetWidth;
|
||||
if (width > 0) {
|
||||
setContainerWidth(width);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial measurement
|
||||
updateWidth();
|
||||
|
||||
// Use ResizeObserver for responsive updates
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate layout whenever photos or container width changes
|
||||
const layoutResult = useMemo(() => {
|
||||
if (containerWidth <= 0 || photos.length === 0) {
|
||||
return { items: [], containerHeight: 0, rowCount: 0 };
|
||||
}
|
||||
|
||||
// Convert photos to justified format
|
||||
const justifiedPhotos = createJustifiedPhotos(
|
||||
photos.map((p) => ({
|
||||
id: p.id,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
}))
|
||||
);
|
||||
|
||||
return calculateJustifiedLayout(justifiedPhotos, {
|
||||
containerWidth,
|
||||
targetRowHeight,
|
||||
spacing: spacingPixels,
|
||||
lastRowBehavior,
|
||||
});
|
||||
}, [photos, containerWidth, targetRowHeight, spacingPixels, lastRowBehavior]);
|
||||
|
||||
// Create a map for quick lookup of layout items by photo ID
|
||||
const layoutItemMap = useMemo(() => {
|
||||
const map = new Map<number, JustifiedLayoutItem>();
|
||||
for (const item of layoutResult.items) {
|
||||
map.set(item.photoId, item);
|
||||
}
|
||||
return map;
|
||||
}, [layoutResult.items]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero Section (optional) */}
|
||||
{showHero && heroPhoto && (
|
||||
<div className="relative -mt-6 mb-8">
|
||||
<div className={`relative ${getHeroHeightClass(heroHeight)} -mx-4 sm:-mx-6 lg:-mx-8`}>
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.url}
|
||||
fallbackSrc={heroPhoto.thumbnail_url || undefined}
|
||||
alt={heroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={heroPhoto.id}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black"
|
||||
style={{ opacity: overlayOpacity }}
|
||||
/>
|
||||
|
||||
{/* Hero Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
{/* Logo at top position */}
|
||||
{heroLogoVisible && heroLogoPosition === 'top' && eventLogo && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={buildResourceUrl(eventLogo)}
|
||||
alt="Event logo"
|
||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Title */}
|
||||
{eventName && (
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
|
||||
{eventName}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Logo at center position */}
|
||||
{heroLogoVisible && heroLogoPosition === 'center' && eventLogo && (
|
||||
<div className="my-6">
|
||||
<img
|
||||
src={buildResourceUrl(eventLogo)}
|
||||
alt="Event logo"
|
||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Dates */}
|
||||
{(eventDate || expiresAt) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
|
||||
{eventDate && (
|
||||
<span className="flex items-center text-lg sm:text-xl">
|
||||
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
||||
{format(parseISO(eventDate), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
{expiresAt && (
|
||||
<span className="flex items-center text-lg sm:text-xl">
|
||||
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
||||
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo at bottom position */}
|
||||
{heroLogoVisible && heroLogoPosition === 'bottom' && eventLogo && (
|
||||
<div className="mt-6">
|
||||
<img
|
||||
src={buildResourceUrl(eventLogo)}
|
||||
alt="Event logo"
|
||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const gridSection = document.getElementById('justified-gallery-grid');
|
||||
if (gridSection) {
|
||||
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollBy({ top: window.innerHeight * 0.7, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
|
||||
aria-label="Scroll to gallery"
|
||||
>
|
||||
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Justified Photo Grid */}
|
||||
<div
|
||||
id="justified-gallery-grid"
|
||||
ref={containerRef}
|
||||
className="photo-grid relative"
|
||||
style={{ height: layoutResult.containerHeight }}
|
||||
>
|
||||
{photos.map((photo, index) => {
|
||||
const layoutItem = layoutItemMap.get(photo.id);
|
||||
if (!layoutItem) return null;
|
||||
|
||||
return (
|
||||
<JustifiedPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
layoutItem={layoutItem}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => onPhotoClick(index)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
animationType={animation}
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
savedIdentity={savedIdentity}
|
||||
onRequireIdentity={(action, photoId) => {
|
||||
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
|
||||
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,
|
||||
});
|
||||
if (pendingAction.type === 'like') {
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pendingAction.photoId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
feedbackType="like"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
createJustifiedPhotos,
|
||||
type JustifiedLayoutItem,
|
||||
} from '../../../utils/justifiedLayoutCalculator';
|
||||
// Flickr's justified-layout library
|
||||
import justifiedLayout from 'justified-layout';
|
||||
// React Photo Album for Google Photos-style layout
|
||||
import { RowsPhotoAlbum, RenderPhotoContext } from 'react-photo-album';
|
||||
import 'react-photo-album/rows.css';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -295,6 +300,48 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
return map;
|
||||
}, [rowsLayout.items]);
|
||||
|
||||
// Calculate Flickr justified layout
|
||||
const flickrLayout = useMemo(() => {
|
||||
if (mode !== 'flickr' || containerWidth <= 0 || photos.length === 0) {
|
||||
return { containerHeight: 0, boxes: [] };
|
||||
}
|
||||
|
||||
// Convert photos to aspect ratios array
|
||||
const aspectRatios = photos.map((p) => {
|
||||
if (p.width && p.height && p.width > 0 && p.height > 0) {
|
||||
return p.width / p.height;
|
||||
}
|
||||
return 1; // Default to square if no dimensions
|
||||
});
|
||||
|
||||
const result = justifiedLayout(aspectRatios, {
|
||||
containerWidth,
|
||||
targetRowHeight,
|
||||
boxSpacing: gutter,
|
||||
containerPadding: 0,
|
||||
targetRowHeightTolerance: 0.25,
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [mode, photos, containerWidth, targetRowHeight, gutter]);
|
||||
|
||||
// Prepare photos for react-photo-album (justified mode)
|
||||
const albumPhotos = useMemo(() => {
|
||||
if (mode !== 'justified' || photos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return photos.map((photo, index) => ({
|
||||
src: photo.thumbnail_url || photo.url,
|
||||
width: photo.width || 800,
|
||||
height: photo.height || 600,
|
||||
key: `photo-${photo.id}`,
|
||||
// Store original data for click handling
|
||||
originalIndex: index,
|
||||
photoData: photo,
|
||||
}));
|
||||
}, [mode, photos]);
|
||||
|
||||
// Distribute photos across columns (for columns mode)
|
||||
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
|
||||
if (mode === 'columns') {
|
||||
@@ -421,6 +468,253 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// FLICKR MODE - Flickr's justified-layout algorithm
|
||||
if (mode === 'flickr') {
|
||||
const isCalculating = containerWidth <= 0 || flickrLayout.boxes.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="photo-grid relative"
|
||||
style={{
|
||||
height: isCalculating ? 'auto' : flickrLayout.containerHeight,
|
||||
minHeight: isCalculating ? 200 : undefined
|
||||
}}
|
||||
>
|
||||
{isCalculating ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{photos.slice(0, 8).map((photo) => (
|
||||
<div key={photo.id} className="aspect-square bg-neutral-200 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : photos.map((photo, index) => {
|
||||
const box = flickrLayout.boxes[index];
|
||||
if (!box) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="photo-card absolute group cursor-pointer transition-all duration-300 hover:z-10"
|
||||
style={{
|
||||
left: box.left,
|
||||
top: box.top,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
}}
|
||||
onClick={() => onPhotoClick(index)}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(photo.like_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.like_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay with actions */}
|
||||
<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">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View full size"
|
||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onPhotoClick(index); }}
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
{allowDownloads && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download photo"
|
||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDownload(photo, e); }}
|
||||
>
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Select ${photo.filename}`}
|
||||
role="checkbox"
|
||||
aria-checked={selectedPhotos.has(photo.id)}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// JUSTIFIED MODE - React Photo Album (Google Photos style with Knuth-Plass algorithm)
|
||||
if (mode === 'justified') {
|
||||
// Custom render function for photos in react-photo-album
|
||||
// The render function receives (props, context) where context contains photo, index, width, height
|
||||
const renderPhoto = useCallback((_props: { onClick?: React.MouseEventHandler }, context: RenderPhotoContext<typeof albumPhotos[0]>) => {
|
||||
const { photo, width, height } = context;
|
||||
const photoData = photo.photoData;
|
||||
const originalIndex = photo.originalIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width, height }}
|
||||
className="photo-card group cursor-pointer transition-all duration-300 hover:z-10 relative"
|
||||
onClick={() => onPhotoClick(originalIndex)}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photoData.thumbnail_url || photoData.url}
|
||||
alt={photoData.filename}
|
||||
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
protectFromDownload={!allowDownloads}
|
||||
/>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{feedbackEnabled && ((photoData.comment_count ?? 0) > 0 || (photoData.average_rating ?? 0) > 0 || (photoData.like_count ?? 0) > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(photoData.comment_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photoData.comment_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{(photoData.average_rating ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photoData.average_rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(photoData.like_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photoData.like_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay with actions */}
|
||||
<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">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View full size"
|
||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onPhotoClick(originalIndex); }}
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
{allowDownloads && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download photo"
|
||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDownload(photoData, e); }}
|
||||
>
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Select ${photoData.filename}`}
|
||||
role="checkbox"
|
||||
aria-checked={selectedPhotos.has(photoData.id)}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||
selectedPhotos.has(photoData.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photoData.id); }}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photoData.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||
{selectedPhotos.has(photoData.id) && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{photoData.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [onPhotoClick, onDownload, allowDownloads, feedbackEnabled, isSelectionMode, selectedPhotos, onPhotoSelect]);
|
||||
|
||||
if (albumPhotos.length === 0) {
|
||||
return (
|
||||
<div ref={containerRef} className="photo-grid">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{photos.slice(0, 8).map((photo) => (
|
||||
<div key={photo.id} className="aspect-square bg-neutral-200 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="photo-grid">
|
||||
<RowsPhotoAlbum
|
||||
photos={albumPhotos}
|
||||
targetRowHeight={targetRowHeight}
|
||||
rowConstraints={{ minPhotos: 1, maxPhotos: 6 }}
|
||||
spacing={gutter}
|
||||
render={{ photo: renderPhoto }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// COLUMNS MODE - Pinterest style masonry (default)
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
|
||||
// import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
@@ -7,6 +7,102 @@ import { feedbackService } from '../../../services/feedback.service';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
// Orientation types for aspect-ratio-aware layout
|
||||
type Orientation = 'landscape' | 'portrait' | 'square';
|
||||
|
||||
interface PhotoWithIndex {
|
||||
photo: Photo;
|
||||
originalIndex: number;
|
||||
orientation: Orientation;
|
||||
}
|
||||
|
||||
// Get photo orientation based on aspect ratio
|
||||
const getOrientation = (photo: Photo): Orientation => {
|
||||
const width = photo.width || 1;
|
||||
const height = photo.height || 1;
|
||||
const ratio = width / height;
|
||||
|
||||
if (ratio > 1.2) return 'landscape';
|
||||
if (ratio < 0.83) return 'portrait';
|
||||
return 'square';
|
||||
};
|
||||
|
||||
// Pattern types that work well with different orientation combinations
|
||||
type PatternType =
|
||||
| 'tall-left-2-right' // Tall photo left, 2 stacked right (good for 1 portrait + 2 landscape)
|
||||
| 'tall-right-2-left' // Tall photo right, 2 stacked left (good for 1 portrait + 2 landscape)
|
||||
| 'wide-top-2-bottom' // Wide photo top, 2 below (good for 1 landscape + 2 portrait)
|
||||
| 'wide-bottom-2-top' // Wide photo bottom, 2 above (good for 1 landscape + 2 portrait)
|
||||
| 'three-columns' // 3 equal columns (good for similar orientations)
|
||||
| 'three-rows' // 3 equal rows (good for landscapes)
|
||||
| 'two-portraits' // 2 tall side by side (good for portraits)
|
||||
| 'hero-wide' // Single wide landscape hero
|
||||
| 'hero-tall'; // Single tall portrait hero
|
||||
|
||||
// Analyze a group of photos and select the best pattern
|
||||
const selectBestPattern = (photosWithIndex: PhotoWithIndex[]): { pattern: PatternType; arranged: PhotoWithIndex[] } => {
|
||||
const count = photosWithIndex.length;
|
||||
|
||||
if (count === 1) {
|
||||
const orientation = photosWithIndex[0].orientation;
|
||||
return {
|
||||
pattern: orientation === 'portrait' ? 'hero-tall' : 'hero-wide',
|
||||
arranged: photosWithIndex
|
||||
};
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
const portraits = photosWithIndex.filter(p => p.orientation === 'portrait');
|
||||
const landscapes = photosWithIndex.filter(p => p.orientation === 'landscape');
|
||||
|
||||
if (portraits.length === 2) {
|
||||
return { pattern: 'two-portraits', arranged: photosWithIndex };
|
||||
}
|
||||
// For 2 photos, treat as part of a larger pattern or use columns
|
||||
return { pattern: 'three-columns', arranged: photosWithIndex };
|
||||
}
|
||||
|
||||
if (count >= 3) {
|
||||
const portraits = photosWithIndex.filter(p => p.orientation === 'portrait');
|
||||
const landscapes = photosWithIndex.filter(p => p.orientation === 'landscape');
|
||||
const squares = photosWithIndex.filter(p => p.orientation === 'square');
|
||||
|
||||
// All or mostly portraits - use vertical-friendly layout
|
||||
if (portraits.length >= 2) {
|
||||
if (landscapes.length >= 1) {
|
||||
// 2 portraits + 1 landscape: landscape on top, portraits below
|
||||
const arranged = [...landscapes.slice(0, 1), ...portraits.slice(0, 2)];
|
||||
return { pattern: 'wide-top-2-bottom', arranged };
|
||||
}
|
||||
// All portraits - stack them or use 3 columns
|
||||
return { pattern: 'three-columns', arranged: photosWithIndex.slice(0, 3) };
|
||||
}
|
||||
|
||||
// All or mostly landscapes - use horizontal-friendly layout
|
||||
if (landscapes.length >= 2) {
|
||||
if (portraits.length >= 1) {
|
||||
// 1 portrait + 2 landscapes: portrait on left, landscapes stacked right
|
||||
const arranged = [...portraits.slice(0, 1), ...landscapes.slice(0, 2)];
|
||||
return { pattern: 'tall-left-2-right', arranged };
|
||||
}
|
||||
// All landscapes - use rows
|
||||
return { pattern: 'three-rows', arranged: photosWithIndex.slice(0, 3) };
|
||||
}
|
||||
|
||||
// Mixed or mostly squares - use standard patterns with smart placement
|
||||
if (portraits.length === 1 && landscapes.length === 1) {
|
||||
// 1 portrait + 1 landscape + 1 square
|
||||
const arranged = [...portraits, ...squares.slice(0, 1), ...landscapes];
|
||||
return { pattern: 'tall-left-2-right', arranged: arranged.slice(0, 3) };
|
||||
}
|
||||
|
||||
// Default to 3 columns for mixed content
|
||||
return { pattern: 'three-columns', arranged: photosWithIndex.slice(0, 3) };
|
||||
}
|
||||
|
||||
return { pattern: 'three-columns', arranged: photosWithIndex };
|
||||
};
|
||||
|
||||
interface MosaicPhotoProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
@@ -200,219 +296,184 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// const gallerySettings = theme.gallerySettings || {};
|
||||
// const pattern = gallerySettings.mosaicPattern || 'structured';
|
||||
|
||||
const handlePhotoClick = (index: number, photoId: number) => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photoId);
|
||||
} else {
|
||||
onPhotoClick(index);
|
||||
// Pre-compute photos with their orientations
|
||||
const photosWithOrientations = useMemo(() => {
|
||||
return photos.map((photo, index) => ({
|
||||
photo,
|
||||
originalIndex: index,
|
||||
orientation: getOrientation(photo)
|
||||
}));
|
||||
}, [photos]);
|
||||
|
||||
// Helper to render a MosaicPhoto with common props
|
||||
const renderMosaicPhoto = (photoWithIndex: PhotoWithIndex, className: string = '') => {
|
||||
const { photo, originalIndex } = photoWithIndex;
|
||||
return (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photo.id);
|
||||
} else {
|
||||
onPhotoClick(originalIndex);
|
||||
}
|
||||
}}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
className={className}
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
onQuickComment={() => {
|
||||
if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) {
|
||||
onOpenPhotoWithFeedback(originalIndex);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render pattern based on type and arranged photos
|
||||
const renderPattern = (pattern: PatternType, arranged: PhotoWithIndex[], keyPrefix: string) => {
|
||||
switch (pattern) {
|
||||
case 'tall-left-2-right':
|
||||
// Portrait/tall photo on left, 2 landscape/square stacked on right
|
||||
return (
|
||||
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
||||
{arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'tall-right-2-left':
|
||||
// 2 landscape/square stacked on left, portrait/tall on right
|
||||
return (
|
||||
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
||||
</div>
|
||||
{arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'wide-top-2-bottom':
|
||||
// Wide landscape on top, 2 photos below
|
||||
return (
|
||||
<div key={keyPrefix} className="grid grid-rows-2 gap-2 mb-2 h-[450px]">
|
||||
<div className="h-[250px]">
|
||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 h-[192px]">
|
||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'wide-bottom-2-top':
|
||||
// 2 photos on top, wide landscape below
|
||||
return (
|
||||
<div key={keyPrefix} className="grid grid-rows-2 gap-2 mb-2 h-[450px]">
|
||||
<div className="grid grid-cols-2 gap-2 h-[192px]">
|
||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
||||
</div>
|
||||
<div className="h-[250px]">
|
||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'three-rows':
|
||||
// 3 horizontal rows - good for all landscapes
|
||||
return (
|
||||
<div key={keyPrefix} className="grid grid-rows-3 gap-2 mb-2 h-[500px]">
|
||||
{arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'two-portraits':
|
||||
// 2 side-by-side tall photos
|
||||
return (
|
||||
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[500px]">
|
||||
{arranged.slice(0, 2).map((p) => renderMosaicPhoto(p))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'hero-wide':
|
||||
// Single wide hero image
|
||||
return (
|
||||
<div key={keyPrefix} className="mb-2 h-[350px]">
|
||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'hero-tall':
|
||||
// Single tall hero image
|
||||
return (
|
||||
<div key={keyPrefix} className="mb-2 h-[500px] max-w-md mx-auto">
|
||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'three-columns':
|
||||
default:
|
||||
// 3 equal columns - adaptive height based on content
|
||||
const hasPortrait = arranged.some(p => p.orientation === 'portrait');
|
||||
const height = hasPortrait ? 'h-[350px]' : 'h-[250px]';
|
||||
return (
|
||||
<div key={keyPrefix} className={`grid grid-cols-3 gap-2 mb-2 ${height}`}>
|
||||
{arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a more structured mosaic layout
|
||||
// Create aspect-ratio-aware mosaic layout
|
||||
const renderMosaicLayout = () => {
|
||||
const elements = [];
|
||||
let photoIndex = 0;
|
||||
let patternIndex = 0;
|
||||
|
||||
while (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.length - photoIndex;
|
||||
|
||||
// Choose pattern based on rotation and remaining photos
|
||||
if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
|
||||
// Pattern 1: Large left, 2 small right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => onPhotoClick(idx0)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
||||
className="col-span-1"
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
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}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
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}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
|
||||
// Pattern 2: 3 equal columns
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[250px]">
|
||||
{[0, 1, 2].map(offset => {
|
||||
const currentIndex = photoIndex + offset;
|
||||
const photo = photos[currentIndex];
|
||||
return photo ? (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
className=""
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(currentIndex); }}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
|
||||
// Pattern 3: Large span-2 with 2 small on right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => onPhotoClick(idx0)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
||||
className="col-span-2"
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
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}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
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}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else {
|
||||
// Handle remaining photos that don't fit patterns
|
||||
break;
|
||||
const elements: React.ReactNode[] = [];
|
||||
let index = 0;
|
||||
let patternCount = 0;
|
||||
|
||||
while (index < photosWithOrientations.length) {
|
||||
const remaining = photosWithOrientations.length - index;
|
||||
|
||||
// Determine group size based on remaining photos
|
||||
let groupSize = 3;
|
||||
if (remaining === 1) groupSize = 1;
|
||||
else if (remaining === 2) groupSize = 2;
|
||||
else if (remaining === 4) groupSize = 2; // Split 4 into 2+2 for balance
|
||||
else groupSize = 3;
|
||||
|
||||
// Get the next group of photos
|
||||
const group = photosWithOrientations.slice(index, index + groupSize);
|
||||
|
||||
// Select the best pattern for this group based on orientations
|
||||
const { pattern, arranged } = selectBestPattern(group);
|
||||
|
||||
// Alternate some patterns for visual variety
|
||||
let finalPattern = pattern;
|
||||
if (pattern === 'tall-left-2-right' && patternCount % 2 === 1) {
|
||||
finalPattern = 'tall-right-2-left';
|
||||
} else if (pattern === 'wide-top-2-bottom' && patternCount % 2 === 1) {
|
||||
finalPattern = 'wide-bottom-2-top';
|
||||
}
|
||||
|
||||
patternIndex++;
|
||||
|
||||
// Render the pattern
|
||||
elements.push(renderPattern(finalPattern, arranged, `pattern-${index}`));
|
||||
|
||||
index += groupSize;
|
||||
patternCount++;
|
||||
}
|
||||
|
||||
// Add remaining photos in a regular grid
|
||||
if (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.slice(photoIndex);
|
||||
elements.push(
|
||||
<div key={`remaining-${photoIndex}`} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{remainingPhotos.map((photo, idx) => {
|
||||
const index = photoIndex + idx;
|
||||
return (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => onPhotoClick(index)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
className="aspect-square"
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(index); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
|
||||
@@ -1290,9 +1290,16 @@
|
||||
"masonryMode": "Layout-Modus",
|
||||
"masonryModeOptions": {
|
||||
"columns": "Spalten (Pinterest-Stil)",
|
||||
"rows": "Zeilen (Google Fotos-Stil)"
|
||||
"rows": "Zeilen (Eigenes Layout)",
|
||||
"flickr": "Flickr (Bewährtes Layout)",
|
||||
"justified": "Google Fotos (Knuth-Plass Algorithmus)"
|
||||
},
|
||||
"masonryModeHint": {
|
||||
"columns": "Pinterest-ähnliche vertikale Spalten mit variierenden Höhen",
|
||||
"rows": "Eigenes zeilenbasiertes Layout",
|
||||
"flickr": "Flickrs Open-Source Justified-Layout Algorithmus",
|
||||
"justified": "Google Fotos-ähnliche Zeilen mit Knuth-Plass Algorithmus für optimale Umbrüche"
|
||||
},
|
||||
"masonryModeHint": "Spalten ordnet Fotos vertikal an, Zeilen füllt horizontale Linien",
|
||||
"targetRowHeight": "Ziel-Zeilenhöhe",
|
||||
"targetRowHeightHint": "Höhe in Pixeln (150-400). Fotos werden skaliert, um in Zeilen zu passen.",
|
||||
"lastRowBehavior": "Letzte Zeile Ausrichtung",
|
||||
|
||||
@@ -1005,9 +1005,16 @@
|
||||
"masonryMode": "Layout Mode",
|
||||
"masonryModeOptions": {
|
||||
"columns": "Columns (Pinterest-style)",
|
||||
"rows": "Rows (Google Photos-style)"
|
||||
"rows": "Rows (Custom justified)",
|
||||
"flickr": "Flickr (Battle-tested justified)",
|
||||
"justified": "Google Photos (Knuth-Plass algorithm)"
|
||||
},
|
||||
"masonryModeHint": {
|
||||
"columns": "Pinterest-style vertical columns with varied heights",
|
||||
"rows": "Custom row-based justified layout",
|
||||
"flickr": "Flickr's open-source justified layout algorithm",
|
||||
"justified": "Google Photos-style rows using Knuth-Plass algorithm for optimal breaks"
|
||||
},
|
||||
"masonryModeHint": "Columns arranges photos vertically, rows fills horizontal lines",
|
||||
"targetRowHeight": "Target Row Height",
|
||||
"targetRowHeightHint": "Height in pixels (150-400). Photos will scale to fit rows.",
|
||||
"lastRowBehavior": "Last Row Alignment",
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
declare module 'justified-layout' {
|
||||
interface JustifiedLayoutOptions {
|
||||
containerWidth: number;
|
||||
containerPadding?: number | { top: number; right: number; bottom: number; left: number };
|
||||
boxSpacing?: number | { horizontal: number; vertical: number };
|
||||
targetRowHeight?: number;
|
||||
targetRowHeightTolerance?: number;
|
||||
maxNumRows?: number;
|
||||
forceAspectRatio?: boolean | number;
|
||||
showWidows?: boolean;
|
||||
fullWidthBreakoutRowCadence?: boolean | number;
|
||||
widowLayoutStyle?: 'left' | 'center' | 'justify';
|
||||
}
|
||||
|
||||
interface Box {
|
||||
aspectRatio: number;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
forcedAspectRatio?: boolean;
|
||||
}
|
||||
|
||||
interface JustifiedLayoutResult {
|
||||
containerHeight: number;
|
||||
widowCount: number;
|
||||
boxes: Box[];
|
||||
}
|
||||
|
||||
type InputItem = number | { width: number; height: number };
|
||||
|
||||
function justifiedLayout(
|
||||
input: InputItem[],
|
||||
options?: JustifiedLayoutOptions
|
||||
): JustifiedLayoutResult;
|
||||
|
||||
export = justifiedLayout;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface GalleryLayoutSettings {
|
||||
};
|
||||
|
||||
// Masonry specific
|
||||
masonryMode?: 'columns' | 'rows'; // columns = Pinterest-style, rows = Google Photos-style
|
||||
masonryMode?: 'columns' | 'rows' | 'flickr' | 'justified'; // columns = Pinterest-style, rows = custom rows, flickr = Flickr justified-layout, justified = react-photo-album (Google Photos style)
|
||||
masonryGutter?: number;
|
||||
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
||||
masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row
|
||||
|
||||
Reference in New Issue
Block a user