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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+121
@@ -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"
|
||||
Reference in New Issue
Block a user