From 8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 29 Jan 2026 21:40:41 +0100 Subject: [PATCH 1/4] fix: use actual photo aspect ratios in masonry columns mode (#146) Previously, the Pinterest-style columns mode assigned random heights to photos, causing landscape images to be cropped into portrait slots. Now the height is calculated based on the photo's actual aspect ratio and the column width, preserving natural proportions. --- .../gallery/layouts/MasonryGalleryLayout.tsx | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index 8154f4ea..3d9043f5 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -34,6 +34,8 @@ interface MasonryPhotoProps { requireNameEmail?: boolean; }; onQuickComment?: () => void; + // Column width for calculating proper aspect-ratio-based height + columnWidth?: number; } const MasonryPhoto: React.FC = ({ @@ -48,19 +50,28 @@ const MasonryPhoto: React.FC = ({ feedbackEnabled = false, slug, feedbackOptions, - onQuickComment + onQuickComment, + columnWidth = 300 }) => { - const [imageHeight, setImageHeight] = useState(200); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); - // Generate random heights for masonry effect - useEffect(() => { - const heights = [200, 250, 300, 350, 400]; - const randomHeight = heights[Math.floor(Math.random() * heights.length)]; - setImageHeight(randomHeight); - }, [photo.id]); + // Calculate height based on actual photo aspect ratio + // This preserves the photo's natural proportions in the masonry layout + const imageHeight = useMemo(() => { + const photoWidth = photo.width || 800; + const photoHeight = photo.height || 600; + const aspectRatio = photoWidth / photoHeight; + + // Calculate height based on column width and aspect ratio + // Clamp to reasonable min/max heights for visual consistency + const calculatedHeight = columnWidth / aspectRatio; + const minHeight = 150; + const maxHeight = 500; + + return Math.max(minHeight, Math.min(maxHeight, calculatedHeight)); + }, [photo.width, photo.height, columnWidth]); return (
= ({ }); } + // Calculate approximate column width for aspect ratio calculations + const columnWidth = useMemo(() => { + if (containerWidth <= 0 || columns <= 0) return 300; + // Account for gaps between columns + const totalGaps = (columns - 1) * gutter; + return (containerWidth - totalGaps) / columns; + }, [containerWidth, columns, gutter]); + // ROWS MODE - Google Photos style justified layout if (mode === 'rows') { // Show loading state while measuring container width @@ -744,6 +763,7 @@ export const MasonryGalleryLayout: React.FC = ({ slug={slug} feedbackOptions={feedbackOptions} onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)} + columnWidth={columnWidth} /> ); })} From 46ed1bc276867a25b27bf22cd9b9d7e879a6947b Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 29 Jan 2026 23:09:12 +0100 Subject: [PATCH 2/4] feat: add quilted layout, fix mosaic, and backfill photo dimensions (#146) - Add migration to backfill width/height for existing photos without dimensions - Replace justified masonry mode with quilted layout (mixed sizes based on aspect ratio) - Rewrite mosaic layout to use proper CSS Grid with span rules - Fix theme not being applied after gallery login - Improve columns mode distribution using shortest-column algorithm - Apply gallery theme regardless of authentication status --- .../core/064_backfill_photo_dimensions.js | 105 ++++ .../gallery/layouts/MasonryGalleryLayout.tsx | 295 ++++----- .../gallery/layouts/MosaicGalleryLayout.tsx | 568 ++++++------------ frontend/src/pages/GalleryPage.tsx | 10 +- frontend/src/types/theme.types.ts | 2 +- scripts/backfill-dimensions.js | 70 +++ scripts/setup-masonry-test-galleries.sh | 121 ++++ 7 files changed, 639 insertions(+), 532 deletions(-) create mode 100644 backend/migrations/core/064_backfill_photo_dimensions.js create mode 100644 scripts/backfill-dimensions.js create mode 100755 scripts/setup-masonry-test-galleries.sh diff --git a/backend/migrations/core/064_backfill_photo_dimensions.js b/backend/migrations/core/064_backfill_photo_dimensions.js new file mode 100644 index 00000000..4d97f3f1 --- /dev/null +++ b/backend/migrations/core/064_backfill_photo_dimensions.js @@ -0,0 +1,105 @@ +/** + * Migration: Backfill photo dimensions + * + * This migration extracts width/height from existing photos that don't have + * these dimensions stored. This is needed for aspect-ratio-aware layouts + * (masonry, mosaic, justified) to work properly. + */ + +const path = require('path'); +const fs = require('fs'); + +exports.up = async function(knex) { + // Check if the width/height columns exist + const hasWidth = await knex.schema.hasColumn('photos', 'width'); + const hasHeight = await knex.schema.hasColumn('photos', 'height'); + + if (!hasWidth || !hasHeight) { + console.log('[Migration 064] Width/height columns not found, skipping backfill'); + return; + } + + // Get storage path + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + + // Find photos without dimensions + const photos = await knex('photos') + .whereNull('width') + .orWhereNull('height') + .select('id', 'path', 'filename', 'media_type'); + + console.log(`[Migration 064] Found ${photos.length} photos without dimensions`); + + if (photos.length === 0) { + return; + } + + // Import sharp dynamically (only needed during migration) + let sharp; + try { + sharp = require('sharp'); + } catch (err) { + console.error('[Migration 064] Sharp not available, skipping backfill:', err.message); + return; + } + + let updated = 0; + let failed = 0; + + for (const photo of photos) { + try { + // Skip videos - they need ffprobe for metadata + if (photo.media_type === 'video') { + continue; + } + + // Construct the full file path + let fullPath; + if (photo.path) { + // Path is relative to events/active directory + fullPath = path.join(storagePath, 'events/active', photo.path); + } else { + console.warn(`[Migration 064] Photo ${photo.id} (${photo.filename}) has no path, skipping`); + continue; + } + + // Check if file exists + if (!fs.existsSync(fullPath)) { + console.warn(`[Migration 064] Photo ${photo.id} file not found: ${fullPath}`); + failed++; + continue; + } + + // Extract dimensions using sharp + const metadata = await sharp(fullPath).metadata(); + + if (metadata.width && metadata.height) { + await knex('photos') + .where('id', photo.id) + .update({ + width: metadata.width, + height: metadata.height + }); + updated++; + + if (updated % 50 === 0) { + console.log(`[Migration 064] Updated ${updated} photos...`); + } + } else { + console.warn(`[Migration 064] Could not extract dimensions for photo ${photo.id}`); + failed++; + } + } catch (err) { + console.error(`[Migration 064] Error processing photo ${photo.id}:`, err.message); + failed++; + } + } + + console.log(`[Migration 064] Completed: ${updated} updated, ${failed} failed`); +}; + +exports.down = async function(knex) { + // This migration only adds data, no rollback needed + // We don't want to null out dimensions on rollback as they're still valid + console.log('[Migration 064] Rollback: No action needed (data-only migration)'); +}; diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index 3d9043f5..9774fc02 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, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; @@ -11,9 +11,6 @@ import { } 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'; @@ -244,6 +241,7 @@ export const MasonryGalleryLayout: React.FC = ({ const targetRowHeight = gallerySettings.masonryRowHeight || 250; const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left'; + // Calculate number of columns based on container width (for columns mode) useEffect(() => { const updateDimensions = () => { @@ -336,30 +334,43 @@ export const MasonryGalleryLayout: React.FC = ({ 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 []; + // Distribute photos across columns using greedy "shortest column" algorithm + // This creates a more balanced masonry layout instead of round-robin + const photoColumns: Photo[][] = useMemo(() => { + if (mode !== 'columns' || photos.length === 0) { + return Array.from({ length: columns }, () => []); } - 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]); + const cols: Photo[][] = Array.from({ length: columns }, () => []); + const colHeights: number[] = Array(columns).fill(0); - // Distribute photos across columns (for columns mode) - const photoColumns: Photo[][] = Array.from({ length: columns }, () => []); - if (mode === 'columns') { - photos.forEach((photo, index) => { - photoColumns[index % columns].push(photo); + // Calculate approximate column width for height estimation + const approxColWidth = containerWidth > 0 ? (containerWidth - (columns - 1) * gutter) / columns : 300; + + photos.forEach((photo) => { + // Find the shortest column + let shortestCol = 0; + let minHeight = colHeights[0]; + for (let i = 1; i < columns; i++) { + if (colHeights[i] < minHeight) { + minHeight = colHeights[i]; + shortestCol = i; + } + } + + // Add photo to shortest column + cols[shortestCol].push(photo); + + // Estimate height based on aspect ratio + const photoWidth = photo.width || 800; + const photoHeight = photo.height || 600; + const aspectRatio = photoWidth / photoHeight; + const estimatedHeight = Math.max(150, Math.min(500, approxColWidth / aspectRatio)); + colHeights[shortestCol] += estimatedHeight + gutter; }); - } + + return cols; + }, [mode, photos, columns, containerWidth, gutter]); // Calculate approximate column width for aspect ratio calculations const columnWidth = useMemo(() => { @@ -609,127 +620,127 @@ export const MasonryGalleryLayout: React.FC = ({ ); } - // 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; + // QUILTED MODE - Mixed sizes based on aspect ratio + // Landscape photos span 2 columns, portrait photos span 2 rows + if (mode === 'quilted') { + // Determine grid span based on aspect ratio + const getSpanClasses = (photo: Photo): string => { + const width = photo.width || 800; + const height = photo.height || 600; + const ratio = width / height; - 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) => ( -
- ))} -
-
- ); - } + // Very wide landscape (panoramic) - span 2 columns + if (ratio > 1.5) return 'col-span-2'; + // Very tall portrait - span 2 rows + if (ratio < 0.7) return 'row-span-2'; + // Normal aspect ratio - single cell + return ''; + }; return ( -
- +
+ {photos.map((photo, index) => { + const spanClasses = getSpanClasses(photo); + + 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 +
+ )} +
+ ); + })}
); } diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index 2c61cc3a..357b1782 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -1,116 +1,58 @@ import React, { useMemo } from 'react'; import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react'; -// import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; 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'; +/** + * Mosaic Gallery Layout + * + * Uses CSS Grid with span rules based on photo aspect ratios to create + * a visually appealing mosaic layout. Based on best practices from: + * - https://www.30secondsofcode.org/css/s/image-mosaic/ + * - https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout + * + * Portrait photos span 2 rows, wide landscape photos span 2 columns. + */ -interface PhotoWithIndex { - photo: Photo; - originalIndex: number; - orientation: Orientation; -} +// Determine grid span based on aspect ratio +type SpanType = 'normal' | 'tall' | 'wide'; -// Get photo orientation based on aspect ratio -const getOrientation = (photo: Photo): Orientation => { +const getSpanType = (photo: Photo): SpanType => { 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'; + // Very tall portrait (aspect ratio < 0.7) - span 2 rows + if (ratio < 0.75) return 'tall'; + // Very wide landscape (aspect ratio > 1.6) - span 2 columns + if (ratio > 1.6) return 'wide'; + // Normal aspect ratio + return 'normal'; }; -// 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 - }; +// Get CSS classes for grid item based on span type +const getGridItemClasses = (spanType: SpanType): string => { + switch (spanType) { + case 'tall': + return 'row-span-2'; + case 'wide': + return 'col-span-2'; + default: + return ''; } - - 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; + spanType: SpanType; isSelected: boolean; isSelectionMode: boolean; onClick: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void; onToggleSelect: () => void; - className?: string; allowDownloads?: boolean; slug?: string; feedbackEnabled?: boolean; @@ -124,12 +66,12 @@ interface MosaicPhotoProps { const MosaicPhoto: React.FC = ({ photo, + spanType, isSelected, isSelectionMode, onClick, onDownload, onToggleSelect, - className = '', allowDownloads = true, slug, feedbackEnabled = false, @@ -142,139 +84,139 @@ const MosaicPhoto: React.FC = ({ const [likedLocal, setLikedLocal] = React.useState(false); const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment); + const gridItemClasses = getGridItemClasses(spanType); + return ( <> -
{ - e.stopPropagation(); - onClick(e); - }} - > -
+
{ + e.stopPropagation(); + onClick(e); + }} + > -
- -
- {!isSelectionMode && ( - <> - - {allowDownloads && ( + +
+ {!isSelectionMode && ( + <> - )} - {feedbackOptions?.allowLikes && ( - - )} - {canComment && ( - - )} - + {allowDownloads && ( + + )} + {feedbackOptions?.allowLikes && ( + + )} + {canComment && ( + + )} + + )} +
+ + {/* Feedback Indicators (bottom-left) */} + {((photo.like_count ?? 0) > 0 || likedLocal) && ( +
+ + + +
+ )} + + {/* Selection Checkbox (visible on hover or when selected) */} + + + {photo.type === 'collage' && ( +
+ + Collage + +
)}
- - {/* Feedback Indicators (bottom-left) */} - {((photo.like_count ?? 0) > 0 || likedLocal) && ( -
- - - -
- )} - - {/* Selection Checkbox (visible on hover or when selected) */} - - - {photo.type === 'collage' && ( -
- - Collage - -
- )} -
- { 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, - }); - setPendingAction(null); - } - }} + { 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, + }); + setPendingAction(null); + } + }} feedbackType="like" - /> + /> ); }; @@ -292,194 +234,52 @@ export const MosaicGalleryLayout: React.FC = ({ feedbackEnabled = false, feedbackOptions }) => { - // const { theme } = useTheme(); - // const gallerySettings = theme.gallerySettings || {}; - // const pattern = gallerySettings.mosaicPattern || 'structured'; - - // Pre-compute photos with their orientations - const photosWithOrientations = useMemo(() => { + // Pre-compute photos with their span types + const photosWithSpans = useMemo(() => { return photos.map((photo, index) => ({ photo, originalIndex: index, - orientation: getOrientation(photo) + spanType: getSpanType(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 aspect-ratio-aware mosaic layout - const renderMosaicLayout = () => { - 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'; - } - - // Render the pattern - elements.push(renderPattern(finalPattern, arranged, `pattern-${index}`)); - - index += groupSize; - patternCount++; - } - - return elements; - }; - return ( -
- {renderMosaicLayout()} +
+ {photosWithSpans.map(({ photo, originalIndex, spanType }) => ( + { + if (isSelectionMode && onPhotoSelect) { + onPhotoSelect(photo.id); + } else { + onPhotoClick(originalIndex); + } + }} + onDownload={(e) => onDownload(photo, e)} + onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)} + allowDownloads={allowDownloads} + slug={slug} + feedbackEnabled={feedbackEnabled} + feedbackOptions={feedbackOptions} + onQuickComment={() => { + if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) { + onOpenPhotoWithFeedback(originalIndex); + } + }} + /> + ))}
); }; diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index f4d1fafd..e7b642cb 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -122,11 +122,11 @@ export const GalleryPage: React.FC = () => { } }, [settingsData, isAuthenticated, i18n]); - // Apply theme for login page + // Apply theme for gallery (both login page and authenticated view) React.useEffect(() => { - if (!isAuthenticated && galleryInfo && settingsData) { + if (galleryInfo && settingsData) { let themeToApply = null; - + if (galleryInfo.color_theme) { try { // Check if it's a valid JSON string @@ -155,13 +155,13 @@ export const GalleryPage: React.FC = () => { // No event theme, use global theme themeToApply = settingsData.theme_config; } - + // Apply theme if (themeToApply) { setTheme(themeToApply); } } - }, [galleryInfo, settingsData, isAuthenticated, setTheme]); + }, [galleryInfo, settingsData, setTheme]); React.useEffect(() => { if (!resolvedSlug || isResolvingIdentifier) { diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index c8267d06..c608c8d9 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' | 'flickr' | 'justified'; // columns = Pinterest-style, rows = custom rows, flickr = Flickr justified-layout, justified = react-photo-album (Google Photos style) + masonryMode?: 'columns' | 'rows' | 'flickr' | 'quilted'; // columns = Pinterest-style, rows = justified rows, flickr = Flickr justified-layout, quilted = mixed sizes based on aspect ratio masonryGutter?: number; masonryRowHeight?: number; // Target row height for rows mode (150-400) masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row diff --git a/scripts/backfill-dimensions.js b/scripts/backfill-dimensions.js new file mode 100644 index 00000000..eef6fb34 --- /dev/null +++ b/scripts/backfill-dimensions.js @@ -0,0 +1,70 @@ +/** + * Script to backfill photo dimensions for photos that are missing them + */ +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +// Dynamic require for knex to use the app's config +const config = require('../backend/knexfile'); +const knex = require('knex')(config); + +const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage'); + +async function backfillDimensions() { + console.log('Storage path:', storagePath); + + const photos = await knex('photos') + .whereNull('width') + .orWhereNull('height') + .select('id', 'path', 'filename', 'media_type'); + + console.log(`Found ${photos.length} photos without dimensions`); + + let updated = 0; + let failed = 0; + + for (const photo of photos) { + if (photo.media_type === 'video') continue; + + if (!photo.path) { + console.log(`Photo ${photo.id} has no path`); + failed++; + continue; + } + + const fullPath = path.join(storagePath, 'events/active', photo.path); + + if (!fs.existsSync(fullPath)) { + console.log(`Not found: ${fullPath}`); + failed++; + continue; + } + + try { + const metadata = await sharp(fullPath).metadata(); + if (metadata.width && metadata.height) { + await knex('photos') + .where('id', photo.id) + .update({ width: metadata.width, height: metadata.height }); + updated++; + + if (updated % 20 === 0) { + console.log(`Updated ${updated} photos...`); + } + } + } catch (err) { + console.log(`Error processing photo ${photo.id}:`, err.message); + failed++; + } + } + + console.log(`\nCompleted: ${updated} updated, ${failed} failed`); + await knex.destroy(); + process.exit(0); +} + +backfillDimensions().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/setup-masonry-test-galleries.sh b/scripts/setup-masonry-test-galleries.sh new file mode 100755 index 00000000..423bfa32 --- /dev/null +++ b/scripts/setup-masonry-test-galleries.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Script to create test galleries for each masonry layout mode + +set -e + +BASE_URL="${BASE_URL:-http://localhost:7100}" +ADMIN_USER="${ADMIN_USERNAME:-admin}" +ADMIN_PASS="${ADMIN_PASSWORD:-admin}" +TEST_IMAGES_DIR="${1:-./test-images}" + +echo "=== Setting up Masonry Layout Test Galleries ===" +echo "Base URL: $BASE_URL" +echo "Test images: $TEST_IMAGES_DIR" + +# Login to get admin token +echo "" +echo "Logging in as admin..." +LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/api/auth/admin/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"$ADMIN_USER\", \"password\": \"$ADMIN_PASS\"}") + +TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) + +if [ -z "$TOKEN" ]; then + echo "Failed to login. Response: $LOGIN_RESPONSE" + exit 1 +fi + +echo "Login successful!" + +# Function to create a gallery with specific masonry mode +create_gallery() { + local name="$1" + local masonry_mode="$2" + local description="$3" + + echo "" + echo "Creating gallery: $name (masonry mode: $masonry_mode)" + + # Build color_theme JSON with galleryLayout and gallerySettings + local color_theme=$(cat < /dev/null + echo " Uploaded: $filename" + fi + done + + echo " Gallery URL: $share_link" + echo "$share_link" >> /tmp/masonry_test_galleries.txt +} + +# Clear previous results +> /tmp/masonry_test_galleries.txt + +# Create galleries for each masonry mode +create_gallery "Masonry Columns Test" "columns" "Pinterest-style vertical columns with varied heights based on photo aspect ratios" +create_gallery "Masonry Rows Test" "rows" "Custom row-based justified layout that fills each row completely" +create_gallery "Masonry Flickr Test" "flickr" "Flickr's justified-layout algorithm for optimal row arrangement" +create_gallery "Masonry Quilted Test" "quilted" "Mixed sizes layout - landscape photos span 2 columns, portraits span 2 rows" + +echo "" +echo "=== All Test Galleries Created ===" +echo "" +echo "Gallery URLs:" +cat /tmp/masonry_test_galleries.txt +echo "" +echo "You can also find these URLs in /tmp/masonry_test_galleries.txt" From 821d3296ea4b6bde499e5497d258f15ab8dd1dbc Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 29 Jan 2026 23:16:14 +0100 Subject: [PATCH 3/4] fix: use CSS Columns for gap-free mosaic layout (#146) Replaced CSS Grid with span rules approach with CSS Columns to eliminate gaps and white spaces in the mosaic layout. Images now flow vertically within columns, maintaining their natural aspect ratios without gaps. --- .../gallery/layouts/MosaicGalleryLayout.tsx | 83 ++++++------------- 1 file changed, 26 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index 357b1782..341abe49 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react'; import { AuthenticatedImage } from '../../common'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; @@ -9,45 +9,17 @@ import type { Photo } from '../../../types'; /** * Mosaic Gallery Layout * - * Uses CSS Grid with span rules based on photo aspect ratios to create - * a visually appealing mosaic layout. Based on best practices from: - * - https://www.30secondsofcode.org/css/s/image-mosaic/ - * - https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout + * Uses CSS Columns for a gap-free masonry/mosaic effect. + * Images flow vertically within columns, maintaining their natural aspect ratios. + * This approach eliminates gaps that occur with CSS Grid span rules. * - * Portrait photos span 2 rows, wide landscape photos span 2 columns. + * Based on: + * - https://css-tricks.com/seamless-responsive-photo-grid/ + * - https://www.30secondsofcode.org/css/s/image-mosaic/ */ -// Determine grid span based on aspect ratio -type SpanType = 'normal' | 'tall' | 'wide'; - -const getSpanType = (photo: Photo): SpanType => { - const width = photo.width || 1; - const height = photo.height || 1; - const ratio = width / height; - - // Very tall portrait (aspect ratio < 0.7) - span 2 rows - if (ratio < 0.75) return 'tall'; - // Very wide landscape (aspect ratio > 1.6) - span 2 columns - if (ratio > 1.6) return 'wide'; - // Normal aspect ratio - return 'normal'; -}; - -// Get CSS classes for grid item based on span type -const getGridItemClasses = (spanType: SpanType): string => { - switch (spanType) { - case 'tall': - return 'row-span-2'; - case 'wide': - return 'col-span-2'; - default: - return ''; - } -}; - interface MosaicPhotoProps { photo: Photo; - spanType: SpanType; isSelected: boolean; isSelectionMode: boolean; onClick: (e: React.MouseEvent) => void; @@ -66,7 +38,6 @@ interface MosaicPhotoProps { const MosaicPhoto: React.FC = ({ photo, - spanType, isSelected, isSelectionMode, onClick, @@ -84,12 +55,11 @@ const MosaicPhoto: React.FC = ({ const [likedLocal, setLikedLocal] = React.useState(false); const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment); - const gridItemClasses = getGridItemClasses(spanType); - return ( <>
{ e.stopPropagation(); onClick(e); @@ -98,7 +68,7 @@ const MosaicPhoto: React.FC = ({ = ({ feedbackEnabled = false, feedbackOptions }) => { - // Pre-compute photos with their span types - const photosWithSpans = useMemo(() => { - return photos.map((photo, index) => ({ - photo, - originalIndex: index, - spanType: getSpanType(photo) - })); - }, [photos]); - return (
- {photosWithSpans.map(({ photo, originalIndex, spanType }) => ( + + {photos.map((photo, index) => ( { if (isSelectionMode && onPhotoSelect) { onPhotoSelect(photo.id); } else { - onPhotoClick(originalIndex); + onPhotoClick(index); } }} onDownload={(e) => onDownload(photo, e)} @@ -275,7 +244,7 @@ export const MosaicGalleryLayout: React.FC = ({ feedbackOptions={feedbackOptions} onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) { - onOpenPhotoWithFeedback(originalIndex); + onOpenPhotoWithFeedback(index); } }} /> From 27ff51e7a1217848859b47940bc88caa6f1fb20f Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 30 Jan 2026 08:23:58 +0100 Subject: [PATCH 4/4] fix: use photo dimensions for mosaic aspect ratios (#146) Thumbnails are generated as 300x300 squares, so CSS Columns alone couldn't show varied aspect ratios. Now using the photo's width/height metadata with CSS aspect-ratio property to force correct proportions. --- .../components/gallery/layouts/MosaicGalleryLayout.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index 341abe49..00778e23 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -55,11 +55,17 @@ const MosaicPhoto: React.FC = ({ const [likedLocal, setLikedLocal] = React.useState(false); const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment); + // Calculate aspect ratio from photo dimensions (fallback to 1 if unknown) + const aspectRatio = (photo.width && photo.height) ? photo.width / photo.height : 1; + return ( <>
{ e.stopPropagation(); onClick(e); @@ -68,7 +74,7 @@ const MosaicPhoto: React.FC = ({