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:
Paul Nothaft
2026-01-27 09:58:09 +01:00
parent 85170b883f
commit e081b56a44
10 changed files with 666 additions and 20 deletions
+3
View File
@@ -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
+22 -1
View File
@@ -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` }}
>
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+8 -2
View File
@@ -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));
}