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,
|
category_slug: photo.type,
|
||||||
size: photo.size_bytes,
|
size: photo.size_bytes,
|
||||||
uploaded_at: photo.uploaded_at,
|
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
|
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||||
requires_token: !useJwtUrl,
|
requires_token: !useJwtUrl,
|
||||||
// Feedback data
|
// Feedback data
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
// Generate thumbnail and extract metadata
|
// Generate thumbnail and extract metadata
|
||||||
let thumbnailPath;
|
let thumbnailPath;
|
||||||
let videoMetadata = null;
|
let videoMetadata = null;
|
||||||
|
let imageMetadata = null;
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// Process video: extract metadata and generate thumbnail
|
// Process video: extract metadata and generate thumbnail
|
||||||
@@ -169,8 +170,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
videoMetadata = result.metadata;
|
videoMetadata = result.metadata;
|
||||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||||
} else {
|
} else {
|
||||||
// Process image: generate thumbnail
|
// Process image: generate thumbnail and extract dimensions
|
||||||
thumbnailPath = await generateThumbnail(newPath);
|
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
|
// Calculate relative paths
|
||||||
@@ -206,6 +221,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
photoData.height = videoMetadata.height;
|
photoData.height = videoMetadata.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add image dimensions if available
|
||||||
|
if (!isVideo && imageMetadata) {
|
||||||
|
photoData.width = imageMetadata.width;
|
||||||
|
photoData.height = imageMetadata.height;
|
||||||
|
}
|
||||||
|
|
||||||
if (supportsReturning) {
|
if (supportsReturning) {
|
||||||
insertResult = await trx('photos')
|
insertResult = await trx('photos')
|
||||||
.insert(photoData)
|
.insert(photoData)
|
||||||
|
|||||||
@@ -366,6 +366,63 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
|||||||
carousel: <Play className="w-4 h-4" />,
|
carousel: <Play className="w-4 h-4" />,
|
||||||
timeline: <Clock className="w-4 h-4" />,
|
timeline: <Clock className="w-4 h-4" />,
|
||||||
hero: <Image 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> = ({
|
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
|||||||
carousel: <Play className="w-4 h-4" />,
|
carousel: <Play className="w-4 h-4" />,
|
||||||
timeline: <Clock className="w-4 h-4" />,
|
timeline: <Clock className="w-4 h-4" />,
|
||||||
hero: <Image 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> = ({
|
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 { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
import { feedbackService } from '../../../services/feedback.service';
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
|
import {
|
||||||
|
calculateJustifiedLayout,
|
||||||
|
createJustifiedPhotos,
|
||||||
|
type JustifiedLayoutItem,
|
||||||
|
} from '../../../utils/justifiedLayoutCalculator';
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
|
|
||||||
@@ -216,14 +221,19 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [columns, setColumns] = useState(3);
|
const [columns, setColumns] = useState(3);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
const gutter = gallerySettings.masonryGutter || 16;
|
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(() => {
|
useEffect(() => {
|
||||||
const updateColumns = () => {
|
const updateDimensions = () => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
const width = containerRef.current.offsetWidth;
|
const width = containerRef.current.offsetWidth;
|
||||||
|
setContainerWidth(width);
|
||||||
if (width < 640) setColumns(2);
|
if (width < 640) setColumns(2);
|
||||||
else if (width < 1024) setColumns(3);
|
else if (width < 1024) setColumns(3);
|
||||||
else if (width < 1280) setColumns(4);
|
else if (width < 1280) setColumns(4);
|
||||||
@@ -231,17 +241,187 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
updateColumns();
|
updateDimensions();
|
||||||
window.addEventListener('resize', updateColumns);
|
|
||||||
return () => window.removeEventListener('resize', updateColumns);
|
// 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
|
// Calculate justified layout for rows mode
|
||||||
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
|
const rowsLayout = useMemo(() => {
|
||||||
photos.forEach((photo, index) => {
|
if (mode !== 'rows' || containerWidth <= 0 || photos.length === 0) {
|
||||||
photoColumns[index % columns].push(photo);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -249,8 +429,8 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
style={{ gap: `${gutter}px` }}
|
style={{ gap: `${gutter}px` }}
|
||||||
>
|
>
|
||||||
{photoColumns.map((column, columnIndex) => (
|
{photoColumns.map((column, columnIndex) => (
|
||||||
<div
|
<div
|
||||||
key={columnIndex}
|
key={columnIndex}
|
||||||
className="flex-1 flex flex-col"
|
className="flex-1 flex flex-col"
|
||||||
style={{ gap: `${gutter}px` }}
|
style={{ gap: `${gutter}px` }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1258,7 +1258,8 @@
|
|||||||
"carousel": "Vollbild-Diashow mit Navigation",
|
"carousel": "Vollbild-Diashow mit Navigation",
|
||||||
"timeline": "Nach Datum organisierte Fotos",
|
"timeline": "Nach Datum organisierte Fotos",
|
||||||
"hero": "Hervorgehobenes Bild mit Raster darunter",
|
"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",
|
"layoutSettings": "Layout-Einstellungen",
|
||||||
"photoSpacing": "Foto-Abstand",
|
"photoSpacing": "Foto-Abstand",
|
||||||
@@ -1286,6 +1287,30 @@
|
|||||||
"week": "Woche",
|
"week": "Woche",
|
||||||
"month": "Monat"
|
"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",
|
"typographyAndStyle": "Typografie & Stil",
|
||||||
"bodyFont": "Fließtext-Schriftart",
|
"bodyFont": "Fließtext-Schriftart",
|
||||||
"headingFont": "Überschriften-Schriftart",
|
"headingFont": "Überschriften-Schriftart",
|
||||||
|
|||||||
@@ -973,7 +973,8 @@
|
|||||||
"carousel": "Full-screen slideshow with navigation",
|
"carousel": "Full-screen slideshow with navigation",
|
||||||
"timeline": "Photos organized by date",
|
"timeline": "Photos organized by date",
|
||||||
"hero": "Featured image with grid below",
|
"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",
|
"layoutSettings": "Layout Settings",
|
||||||
"photoSpacing": "Photo Spacing",
|
"photoSpacing": "Photo Spacing",
|
||||||
@@ -1001,6 +1002,30 @@
|
|||||||
"week": "Week",
|
"week": "Week",
|
||||||
"month": "Month"
|
"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",
|
"typographyAndStyle": "Typography & Style",
|
||||||
"bodyFont": "Body Font",
|
"bodyFont": "Body Font",
|
||||||
"headingFont": "Heading Font",
|
"headingFont": "Heading Font",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export interface GalleryLayoutSettings {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Masonry specific
|
// Masonry specific
|
||||||
|
masonryMode?: 'columns' | 'rows'; // columns = Pinterest-style, rows = Google Photos-style
|
||||||
masonryGutter?: number;
|
masonryGutter?: number;
|
||||||
|
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
||||||
|
masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row
|
||||||
|
|
||||||
// Carousel specific
|
// Carousel specific
|
||||||
carouselAutoplay?: boolean;
|
carouselAutoplay?: boolean;
|
||||||
@@ -127,7 +130,7 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
|
|
||||||
modernMasonry: {
|
modernMasonry: {
|
||||||
name: 'Modern Masonry',
|
name: 'Modern Masonry',
|
||||||
description: 'Pinterest-style dynamic layout',
|
description: 'Pinterest-style columns or Google Photos-style rows',
|
||||||
config: {
|
config: {
|
||||||
primaryColor: '#3b82f6',
|
primaryColor: '#3b82f6',
|
||||||
accentColor: '#1e40af',
|
accentColor: '#1e40af',
|
||||||
@@ -139,7 +142,10 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
gallerySettings: {
|
gallerySettings: {
|
||||||
spacing: 'tight',
|
spacing: 'tight',
|
||||||
photoAnimation: 'fade',
|
photoAnimation: 'fade',
|
||||||
masonryGutter: 16
|
masonryMode: 'columns',
|
||||||
|
masonryGutter: 16,
|
||||||
|
masonryRowHeight: 250,
|
||||||
|
masonryLastRowBehavior: 'left'
|
||||||
},
|
},
|
||||||
headerStyle: 'minimal',
|
headerStyle: 'minimal',
|
||||||
footerStyle: '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