Merge pull request #153 from the-luap/feat/new-features

feat: improve gallery layouts with aspect-ratio-aware masonry and mosaic modes (#146)
This commit is contained in:
Paul Nothaft
2026-01-30 08:34:24 +01:00
committed by GitHub
7 changed files with 659 additions and 557 deletions
@@ -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)');
};
@@ -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';
@@ -34,6 +31,8 @@ interface MasonryPhotoProps {
requireNameEmail?: boolean;
};
onQuickComment?: () => void;
// Column width for calculating proper aspect-ratio-based height
columnWidth?: number;
}
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
@@ -48,19 +47,28 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
feedbackEnabled = false,
slug,
feedbackOptions,
onQuickComment
onQuickComment,
columnWidth = 300
}) => {
const [imageHeight, setImageHeight] = useState<number>(200);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(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 (
<div
@@ -233,6 +241,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
// Calculate number of columns based on container width (for columns mode)
useEffect(() => {
const updateDimensions = () => {
@@ -325,30 +334,51 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
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(() => {
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') {
@@ -590,127 +620,127 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
);
}
// JUSTIFIED MODE - React Photo Album (Google Photos style with Knuth-Plass algorithm)
if (mode === 'justified') {
// Custom render function for photos in react-photo-album
// The render function receives (props, context) where context contains photo, index, width, height
const renderPhoto = useCallback((_props: { onClick?: React.MouseEventHandler }, context: RenderPhotoContext<typeof albumPhotos[0]>) => {
const { photo, width, height } = context;
const photoData = photo.photoData;
const originalIndex = photo.originalIndex;
// 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 (
<div
style={{ width, height }}
className="photo-card group cursor-pointer transition-all duration-300 hover:z-10 relative"
onClick={() => onPhotoClick(originalIndex)}
>
<AuthenticatedImage
src={photoData.thumbnail_url || photoData.url}
alt={photoData.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]"
style={{ width: '100%', height: '100%' }}
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
{/* Feedback Indicators */}
{feedbackEnabled && ((photoData.comment_count ?? 0) > 0 || (photoData.average_rating ?? 0) > 0 || (photoData.like_count ?? 0) > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10">
{(photoData.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photoData.comment_count ?? 0}</span>
</div>
)}
{(photoData.average_rating ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photoData.average_rating ?? 0).toFixed(1)}</span>
</div>
)}
{(photoData.like_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photoData.like_count ?? 0}</span>
</div>
)}
</div>
)}
{/* Hover overlay with actions */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
type="button"
aria-label="View full size"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onPhotoClick(originalIndex); }}
>
<Maximize2 className="w-5 h-5 text-white" />
</button>
{allowDownloads && (
<button
type="button"
aria-label="Download photo"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onDownload(photoData, e); }}
>
<Download className="w-5 h-5 text-white" />
</button>
)}
</>
)}
</div>
{/* Selection Checkbox */}
<button
type="button"
aria-label={`Select ${photoData.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(photoData.id)}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photoData.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photoData.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photoData.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photoData.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{photoData.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
</div>
)}
</div>
);
}, [onPhotoClick, onDownload, allowDownloads, feedbackEnabled, isSelectionMode, selectedPhotos, onPhotoSelect]);
if (albumPhotos.length === 0) {
return (
<div ref={containerRef} className="photo-grid">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{photos.slice(0, 8).map((photo) => (
<div key={photo.id} className="aspect-square bg-neutral-200 rounded-lg animate-pulse" />
))}
</div>
</div>
);
}
// 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 (
<div ref={containerRef} className="photo-grid">
<RowsPhotoAlbum
photos={albumPhotos}
targetRowHeight={targetRowHeight}
rowConstraints={{ minPhotos: 1, maxPhotos: 6 }}
spacing={gutter}
render={{ photo: renderPhoto }}
/>
<div
ref={containerRef}
className="photo-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gridAutoRows: '200px',
gap: `${gutter}px`,
gridAutoFlow: 'dense', // Fill gaps automatically
}}
>
{photos.map((photo, index) => {
const spanClasses = getSpanClasses(photo);
return (
<div
key={photo.id}
className={`photo-card group cursor-pointer relative overflow-hidden rounded-lg bg-neutral-100 ${spanClasses}`}
onClick={() => onPhotoClick(index)}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
{/* Feedback Indicators */}
{feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div>
)}
{(photo.average_rating ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
</div>
)}
{(photo.like_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.like_count ?? 0}</span>
</div>
)}
</div>
)}
{/* Hover overlay with actions */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
type="button"
aria-label="View full size"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onPhotoClick(index); }}
>
<Maximize2 className="w-5 h-5 text-white" />
</button>
{allowDownloads && (
<button
type="button"
aria-label="Download photo"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onDownload(photo, e); }}
>
<Download className="w-5 h-5 text-white" />
</button>
)}
</>
)}
</div>
{/* Selection Checkbox */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(photo.id)}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -744,6 +774,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
slug={slug}
feedbackOptions={feedbackOptions}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
columnWidth={columnWidth}
/>
);
})}
@@ -1,107 +1,22 @@
import React, { useMemo } from 'react';
import React 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';
interface PhotoWithIndex {
photo: Photo;
originalIndex: number;
orientation: Orientation;
}
// Get photo orientation based on aspect ratio
const getOrientation = (photo: Photo): Orientation => {
const width = photo.width || 1;
const height = photo.height || 1;
const ratio = width / height;
if (ratio > 1.2) return 'landscape';
if (ratio < 0.83) return 'portrait';
return 'square';
};
// Pattern types that work well with different orientation combinations
type PatternType =
| 'tall-left-2-right' // Tall photo left, 2 stacked right (good for 1 portrait + 2 landscape)
| 'tall-right-2-left' // Tall photo right, 2 stacked left (good for 1 portrait + 2 landscape)
| 'wide-top-2-bottom' // Wide photo top, 2 below (good for 1 landscape + 2 portrait)
| 'wide-bottom-2-top' // Wide photo bottom, 2 above (good for 1 landscape + 2 portrait)
| 'three-columns' // 3 equal columns (good for similar orientations)
| 'three-rows' // 3 equal rows (good for landscapes)
| 'two-portraits' // 2 tall side by side (good for portraits)
| 'hero-wide' // Single wide landscape hero
| 'hero-tall'; // Single tall portrait hero
// Analyze a group of photos and select the best pattern
const selectBestPattern = (photosWithIndex: PhotoWithIndex[]): { pattern: PatternType; arranged: PhotoWithIndex[] } => {
const count = photosWithIndex.length;
if (count === 1) {
const orientation = photosWithIndex[0].orientation;
return {
pattern: orientation === 'portrait' ? 'hero-tall' : 'hero-wide',
arranged: photosWithIndex
};
}
if (count === 2) {
const portraits = photosWithIndex.filter(p => p.orientation === 'portrait');
const landscapes = photosWithIndex.filter(p => p.orientation === 'landscape');
if (portraits.length === 2) {
return { pattern: 'two-portraits', arranged: photosWithIndex };
}
// For 2 photos, treat as part of a larger pattern or use columns
return { pattern: 'three-columns', arranged: photosWithIndex };
}
if (count >= 3) {
const portraits = photosWithIndex.filter(p => p.orientation === 'portrait');
const landscapes = photosWithIndex.filter(p => p.orientation === 'landscape');
const squares = photosWithIndex.filter(p => p.orientation === 'square');
// All or mostly portraits - use vertical-friendly layout
if (portraits.length >= 2) {
if (landscapes.length >= 1) {
// 2 portraits + 1 landscape: landscape on top, portraits below
const arranged = [...landscapes.slice(0, 1), ...portraits.slice(0, 2)];
return { pattern: 'wide-top-2-bottom', arranged };
}
// All portraits - stack them or use 3 columns
return { pattern: 'three-columns', arranged: photosWithIndex.slice(0, 3) };
}
// All or mostly landscapes - use horizontal-friendly layout
if (landscapes.length >= 2) {
if (portraits.length >= 1) {
// 1 portrait + 2 landscapes: portrait on left, landscapes stacked right
const arranged = [...portraits.slice(0, 1), ...landscapes.slice(0, 2)];
return { pattern: 'tall-left-2-right', arranged };
}
// All landscapes - use rows
return { pattern: 'three-rows', arranged: photosWithIndex.slice(0, 3) };
}
// Mixed or mostly squares - use standard patterns with smart placement
if (portraits.length === 1 && landscapes.length === 1) {
// 1 portrait + 1 landscape + 1 square
const arranged = [...portraits, ...squares.slice(0, 1), ...landscapes];
return { pattern: 'tall-left-2-right', arranged: arranged.slice(0, 3) };
}
// Default to 3 columns for mixed content
return { pattern: 'three-columns', arranged: photosWithIndex.slice(0, 3) };
}
return { pattern: 'three-columns', arranged: photosWithIndex };
};
/**
* Mosaic Gallery 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.
*
* Based on:
* - https://css-tricks.com/seamless-responsive-photo-grid/
* - https://www.30secondsofcode.org/css/s/image-mosaic/
*/
interface MosaicPhotoProps {
photo: Photo;
@@ -110,7 +25,6 @@ interface MosaicPhotoProps {
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
className?: string;
allowDownloads?: boolean;
slug?: string;
feedbackEnabled?: boolean;
@@ -129,7 +43,6 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
onClick,
onDownload,
onToggleSelect,
className = '',
allowDownloads = true,
slug,
feedbackEnabled = false,
@@ -142,139 +55,144 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
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 (
<>
<div
className={`photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
>
<div className="absolute inset-0">
<div
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 mb-2"
style={{
breakInside: 'avoid',
aspectRatio: aspectRatio.toString()
}}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
{allowDownloads && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
onClick={(e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
setLikedLocal(true);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
onClick(e);
}}
aria-label="Like photo"
aria-pressed={likedLocal}
title="Like"
aria-label="View full size"
>
<Heart className={`w-5 h-5 ${likedLocal ? 'text-white fill-white' : 'text-neutral-800'}`} />
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
)}
{canComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment?.(); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</>
{allowDownloads && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
setLikedLocal(true);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedLocal}
title="Like"
>
<Heart className={`w-5 h-5 ${likedLocal ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{canComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment?.(); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</>
)}
</div>
{/* Feedback Indicators (bottom-left) */}
{((photo.like_count ?? 0) > 0 || likedLocal) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
</div>
)}
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
</div>
{/* Feedback Indicators (bottom-left) */}
{((photo.like_count ?? 0) > 0 || likedLocal) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
</div>
)}
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
</div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
feedbackType="like"
/>
/>
</>
);
};
@@ -292,194 +210,51 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions
}) => {
// const { theme } = useTheme();
// const gallerySettings = theme.gallerySettings || {};
// const pattern = gallerySettings.mosaicPattern || 'structured';
// Pre-compute photos with their orientations
const photosWithOrientations = useMemo(() => {
return photos.map((photo, index) => ({
photo,
originalIndex: index,
orientation: getOrientation(photo)
}));
}, [photos]);
// Helper to render a MosaicPhoto with common props
const renderMosaicPhoto = (photoWithIndex: PhotoWithIndex, className: string = '') => {
const { photo, originalIndex } = photoWithIndex;
return (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(originalIndex);
}
}}
onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
className={className}
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => {
if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) {
onOpenPhotoWithFeedback(originalIndex);
}
}}
/>
);
};
// Render pattern based on type and arranged photos
const renderPattern = (pattern: PatternType, arranged: PhotoWithIndex[], keyPrefix: string) => {
switch (pattern) {
case 'tall-left-2-right':
// Portrait/tall photo on left, 2 landscape/square stacked on right
return (
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
{arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')}
<div className="grid grid-rows-2 gap-2">
{arranged[1] && renderMosaicPhoto(arranged[1])}
{arranged[2] && renderMosaicPhoto(arranged[2])}
</div>
</div>
);
case 'tall-right-2-left':
// 2 landscape/square stacked on left, portrait/tall on right
return (
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
<div className="grid grid-rows-2 gap-2">
{arranged[1] && renderMosaicPhoto(arranged[1])}
{arranged[2] && renderMosaicPhoto(arranged[2])}
</div>
{arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')}
</div>
);
case 'wide-top-2-bottom':
// Wide landscape on top, 2 photos below
return (
<div key={keyPrefix} className="grid grid-rows-2 gap-2 mb-2 h-[450px]">
<div className="h-[250px]">
{arranged[0] && renderMosaicPhoto(arranged[0])}
</div>
<div className="grid grid-cols-2 gap-2 h-[192px]">
{arranged[1] && renderMosaicPhoto(arranged[1])}
{arranged[2] && renderMosaicPhoto(arranged[2])}
</div>
</div>
);
case 'wide-bottom-2-top':
// 2 photos on top, wide landscape below
return (
<div key={keyPrefix} className="grid grid-rows-2 gap-2 mb-2 h-[450px]">
<div className="grid grid-cols-2 gap-2 h-[192px]">
{arranged[1] && renderMosaicPhoto(arranged[1])}
{arranged[2] && renderMosaicPhoto(arranged[2])}
</div>
<div className="h-[250px]">
{arranged[0] && renderMosaicPhoto(arranged[0])}
</div>
</div>
);
case 'three-rows':
// 3 horizontal rows - good for all landscapes
return (
<div key={keyPrefix} className="grid grid-rows-3 gap-2 mb-2 h-[500px]">
{arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))}
</div>
);
case 'two-portraits':
// 2 side-by-side tall photos
return (
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[500px]">
{arranged.slice(0, 2).map((p) => renderMosaicPhoto(p))}
</div>
);
case 'hero-wide':
// Single wide hero image
return (
<div key={keyPrefix} className="mb-2 h-[350px]">
{arranged[0] && renderMosaicPhoto(arranged[0])}
</div>
);
case 'hero-tall':
// Single tall hero image
return (
<div key={keyPrefix} className="mb-2 h-[500px] max-w-md mx-auto">
{arranged[0] && renderMosaicPhoto(arranged[0])}
</div>
);
case 'three-columns':
default:
// 3 equal columns - adaptive height based on content
const hasPortrait = arranged.some(p => p.orientation === 'portrait');
const height = hasPortrait ? 'h-[350px]' : 'h-[250px]';
return (
<div key={keyPrefix} className={`grid grid-cols-3 gap-2 mb-2 ${height}`}>
{arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))}
</div>
);
}
};
// Create 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 (
<div className="photo-grid w-full max-w-7xl mx-auto">
{renderMosaicLayout()}
<div
className="photo-grid w-full"
style={{
columnCount: 4,
columnGap: '8px',
}}
>
<style>{`
@media (max-width: 1280px) {
.photo-grid { column-count: 3 !important; }
}
@media (max-width: 1024px) {
.photo-grid { column-count: 2 !important; }
}
@media (max-width: 640px) {
.photo-grid { column-count: 1 !important; }
}
`}</style>
{photos.map((photo, index) => (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(index);
}
}}
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(index);
}
}}
/>
))}
</div>
);
};
+3 -3
View File
@@ -122,9 +122,9 @@ 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) {
@@ -161,7 +161,7 @@ export const GalleryPage: React.FC = () => {
setTheme(themeToApply);
}
}
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
}, [galleryInfo, settingsData, setTheme]);
React.useEffect(() => {
if (!resolvedSlug || isResolvingIdentifier) {
+1 -1
View File
@@ -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
+70
View File
@@ -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);
});
+121
View File
@@ -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 <<EOF
{
"galleryLayout": "masonry",
"gallerySettings": {
"masonryMode": "$masonry_mode",
"masonryGutter": 8,
"masonryRowHeight": 250
},
"primaryColor": "#3B82F6",
"backgroundColor": "#FFFFFF",
"textColor": "#1F2937"
}
EOF
)
# Escape for JSON
local color_theme_escaped=$(echo "$color_theme" | tr -d '\n' | sed 's/"/\\"/g')
local event_date=$(date +%Y-%m-%d)
CREATE_RESPONSE=$(curl -s -X POST "$BASE_URL/api/admin/events" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"event_type\": \"other\",
\"event_name\": \"$name\",
\"event_date\": \"$event_date\",
\"customer_name\": \"Test User\",
\"customer_email\": \"[email protected]\",
\"admin_email\": \"[email protected]\",
\"password\": \"MasonryTest2026!\",
\"welcome_message\": \"$description\",
\"color_theme\": \"$color_theme_escaped\",
\"expiration_days\": 30
}")
local event_id=$(echo "$CREATE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
local slug=$(echo "$CREATE_RESPONSE" | grep -o '"slug":"[^"]*"' | cut -d'"' -f4)
local share_link=$(echo "$CREATE_RESPONSE" | grep -o '"share_link":"[^"]*"' | cut -d'"' -f4)
if [ -z "$event_id" ]; then
echo " Failed to create gallery. Response: $CREATE_RESPONSE"
return 1
fi
echo " Created event ID: $event_id, slug: $slug"
echo " Share link: $share_link"
# Upload test images
echo " Uploading test images..."
for img in "$TEST_IMAGES_DIR"/*.jpg; do
if [ -f "$img" ]; then
local filename=$(basename "$img")
curl -s -X POST "$BASE_URL/api/admin/photos/$event_id/upload" \
-H "Authorization: Bearer $TOKEN" \
-F "photos=@$img" > /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"