feat: add justified/rows layout mode to masonry gallery (#146)
Add Google Photos-style justified row layout as a mode within masonry: - Add masonryMode setting: 'columns' (Pinterest) or 'rows' (Google Photos) - Create justifiedLayoutCalculator utility for row-based layouts - Extract and store image dimensions on upload for layout calculations - Include width/height in gallery API response - Add row height and last row behavior controls to theme customizer - Support responsive container width detection with ResizeObserver Photos in rows mode maintain their aspect ratios while filling horizontal rows at a consistent height. The number of photos per row is automatically calculated based on target row height and photo dimensions. Closes #146
This commit is contained in:
@@ -368,6 +368,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Image dimensions for layout calculations
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
|
||||
@@ -158,6 +158,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
let imageMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
@@ -169,8 +170,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
// Process image: generate thumbnail and extract dimensions
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
|
||||
// Extract image dimensions using sharp
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(newPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
imageMetadata = {
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
};
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate relative paths
|
||||
@@ -206,6 +221,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
// Add image dimensions if available
|
||||
if (!isVideo && imageMetadata) {
|
||||
photoData.width = imageMetadata.width;
|
||||
photoData.height = imageMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert(photoData)
|
||||
|
||||
@@ -366,6 +366,63 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Masonry specific */}
|
||||
{localTheme.galleryLayout === 'masonry' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.masonryMode', 'Layout Mode')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryMode || 'columns'}
|
||||
onChange={(e) => updateGallerySettings('masonryMode', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="columns">{t('branding.masonryModeOptions.columns', 'Columns (Pinterest-style)')}</option>
|
||||
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Google Photos-style)')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('branding.masonryModeHint', 'Columns arranges photos vertically, rows fills horizontal lines')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row-specific settings */}
|
||||
{localTheme.gallerySettings?.masonryMode === 'rows' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.targetRowHeight', 'Target Row Height')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="150"
|
||||
max="400"
|
||||
value={localTheme.gallerySettings?.masonryRowHeight || 250}
|
||||
onChange={(e) => updateGallerySettings('masonryRowHeight', parseInt(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.lastRowBehavior', 'Last Row Alignment')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
|
||||
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
|
||||
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
|
||||
<option value="justify">{t('branding.lastRowOptions.justify', 'Justified (stretch)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -26,7 +26,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
||||
justified: <Layers className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
|
||||
@@ -22,7 +22,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
||||
justified: <Layers className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React, { useEffect, useRef, useState } 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';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import {
|
||||
calculateJustifiedLayout,
|
||||
createJustifiedPhotos,
|
||||
type JustifiedLayoutItem,
|
||||
} from '../../../utils/justifiedLayoutCalculator';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -216,14 +221,19 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const { theme } = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [columns, setColumns] = useState(3);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const gutter = gallerySettings.masonryGutter || 16;
|
||||
const mode = gallerySettings.masonryMode || 'columns';
|
||||
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
|
||||
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
|
||||
|
||||
// Calculate number of columns based on container width
|
||||
// Calculate number of columns based on container width (for columns mode)
|
||||
useEffect(() => {
|
||||
const updateColumns = () => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.offsetWidth;
|
||||
setContainerWidth(width);
|
||||
if (width < 640) setColumns(2);
|
||||
else if (width < 1024) setColumns(3);
|
||||
else if (width < 1280) setColumns(4);
|
||||
@@ -231,17 +241,187 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
updateColumns();
|
||||
window.addEventListener('resize', updateColumns);
|
||||
return () => window.removeEventListener('resize', updateColumns);
|
||||
updateDimensions();
|
||||
|
||||
// Use ResizeObserver for better performance
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
const width = entry.contentRect.width;
|
||||
if (width < 640) setColumns(2);
|
||||
else if (width < 1024) setColumns(3);
|
||||
else if (width < 1280) setColumns(4);
|
||||
else setColumns(5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Distribute photos across columns
|
||||
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
|
||||
photos.forEach((photo, index) => {
|
||||
photoColumns[index % columns].push(photo);
|
||||
});
|
||||
// Calculate justified layout for rows mode
|
||||
const rowsLayout = useMemo(() => {
|
||||
if (mode !== 'rows' || containerWidth <= 0 || photos.length === 0) {
|
||||
return { items: [], containerHeight: 0, rowCount: 0 };
|
||||
}
|
||||
|
||||
const justifiedPhotos = createJustifiedPhotos(
|
||||
photos.map((p) => ({
|
||||
id: p.id,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
}))
|
||||
);
|
||||
|
||||
return calculateJustifiedLayout(justifiedPhotos, {
|
||||
containerWidth,
|
||||
targetRowHeight,
|
||||
spacing: gutter,
|
||||
lastRowBehavior,
|
||||
});
|
||||
}, [mode, photos, containerWidth, targetRowHeight, gutter, lastRowBehavior]);
|
||||
|
||||
// Create a map for quick lookup of layout items by photo ID (rows mode)
|
||||
const layoutItemMap = useMemo(() => {
|
||||
const map = new Map<number, JustifiedLayoutItem>();
|
||||
for (const item of rowsLayout.items) {
|
||||
map.set(item.photoId, item);
|
||||
}
|
||||
return map;
|
||||
}, [rowsLayout.items]);
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
// ROWS MODE - Google Photos style justified layout
|
||||
if (mode === 'rows') {
|
||||
// Show loading state while measuring container width
|
||||
const isCalculating = containerWidth <= 0 || rowsLayout.items.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="photo-grid relative"
|
||||
style={{
|
||||
height: isCalculating ? 'auto' : rowsLayout.containerHeight,
|
||||
minHeight: isCalculating ? 200 : undefined
|
||||
}}
|
||||
>
|
||||
{isCalculating ? (
|
||||
// Render a simple grid while calculating to get container width
|
||||
<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>
|
||||
) : photos.map((photo, index) => {
|
||||
const layoutItem = layoutItemMap.get(photo.id);
|
||||
if (!layoutItem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="photo-card absolute group cursor-pointer transition-all duration-300 hover:z-10"
|
||||
style={{
|
||||
left: layoutItem.x,
|
||||
top: layoutItem.y,
|
||||
width: layoutItem.width,
|
||||
height: layoutItem.height,
|
||||
}}
|
||||
onClick={() => onPhotoClick(index)}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
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 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(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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// COLUMNS MODE - Pinterest style masonry (default)
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -249,8 +429,8 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
style={{ gap: `${gutter}px` }}
|
||||
>
|
||||
{photoColumns.map((column, columnIndex) => (
|
||||
<div
|
||||
key={columnIndex}
|
||||
<div
|
||||
key={columnIndex}
|
||||
className="flex-1 flex flex-col"
|
||||
style={{ gap: `${gutter}px` }}
|
||||
>
|
||||
|
||||
@@ -1258,7 +1258,8 @@
|
||||
"carousel": "Vollbild-Diashow mit Navigation",
|
||||
"timeline": "Nach Datum organisierte Fotos",
|
||||
"hero": "Hervorgehobenes Bild mit Raster darunter",
|
||||
"mosaic": "Künstlerisches Layout mit gemischten Größen"
|
||||
"mosaic": "Künstlerisches Layout mit gemischten Größen",
|
||||
"justified": "Zeilenbasiertes Layout mit Seitenverhältnis-Erhaltung"
|
||||
},
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"photoSpacing": "Foto-Abstand",
|
||||
@@ -1286,6 +1287,30 @@
|
||||
"week": "Woche",
|
||||
"month": "Monat"
|
||||
},
|
||||
"masonryMode": "Layout-Modus",
|
||||
"masonryModeOptions": {
|
||||
"columns": "Spalten (Pinterest-Stil)",
|
||||
"rows": "Zeilen (Google Fotos-Stil)"
|
||||
},
|
||||
"masonryModeHint": "Spalten ordnet Fotos vertikal an, Zeilen füllt horizontale Linien",
|
||||
"targetRowHeight": "Ziel-Zeilenhöhe",
|
||||
"targetRowHeightHint": "Höhe in Pixeln (150-400). Fotos werden skaliert, um in Zeilen zu passen.",
|
||||
"lastRowBehavior": "Letzte Zeile Ausrichtung",
|
||||
"lastRowOptions": {
|
||||
"left": "Linksbündig",
|
||||
"center": "Zentriert",
|
||||
"justify": "Blocksatz (gestreckt)"
|
||||
},
|
||||
"showHeroSection": "Hero-Bereich anzeigen",
|
||||
"showHeroSectionHint": "Zeigt ein hervorgehobenes Titelbild über der Galerie an",
|
||||
"heroHeight": "Hero-Bereich Höhe",
|
||||
"heroHeightOptions": {
|
||||
"small": "Klein (40-50%)",
|
||||
"medium": "Mittel (50-70%)",
|
||||
"large": "Groß (60-80%)"
|
||||
},
|
||||
"heroOverlayOpacity": "Hero-Overlay Deckkraft",
|
||||
"heroOverlayHint": "Verdunkelt das Titelbild für bessere Lesbarkeit",
|
||||
"typographyAndStyle": "Typografie & Stil",
|
||||
"bodyFont": "Fließtext-Schriftart",
|
||||
"headingFont": "Überschriften-Schriftart",
|
||||
|
||||
@@ -973,7 +973,8 @@
|
||||
"carousel": "Full-screen slideshow with navigation",
|
||||
"timeline": "Photos organized by date",
|
||||
"hero": "Featured image with grid below",
|
||||
"mosaic": "Artistic layout with mixed sizes"
|
||||
"mosaic": "Artistic layout with mixed sizes",
|
||||
"justified": "Row-based layout preserving aspect ratios"
|
||||
},
|
||||
"layoutSettings": "Layout Settings",
|
||||
"photoSpacing": "Photo Spacing",
|
||||
@@ -1001,6 +1002,30 @@
|
||||
"week": "Week",
|
||||
"month": "Month"
|
||||
},
|
||||
"masonryMode": "Layout Mode",
|
||||
"masonryModeOptions": {
|
||||
"columns": "Columns (Pinterest-style)",
|
||||
"rows": "Rows (Google Photos-style)"
|
||||
},
|
||||
"masonryModeHint": "Columns arranges photos vertically, rows fills horizontal lines",
|
||||
"targetRowHeight": "Target Row Height",
|
||||
"targetRowHeightHint": "Height in pixels (150-400). Photos will scale to fit rows.",
|
||||
"lastRowBehavior": "Last Row Alignment",
|
||||
"lastRowOptions": {
|
||||
"left": "Left aligned",
|
||||
"center": "Centered",
|
||||
"justify": "Justified (stretch)"
|
||||
},
|
||||
"showHeroSection": "Show Hero Section",
|
||||
"showHeroSectionHint": "Display a featured hero image above the justified gallery",
|
||||
"heroHeight": "Hero Section Height",
|
||||
"heroHeightOptions": {
|
||||
"small": "Small (40-50%)",
|
||||
"medium": "Medium (50-70%)",
|
||||
"large": "Large (60-80%)"
|
||||
},
|
||||
"heroOverlayOpacity": "Hero Overlay Opacity",
|
||||
"heroOverlayHint": "Darken the hero image to improve text readability",
|
||||
"typographyAndStyle": "Typography & Style",
|
||||
"bodyFont": "Body Font",
|
||||
"headingFont": "Heading Font",
|
||||
|
||||
@@ -15,7 +15,10 @@ export interface GalleryLayoutSettings {
|
||||
};
|
||||
|
||||
// Masonry specific
|
||||
masonryMode?: 'columns' | 'rows'; // columns = Pinterest-style, rows = Google Photos-style
|
||||
masonryGutter?: number;
|
||||
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
||||
masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row
|
||||
|
||||
// Carousel specific
|
||||
carouselAutoplay?: boolean;
|
||||
@@ -127,7 +130,7 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
|
||||
modernMasonry: {
|
||||
name: 'Modern Masonry',
|
||||
description: 'Pinterest-style dynamic layout',
|
||||
description: 'Pinterest-style columns or Google Photos-style rows',
|
||||
config: {
|
||||
primaryColor: '#3b82f6',
|
||||
accentColor: '#1e40af',
|
||||
@@ -139,7 +142,10 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
gallerySettings: {
|
||||
spacing: 'tight',
|
||||
photoAnimation: 'fade',
|
||||
masonryGutter: 16
|
||||
masonryMode: 'columns',
|
||||
masonryGutter: 16,
|
||||
masonryRowHeight: 250,
|
||||
masonryLastRowBehavior: 'left'
|
||||
},
|
||||
headerStyle: 'minimal',
|
||||
footerStyle: 'minimal',
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Justified/row-based gallery layout algorithm (similar to Google Photos or Flickr)
|
||||
*
|
||||
* This algorithm arranges photos in rows where each row has the same height,
|
||||
* and photos are scaled to fit the container width exactly.
|
||||
*/
|
||||
|
||||
export interface JustifiedPhoto {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
export interface JustifiedLayoutItem {
|
||||
photoId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rowIndex: number;
|
||||
}
|
||||
|
||||
export interface JustifiedLayoutOptions {
|
||||
containerWidth: number;
|
||||
targetRowHeight: number;
|
||||
spacing: number;
|
||||
maxRowHeight?: number; // Maximum row height (for last row)
|
||||
lastRowBehavior?: 'justify' | 'left' | 'center'; // How to handle the last row
|
||||
}
|
||||
|
||||
export interface JustifiedLayoutResult {
|
||||
items: JustifiedLayoutItem[];
|
||||
containerHeight: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the aspect ratio for a photo, defaulting to 1:1 if dimensions are missing
|
||||
*/
|
||||
function getAspectRatio(photo: JustifiedPhoto): number {
|
||||
// If aspectRatio is provided and valid, use it
|
||||
if (photo.aspectRatio && photo.aspectRatio > 0 && isFinite(photo.aspectRatio)) {
|
||||
return photo.aspectRatio;
|
||||
}
|
||||
|
||||
// Calculate from width/height if both are valid
|
||||
if (photo.width && photo.height && photo.width > 0 && photo.height > 0) {
|
||||
return photo.width / photo.height;
|
||||
}
|
||||
|
||||
// Default to square (1:1) if no valid dimensions
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the width a photo would have at a given height
|
||||
*/
|
||||
function getPhotoWidthAtHeight(photo: JustifiedPhoto, height: number): number {
|
||||
return height * getAspectRatio(photo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total width of photos in a row at a given height, including spacing
|
||||
*/
|
||||
function calculateRowWidth(
|
||||
photos: JustifiedPhoto[],
|
||||
height: number,
|
||||
spacing: number
|
||||
): number {
|
||||
if (photos.length === 0) return 0;
|
||||
|
||||
const photosWidth = photos.reduce(
|
||||
(sum, photo) => sum + getPhotoWidthAtHeight(photo, height),
|
||||
0
|
||||
);
|
||||
const spacingWidth = (photos.length - 1) * spacing;
|
||||
|
||||
return photosWidth + spacingWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the exact height needed for a row to fit the container width
|
||||
*/
|
||||
function calculateRowHeight(
|
||||
photos: JustifiedPhoto[],
|
||||
containerWidth: number,
|
||||
spacing: number
|
||||
): number {
|
||||
if (photos.length === 0) return 0;
|
||||
|
||||
// Total spacing between photos
|
||||
const totalSpacing = (photos.length - 1) * spacing;
|
||||
|
||||
// Available width for actual photo content
|
||||
const availableWidth = containerWidth - totalSpacing;
|
||||
|
||||
// Sum of aspect ratios determines how width is distributed
|
||||
const totalAspectRatio = photos.reduce(
|
||||
(sum, photo) => sum + getAspectRatio(photo),
|
||||
0
|
||||
);
|
||||
|
||||
// Height = available width / sum of aspect ratios
|
||||
// This ensures all photos at this height exactly fill the available width
|
||||
return availableWidth / totalAspectRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position photos in a row with calculated dimensions
|
||||
*/
|
||||
function positionRowPhotos(
|
||||
photos: JustifiedPhoto[],
|
||||
rowHeight: number,
|
||||
startY: number,
|
||||
rowIndex: number,
|
||||
spacing: number,
|
||||
containerWidth: number,
|
||||
alignment: 'justify' | 'left' | 'center' = 'justify'
|
||||
): JustifiedLayoutItem[] {
|
||||
if (photos.length === 0) return [];
|
||||
|
||||
const items: JustifiedLayoutItem[] = [];
|
||||
|
||||
// Calculate actual widths at this row height
|
||||
const photoWidths = photos.map(photo => getPhotoWidthAtHeight(photo, rowHeight));
|
||||
const totalPhotoWidth = photoWidths.reduce((sum, w) => sum + w, 0);
|
||||
const totalSpacing = (photos.length - 1) * spacing;
|
||||
const totalRowWidth = totalPhotoWidth + totalSpacing;
|
||||
|
||||
// Calculate starting X position based on alignment
|
||||
let startX = 0;
|
||||
if (alignment === 'center') {
|
||||
startX = (containerWidth - totalRowWidth) / 2;
|
||||
} else if (alignment === 'left') {
|
||||
startX = 0;
|
||||
}
|
||||
// For 'justify', startX is 0 and we'll adjust spacing below
|
||||
|
||||
let currentX = startX;
|
||||
|
||||
// For justified alignment, we might need to adjust spacing to fill the row exactly
|
||||
let actualSpacing = spacing;
|
||||
if (alignment === 'justify' && photos.length > 1) {
|
||||
// Calculate the spacing needed to fill the container exactly
|
||||
const widthDifference = containerWidth - totalRowWidth;
|
||||
actualSpacing = spacing + widthDifference / (photos.length - 1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const photo = photos[i];
|
||||
const width = photoWidths[i];
|
||||
|
||||
items.push({
|
||||
photoId: photo.id,
|
||||
x: currentX,
|
||||
y: startY,
|
||||
width: width,
|
||||
height: rowHeight,
|
||||
rowIndex: rowIndex,
|
||||
});
|
||||
|
||||
currentX += width + (i < photos.length - 1 ? actualSpacing : 0);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to calculate the justified layout
|
||||
*/
|
||||
export function calculateJustifiedLayout(
|
||||
photos: JustifiedPhoto[],
|
||||
options: JustifiedLayoutOptions
|
||||
): JustifiedLayoutResult {
|
||||
const {
|
||||
containerWidth,
|
||||
targetRowHeight,
|
||||
spacing,
|
||||
maxRowHeight = targetRowHeight * 1.5,
|
||||
lastRowBehavior = 'left',
|
||||
} = options;
|
||||
|
||||
// Handle edge cases
|
||||
if (photos.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
containerHeight: 0,
|
||||
rowCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (containerWidth <= 0) {
|
||||
return {
|
||||
items: [],
|
||||
containerHeight: 0,
|
||||
rowCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const items: JustifiedLayoutItem[] = [];
|
||||
const rows: JustifiedPhoto[][] = [];
|
||||
let currentRow: JustifiedPhoto[] = [];
|
||||
|
||||
// Step 1: Assign photos to rows
|
||||
for (const photo of photos) {
|
||||
// Try adding this photo to the current row
|
||||
const testRow = [...currentRow, photo];
|
||||
const rowWidthAtTarget = calculateRowWidth(testRow, targetRowHeight, spacing);
|
||||
|
||||
if (rowWidthAtTarget <= containerWidth) {
|
||||
// Photo fits in current row at target height
|
||||
currentRow.push(photo);
|
||||
} else if (currentRow.length === 0) {
|
||||
// Single photo that's wider than container - it gets its own row
|
||||
currentRow.push(photo);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
} else {
|
||||
// Adding this photo would exceed container width
|
||||
// Finalize current row and start new one
|
||||
rows.push(currentRow);
|
||||
currentRow = [photo];
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last row
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
// Step 2: Calculate positions for each row
|
||||
let currentY = 0;
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
||||
const row = rows[rowIndex];
|
||||
const isLastRow = rowIndex === rows.length - 1;
|
||||
|
||||
// Calculate the height needed to justify this row
|
||||
let rowHeight = calculateRowHeight(row, containerWidth, spacing);
|
||||
|
||||
// Determine alignment and height constraints for last row
|
||||
let alignment: 'justify' | 'left' | 'center' = 'justify';
|
||||
|
||||
if (isLastRow) {
|
||||
// For the last row, we might not want to stretch photos too much
|
||||
if (lastRowBehavior === 'left' || lastRowBehavior === 'center') {
|
||||
// Use target height for last row (or max height if calculated is larger)
|
||||
if (rowHeight > maxRowHeight) {
|
||||
rowHeight = maxRowHeight;
|
||||
} else if (rowHeight > targetRowHeight * 1.2) {
|
||||
// If photos would be stretched too much, cap at a reasonable height
|
||||
rowHeight = targetRowHeight;
|
||||
}
|
||||
alignment = lastRowBehavior;
|
||||
} else {
|
||||
// Justify last row, but cap at max height
|
||||
if (rowHeight > maxRowHeight) {
|
||||
rowHeight = maxRowHeight;
|
||||
alignment = 'left'; // Fall back to left align if we can't justify within max height
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-last rows, always justify (fit exactly to container)
|
||||
// The calculated height should fit perfectly
|
||||
}
|
||||
|
||||
// Position photos in this row
|
||||
const rowItems = positionRowPhotos(
|
||||
row,
|
||||
rowHeight,
|
||||
currentY,
|
||||
rowIndex,
|
||||
spacing,
|
||||
containerWidth,
|
||||
alignment
|
||||
);
|
||||
|
||||
items.push(...rowItems);
|
||||
currentY += rowHeight + spacing;
|
||||
}
|
||||
|
||||
// Remove the last spacing (no spacing after the last row)
|
||||
const containerHeight = currentY > 0 ? currentY - spacing : 0;
|
||||
|
||||
return {
|
||||
items,
|
||||
containerHeight,
|
||||
rowCount: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a JustifiedPhoto from raw photo data
|
||||
* Handles missing or invalid dimensions gracefully
|
||||
*/
|
||||
export function createJustifiedPhoto(
|
||||
id: number,
|
||||
width?: number | null,
|
||||
height?: number | null
|
||||
): JustifiedPhoto {
|
||||
const w = width && width > 0 ? width : 0;
|
||||
const h = height && height > 0 ? height : 0;
|
||||
|
||||
let aspectRatio: number;
|
||||
if (w > 0 && h > 0) {
|
||||
aspectRatio = w / h;
|
||||
} else {
|
||||
aspectRatio = 1; // Default to square
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
width: w || 1,
|
||||
height: h || 1,
|
||||
aspectRatio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch convert photo data to JustifiedPhoto array
|
||||
*/
|
||||
export function createJustifiedPhotos(
|
||||
photos: Array<{ id: number; width?: number | null; height?: number | null }>
|
||||
): JustifiedPhoto[] {
|
||||
return photos.map(photo => createJustifiedPhoto(photo.id, photo.width, photo.height));
|
||||
}
|
||||
Reference in New Issue
Block a user