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"