From 608bbd50e7b31d49c7516a00e96f284fa16e2777 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 28 Jan 2026 22:31:34 +0100 Subject: [PATCH] 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 --- frontend/package-lock.json | 29 + frontend/package.json | 2 + .../admin/ThemeCustomizerEnhanced.tsx | 47 +- .../layouts/JustifiedGalleryLayout.tsx | 785 ++++++++++++++++++ .../gallery/layouts/MasonryGalleryLayout.tsx | 296 ++++++- .../gallery/layouts/MosaicGalleryLayout.tsx | 477 ++++++----- frontend/src/i18n/locales/de.json | 11 +- frontend/src/i18n/locales/en.json | 11 +- frontend/src/types/justified-layout.d.ts | 38 + frontend/src/types/theme.types.ts | 2 +- 10 files changed, 1466 insertions(+), 232 deletions(-) create mode 100644 frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx create mode 100644 frontend/src/types/justified-layout.d.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c59d7d97..bcb9e24b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index a2340f76..712b8c8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index fd2a5c94..ba7255c3 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -380,15 +380,23 @@ export const ThemeCustomizerEnhanced: React.FC = ( className="w-full px-3 py-2 border border-neutral-300 rounded-lg" > - + + +

- {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')}

- {/* Row-specific settings */} - {localTheme.gallerySettings?.masonryMode === 'rows' && ( + {/* Row-specific settings - show for all row-based modes */} + {['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && ( <>
-
- - -
+ {/* Last row behavior - only for rows and flickr modes */} + {['rows', 'flickr'].includes(localTheme.gallerySettings?.masonryMode || '') && ( +
+ + +
+ )} )} diff --git a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx new file mode 100644 index 00000000..2c1d3a8d --- /dev/null +++ b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx @@ -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 = ({ + 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(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) => { + if (isTouchDevice && !overlayVisible && !isSelectionMode) { + e.preventDefault(); + e.stopPropagation(); + showOverlayTemporarily(); + return; + } + + onClick(); + if (isTouchDevice) { + hideOverlay(); + } + }; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + > + {inView ? ( + <> + { + console.warn(`Protection violation on justified photo ${photo.id}: ${violationType}`); + }} + /> + + {/* Hover Overlay */} +
+ {!isSelectionMode && ( + <> + + {allowDownloads && ( + + )} + {showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && ( + + )} + {showFeedbackActions && feedbackOptions?.allowLikes && ( + + )} + + )} +
+ + {/* Selection Checkbox */} + + + {/* Feedback Indicators */} + {(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && ( +
+ {(likeCount > 0 || liked) && ( + + + + )} + {averageRating > 0 && ( + + + + )} + {commentCount > 0 && ( + + + + )} +
+ )} + + {/* Video Badge */} + {isVideo && ( +
+ + +
+ )} + + {/* Collage Badge */} + {photo.type === 'collage' && ( +
+ Collage +
+ )} + + ) : ( +
+ )} +
+ ); +}; + +export const JustifiedGalleryLayout: React.FC = ({ + 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(null); + const [containerWidth, setContainerWidth] = useState(0); + const [heroPhoto, setHeroPhoto] = useState(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 + ); + const [likedPhotoIds, setLikedPhotoIds] = useState>(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(); + for (const item of layoutResult.items) { + map.set(item.photoId, item); + } + return map; + }, [layoutResult.items]); + + return ( + <> + {/* Hero Section (optional) */} + {showHero && heroPhoto && ( +
+
+ + + {/* Overlay */} +
+ + {/* Hero Content */} +
+
+ {/* Logo at top position */} + {heroLogoVisible && heroLogoPosition === 'top' && eventLogo && ( +
+ Event logo +
+ )} + + {/* Event Title */} + {eventName && ( +

+ {eventName} +

+ )} + + {/* Logo at center position */} + {heroLogoVisible && heroLogoPosition === 'center' && eventLogo && ( +
+ Event logo +
+ )} + + {/* Event Dates */} + {(eventDate || expiresAt) && ( +
+ {eventDate && ( + + + {format(parseISO(eventDate), 'PP')} + + )} + {expiresAt && ( + + + {t('gallery.expires')} {format(parseISO(expiresAt), 'PP')} + + )} +
+ )} + + {/* Logo at bottom position */} + {heroLogoVisible && heroLogoPosition === 'bottom' && eventLogo && ( +
+ Event logo +
+ )} +
+
+ + {/* Scroll Indicator */} + +
+
+ )} + + {/* Justified Photo Grid */} + + + ); +}; diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index 0e206844..8154f4ea 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -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 = ({ 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 = ({ ); } + // FLICKR MODE - Flickr's justified-layout algorithm + if (mode === 'flickr') { + const isCalculating = containerWidth <= 0 || flickrLayout.boxes.length === 0; + + return ( +
+ {isCalculating ? ( +
+ {photos.slice(0, 8).map((photo) => ( +
+ ))} +
+ ) : photos.map((photo, index) => { + const box = flickrLayout.boxes[index]; + if (!box) return null; + + return ( +
onPhotoClick(index)} + > + + + {/* Feedback Indicators */} + {feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && ( +
+ {(photo.comment_count ?? 0) > 0 && ( +
+ + {photo.comment_count ?? 0} +
+ )} + {(photo.average_rating ?? 0) > 0 && ( +
+ + {Number(photo.average_rating ?? 0).toFixed(1)} +
+ )} + {(photo.like_count ?? 0) > 0 && ( +
+ + {photo.like_count ?? 0} +
+ )} +
+ )} + + {/* Hover overlay with actions */} +
+ {!isSelectionMode && ( + <> + + {allowDownloads && ( + + )} + + )} +
+ + {/* Selection Checkbox */} + + + {photo.type === 'collage' && ( +
+ Collage +
+ )} +
+ ); + })} +
+ ); + } + + // 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) => { + const { photo, width, height } = context; + const photoData = photo.photoData; + const originalIndex = photo.originalIndex; + + return ( +
onPhotoClick(originalIndex)} + > + + + {/* Feedback Indicators */} + {feedbackEnabled && ((photoData.comment_count ?? 0) > 0 || (photoData.average_rating ?? 0) > 0 || (photoData.like_count ?? 0) > 0) && ( +
+ {(photoData.comment_count ?? 0) > 0 && ( +
+ + {photoData.comment_count ?? 0} +
+ )} + {(photoData.average_rating ?? 0) > 0 && ( +
+ + {Number(photoData.average_rating ?? 0).toFixed(1)} +
+ )} + {(photoData.like_count ?? 0) > 0 && ( +
+ + {photoData.like_count ?? 0} +
+ )} +
+ )} + + {/* Hover overlay with actions */} +
+ {!isSelectionMode && ( + <> + + {allowDownloads && ( + + )} + + )} +
+ + {/* Selection Checkbox */} + + + {photoData.type === 'collage' && ( +
+ Collage +
+ )} +
+ ); + }, [onPhotoClick, onDownload, allowDownloads, feedbackEnabled, isSelectionMode, selectedPhotos, onPhotoSelect]); + + if (albumPhotos.length === 0) { + return ( +
+
+ {photos.slice(0, 8).map((photo) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ +
+ ); + } + // COLUMNS MODE - Pinterest style masonry (default) return (
{ + 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 = ({ // 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 ( + { + 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 ( +
+ {arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')} +
+ {arranged[1] && renderMosaicPhoto(arranged[1])} + {arranged[2] && renderMosaicPhoto(arranged[2])} +
+
+ ); + + case 'tall-right-2-left': + // 2 landscape/square stacked on left, portrait/tall on right + return ( +
+
+ {arranged[1] && renderMosaicPhoto(arranged[1])} + {arranged[2] && renderMosaicPhoto(arranged[2])} +
+ {arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')} +
+ ); + + case 'wide-top-2-bottom': + // Wide landscape on top, 2 photos below + return ( +
+
+ {arranged[0] && renderMosaicPhoto(arranged[0])} +
+
+ {arranged[1] && renderMosaicPhoto(arranged[1])} + {arranged[2] && renderMosaicPhoto(arranged[2])} +
+
+ ); + + case 'wide-bottom-2-top': + // 2 photos on top, wide landscape below + return ( +
+
+ {arranged[1] && renderMosaicPhoto(arranged[1])} + {arranged[2] && renderMosaicPhoto(arranged[2])} +
+
+ {arranged[0] && renderMosaicPhoto(arranged[0])} +
+
+ ); + + case 'three-rows': + // 3 horizontal rows - good for all landscapes + return ( +
+ {arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))} +
+ ); + + case 'two-portraits': + // 2 side-by-side tall photos + return ( +
+ {arranged.slice(0, 2).map((p) => renderMosaicPhoto(p))} +
+ ); + + case 'hero-wide': + // Single wide hero image + return ( +
+ {arranged[0] && renderMosaicPhoto(arranged[0])} +
+ ); + + case 'hero-tall': + // Single tall hero image + return ( +
+ {arranged[0] && renderMosaicPhoto(arranged[0])} +
+ ); + + 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 ( +
+ {arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))} +
+ ); } }; - // 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( -
- {photo0 && ( - 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); }} - /> - )} -
- {photo1 && ( - 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 && ( - 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); }} - /> - )} -
-
- ); - photoIndex += 3; - } else if (patternIndex % 3 === 1 && remainingPhotos >= 3) { - // Pattern 2: 3 equal columns - elements.push( -
- {[0, 1, 2].map(offset => { - const currentIndex = photoIndex + offset; - const photo = photos[currentIndex]; - return photo ? ( - 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; - })} -
- ); - 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( -
- {photo0 && ( - 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); }} - /> - )} -
- {photo1 && ( - 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 && ( - 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); }} - /> - )} -
-
- ); - 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( -
- {remainingPhotos.map((photo, idx) => { - const index = photoIndex + idx; - return ( - 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); }} - /> - ); - })} -
- ); - } - + return elements; }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c0848fd7..aeed14c3 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f7da9643..90b75f52 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/types/justified-layout.d.ts b/frontend/src/types/justified-layout.d.ts new file mode 100644 index 00000000..c3c3f802 --- /dev/null +++ b/frontend/src/types/justified-layout.d.ts @@ -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; +} diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index fde1ab1a..c8267d06 100644 --- a/frontend/src/types/theme.types.ts +++ b/frontend/src/types/theme.types.ts @@ -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