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:
Paul Nothaft
2026-01-28 22:31:34 +01:00
parent 8097a0cb53
commit 608bbd50e7
10 changed files with 1466 additions and 232 deletions
+29
View File
@@ -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",
+2
View File
@@ -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;
};
+9 -2
View File
@@ -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",
+9 -2
View File
@@ -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
View File
@@ -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;
}
+1 -1
View File
@@ -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