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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user