diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 43ec6a61..0b682cc6 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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 diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js index 3be6a654..0cbd8cf7 100644 --- a/backend/src/services/photoProcessor.js +++ b/backend/src/services/photoProcessor.js @@ -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) diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index df1df102..fd2a5c94 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -366,6 +366,63 @@ export const ThemeCustomizerEnhanced: React.FC = ( )} + + {/* Masonry specific */} + {localTheme.galleryLayout === 'masonry' && ( + <> +
+ + +

+ {t('branding.masonryModeHint', 'Columns arranges photos vertically, rows fills horizontal lines')} +

+
+ + {/* Row-specific settings */} + {localTheme.gallerySettings?.masonryMode === 'rows' && ( + <> +
+ + updateGallerySettings('masonryRowHeight', parseInt(e.target.value))} + /> +

+ {t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')} +

+
+
+ + +
+ + )} + + )} )} diff --git a/frontend/src/components/admin/ThemeDisplay.tsx b/frontend/src/components/admin/ThemeDisplay.tsx index d2965fca..5587b055 100644 --- a/frontend/src/components/admin/ThemeDisplay.tsx +++ b/frontend/src/components/admin/ThemeDisplay.tsx @@ -26,7 +26,8 @@ const layoutIcons: Record = { carousel: , timeline: , hero: , - mosaic: + mosaic: , + justified: }; export const ThemeDisplay: React.FC = ({ diff --git a/frontend/src/components/admin/ThemeEditorModal.tsx b/frontend/src/components/admin/ThemeEditorModal.tsx index ecd1f904..168c2b2d 100644 --- a/frontend/src/components/admin/ThemeEditorModal.tsx +++ b/frontend/src/components/admin/ThemeEditorModal.tsx @@ -22,7 +22,8 @@ const layoutIcons: Record = { carousel: , timeline: , hero: , - mosaic: + mosaic: , + justified: }; export const ThemeEditorModal: React.FC = ({ diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index d72f4507..0e206844 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -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 = ({ const { theme } = useTheme(); const containerRef = useRef(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 = ({ } }; - 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(); + 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 ( +
+ {isCalculating ? ( + // Render a simple grid while calculating to get container width +
+ {photos.slice(0, 8).map((photo) => ( +
+ ))} +
+ ) : photos.map((photo, index) => { + const layoutItem = layoutItemMap.get(photo.id); + if (!layoutItem) return null; + + return ( +
onPhotoClick(index)} + > + + + {/* Feedback Indicators */} + {feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && ( +
+ {(photo.comment_count ?? 0) > 0 && ( +
+ + {photo.comment_count ?? 0} +
+ )} + {(photo.average_rating ?? 0) > 0 && ( +
+ + {Number(photo.average_rating ?? 0).toFixed(1)} +
+ )} + {(photo.like_count ?? 0) > 0 && ( +
+ + {photo.like_count ?? 0} +
+ )} +
+ )} + + {/* Hover overlay with actions */} +
+ {!isSelectionMode && ( + <> + + {allowDownloads && ( + + )} + + )} +
+ + {/* Selection Checkbox */} + +
+ ); + })} +
+ ); + } + + // COLUMNS MODE - Pinterest style masonry (default) return (
= ({ style={{ gap: `${gutter}px` }} > {photoColumns.map((column, columnIndex) => ( -
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 1204c26f..c0848fd7 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9ffc4174..f7da9643 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index a8120d96..fde1ab1a 100644 --- a/frontend/src/types/theme.types.ts +++ b/frontend/src/types/theme.types.ts @@ -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 = { 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 = { gallerySettings: { spacing: 'tight', photoAnimation: 'fade', - masonryGutter: 16 + masonryMode: 'columns', + masonryGutter: 16, + masonryRowHeight: 250, + masonryLastRowBehavior: 'left' }, headerStyle: 'minimal', footerStyle: 'minimal', diff --git a/frontend/src/utils/justifiedLayoutCalculator.ts b/frontend/src/utils/justifiedLayoutCalculator.ts new file mode 100644 index 00000000..70d9524b --- /dev/null +++ b/frontend/src/utils/justifiedLayoutCalculator.ts @@ -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)); +}