Merge pull request #153 from the-luap/feat/new-features
feat: improve gallery layouts with aspect-ratio-aware masonry and mosaic modes (#146)
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Backfill photo dimensions
|
||||||
|
*
|
||||||
|
* This migration extracts width/height from existing photos that don't have
|
||||||
|
* these dimensions stored. This is needed for aspect-ratio-aware layouts
|
||||||
|
* (masonry, mosaic, justified) to work properly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// Check if the width/height columns exist
|
||||||
|
const hasWidth = await knex.schema.hasColumn('photos', 'width');
|
||||||
|
const hasHeight = await knex.schema.hasColumn('photos', 'height');
|
||||||
|
|
||||||
|
if (!hasWidth || !hasHeight) {
|
||||||
|
console.log('[Migration 064] Width/height columns not found, skipping backfill');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get storage path
|
||||||
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
// Find photos without dimensions
|
||||||
|
const photos = await knex('photos')
|
||||||
|
.whereNull('width')
|
||||||
|
.orWhereNull('height')
|
||||||
|
.select('id', 'path', 'filename', 'media_type');
|
||||||
|
|
||||||
|
console.log(`[Migration 064] Found ${photos.length} photos without dimensions`);
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sharp dynamically (only needed during migration)
|
||||||
|
let sharp;
|
||||||
|
try {
|
||||||
|
sharp = require('sharp');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Migration 064] Sharp not available, skipping backfill:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
try {
|
||||||
|
// Skip videos - they need ffprobe for metadata
|
||||||
|
if (photo.media_type === 'video') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the full file path
|
||||||
|
let fullPath;
|
||||||
|
if (photo.path) {
|
||||||
|
// Path is relative to events/active directory
|
||||||
|
fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Migration 064] Photo ${photo.id} (${photo.filename}) has no path, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
console.warn(`[Migration 064] Photo ${photo.id} file not found: ${fullPath}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract dimensions using sharp
|
||||||
|
const metadata = await sharp(fullPath).metadata();
|
||||||
|
|
||||||
|
if (metadata.width && metadata.height) {
|
||||||
|
await knex('photos')
|
||||||
|
.where('id', photo.id)
|
||||||
|
.update({
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
|
||||||
|
if (updated % 50 === 0) {
|
||||||
|
console.log(`[Migration 064] Updated ${updated} photos...`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[Migration 064] Could not extract dimensions for photo ${photo.id}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Migration 064] Error processing photo ${photo.id}:`, err.message);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Migration 064] Completed: ${updated} updated, ${failed} failed`);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// This migration only adds data, no rollback needed
|
||||||
|
// We don't want to null out dimensions on rollback as they're still valid
|
||||||
|
console.log('[Migration 064] Rollback: No action needed (data-only migration)');
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
|
import { 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';
|
||||||
@@ -11,9 +11,6 @@ import {
|
|||||||
} from '../../../utils/justifiedLayoutCalculator';
|
} from '../../../utils/justifiedLayoutCalculator';
|
||||||
// Flickr's justified-layout library
|
// Flickr's justified-layout library
|
||||||
import justifiedLayout from 'justified-layout';
|
import justifiedLayout from 'justified-layout';
|
||||||
// React Photo Album for Google Photos-style layout
|
|
||||||
import { RowsPhotoAlbum, RenderPhotoContext } from 'react-photo-album';
|
|
||||||
import 'react-photo-album/rows.css';
|
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
|
|
||||||
@@ -34,6 +31,8 @@ interface MasonryPhotoProps {
|
|||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
onQuickComment?: () => void;
|
onQuickComment?: () => void;
|
||||||
|
// Column width for calculating proper aspect-ratio-based height
|
||||||
|
columnWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||||
@@ -48,19 +47,28 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
slug,
|
slug,
|
||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
onQuickComment
|
onQuickComment,
|
||||||
|
columnWidth = 300
|
||||||
}) => {
|
}) => {
|
||||||
const [imageHeight, setImageHeight] = useState<number>(200);
|
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
|
|
||||||
// Generate random heights for masonry effect
|
// Calculate height based on actual photo aspect ratio
|
||||||
useEffect(() => {
|
// This preserves the photo's natural proportions in the masonry layout
|
||||||
const heights = [200, 250, 300, 350, 400];
|
const imageHeight = useMemo(() => {
|
||||||
const randomHeight = heights[Math.floor(Math.random() * heights.length)];
|
const photoWidth = photo.width || 800;
|
||||||
setImageHeight(randomHeight);
|
const photoHeight = photo.height || 600;
|
||||||
}, [photo.id]);
|
const aspectRatio = photoWidth / photoHeight;
|
||||||
|
|
||||||
|
// Calculate height based on column width and aspect ratio
|
||||||
|
// Clamp to reasonable min/max heights for visual consistency
|
||||||
|
const calculatedHeight = columnWidth / aspectRatio;
|
||||||
|
const minHeight = 150;
|
||||||
|
const maxHeight = 500;
|
||||||
|
|
||||||
|
return Math.max(minHeight, Math.min(maxHeight, calculatedHeight));
|
||||||
|
}, [photo.width, photo.height, columnWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -233,6 +241,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
|
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
|
||||||
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
|
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
|
||||||
|
|
||||||
|
|
||||||
// Calculate number of columns based on container width (for columns mode)
|
// Calculate number of columns based on container width (for columns mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateDimensions = () => {
|
const updateDimensions = () => {
|
||||||
@@ -325,30 +334,51 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
return result;
|
return result;
|
||||||
}, [mode, photos, containerWidth, targetRowHeight, gutter]);
|
}, [mode, photos, containerWidth, targetRowHeight, gutter]);
|
||||||
|
|
||||||
// Prepare photos for react-photo-album (justified mode)
|
// Distribute photos across columns using greedy "shortest column" algorithm
|
||||||
const albumPhotos = useMemo(() => {
|
// This creates a more balanced masonry layout instead of round-robin
|
||||||
if (mode !== 'justified' || photos.length === 0) {
|
const photoColumns: Photo[][] = useMemo(() => {
|
||||||
return [];
|
if (mode !== 'columns' || photos.length === 0) {
|
||||||
|
return Array.from({ length: columns }, () => []);
|
||||||
}
|
}
|
||||||
|
|
||||||
return photos.map((photo, index) => ({
|
const cols: Photo[][] = Array.from({ length: columns }, () => []);
|
||||||
src: photo.thumbnail_url || photo.url,
|
const colHeights: number[] = Array(columns).fill(0);
|
||||||
width: photo.width || 800,
|
|
||||||
height: photo.height || 600,
|
|
||||||
key: `photo-${photo.id}`,
|
|
||||||
// Store original data for click handling
|
|
||||||
originalIndex: index,
|
|
||||||
photoData: photo,
|
|
||||||
}));
|
|
||||||
}, [mode, photos]);
|
|
||||||
|
|
||||||
// Distribute photos across columns (for columns mode)
|
// Calculate approximate column width for height estimation
|
||||||
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
|
const approxColWidth = containerWidth > 0 ? (containerWidth - (columns - 1) * gutter) / columns : 300;
|
||||||
if (mode === 'columns') {
|
|
||||||
photos.forEach((photo, index) => {
|
photos.forEach((photo) => {
|
||||||
photoColumns[index % columns].push(photo);
|
// Find the shortest column
|
||||||
|
let shortestCol = 0;
|
||||||
|
let minHeight = colHeights[0];
|
||||||
|
for (let i = 1; i < columns; i++) {
|
||||||
|
if (colHeights[i] < minHeight) {
|
||||||
|
minHeight = colHeights[i];
|
||||||
|
shortestCol = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add photo to shortest column
|
||||||
|
cols[shortestCol].push(photo);
|
||||||
|
|
||||||
|
// Estimate height based on aspect ratio
|
||||||
|
const photoWidth = photo.width || 800;
|
||||||
|
const photoHeight = photo.height || 600;
|
||||||
|
const aspectRatio = photoWidth / photoHeight;
|
||||||
|
const estimatedHeight = Math.max(150, Math.min(500, approxColWidth / aspectRatio));
|
||||||
|
colHeights[shortestCol] += estimatedHeight + gutter;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
return cols;
|
||||||
|
}, [mode, photos, columns, containerWidth, gutter]);
|
||||||
|
|
||||||
|
// Calculate approximate column width for aspect ratio calculations
|
||||||
|
const columnWidth = useMemo(() => {
|
||||||
|
if (containerWidth <= 0 || columns <= 0) return 300;
|
||||||
|
// Account for gaps between columns
|
||||||
|
const totalGaps = (columns - 1) * gutter;
|
||||||
|
return (containerWidth - totalGaps) / columns;
|
||||||
|
}, [containerWidth, columns, gutter]);
|
||||||
|
|
||||||
// ROWS MODE - Google Photos style justified layout
|
// ROWS MODE - Google Photos style justified layout
|
||||||
if (mode === 'rows') {
|
if (mode === 'rows') {
|
||||||
@@ -590,64 +620,86 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JUSTIFIED MODE - React Photo Album (Google Photos style with Knuth-Plass algorithm)
|
// QUILTED MODE - Mixed sizes based on aspect ratio
|
||||||
if (mode === 'justified') {
|
// Landscape photos span 2 columns, portrait photos span 2 rows
|
||||||
// Custom render function for photos in react-photo-album
|
if (mode === 'quilted') {
|
||||||
// The render function receives (props, context) where context contains photo, index, width, height
|
// Determine grid span based on aspect ratio
|
||||||
const renderPhoto = useCallback((_props: { onClick?: React.MouseEventHandler }, context: RenderPhotoContext<typeof albumPhotos[0]>) => {
|
const getSpanClasses = (photo: Photo): string => {
|
||||||
const { photo, width, height } = context;
|
const width = photo.width || 800;
|
||||||
const photoData = photo.photoData;
|
const height = photo.height || 600;
|
||||||
const originalIndex = photo.originalIndex;
|
const ratio = width / height;
|
||||||
|
|
||||||
|
// Very wide landscape (panoramic) - span 2 columns
|
||||||
|
if (ratio > 1.5) return 'col-span-2';
|
||||||
|
// Very tall portrait - span 2 rows
|
||||||
|
if (ratio < 0.7) return 'row-span-2';
|
||||||
|
// Normal aspect ratio - single cell
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ width, height }}
|
ref={containerRef}
|
||||||
className="photo-card group cursor-pointer transition-all duration-300 hover:z-10 relative"
|
className="photo-grid"
|
||||||
onClick={() => onPhotoClick(originalIndex)}
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
|
gridAutoRows: '200px',
|
||||||
|
gap: `${gutter}px`,
|
||||||
|
gridAutoFlow: 'dense', // Fill gaps automatically
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{photos.map((photo, index) => {
|
||||||
|
const spanClasses = getSpanClasses(photo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
className={`photo-card group cursor-pointer relative overflow-hidden rounded-lg bg-neutral-100 ${spanClasses}`}
|
||||||
|
onClick={() => onPhotoClick(index)}
|
||||||
>
|
>
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={photoData.thumbnail_url || photoData.url}
|
src={photo.thumbnail_url || photo.url}
|
||||||
alt={photoData.filename}
|
alt={photo.filename}
|
||||||
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]"
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
isGallery={true}
|
isGallery={true}
|
||||||
protectFromDownload={!allowDownloads}
|
protectFromDownload={!allowDownloads}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Feedback Indicators */}
|
{/* Feedback Indicators */}
|
||||||
{feedbackEnabled && ((photoData.comment_count ?? 0) > 0 || (photoData.average_rating ?? 0) > 0 || (photoData.like_count ?? 0) > 0) && (
|
{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">
|
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||||
{(photoData.comment_count ?? 0) > 0 && (
|
{(photo.comment_count ?? 0) > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
<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" />
|
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{photoData.comment_count ?? 0}</span>
|
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(photoData.average_rating ?? 0) > 0 && (
|
{(photo.average_rating ?? 0) > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
<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" />
|
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{Number(photoData.average_rating ?? 0).toFixed(1)}</span>
|
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(photoData.like_count ?? 0) > 0 && (
|
{(photo.like_count ?? 0) > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
<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" />
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{photoData.like_count ?? 0}</span>
|
<span className="text-xs font-medium text-neutral-700">{photo.like_count ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hover overlay with actions */}
|
{/* 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">
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||||
{!isSelectionMode && (
|
{!isSelectionMode && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="View full size"
|
aria-label="View full size"
|
||||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||||
onClick={(e) => { e.stopPropagation(); onPhotoClick(originalIndex); }}
|
onClick={(e) => { e.stopPropagation(); onPhotoClick(index); }}
|
||||||
>
|
>
|
||||||
<Maximize2 className="w-5 h-5 text-white" />
|
<Maximize2 className="w-5 h-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -656,7 +708,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Download photo"
|
aria-label="Download photo"
|
||||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||||
onClick={(e) => { e.stopPropagation(); onDownload(photoData, e); }}
|
onClick={(e) => { e.stopPropagation(); onDownload(photo, e); }}
|
||||||
>
|
>
|
||||||
<Download className="w-5 h-5 text-white" />
|
<Download className="w-5 h-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -668,49 +720,27 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
{/* Selection Checkbox */}
|
{/* Selection Checkbox */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Select ${photoData.filename}`}
|
aria-label={`Select ${photo.filename}`}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
aria-checked={selectedPhotos.has(photoData.id)}
|
aria-checked={selectedPhotos.has(photo.id)}
|
||||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||||
selectedPhotos.has(photoData.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photoData.id); }}
|
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
|
||||||
>
|
>
|
||||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photoData.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
<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(photoData.id) && <Check className="w-4 h-4 text-white" />}
|
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{photoData.type === 'collage' && (
|
{photo.type === 'collage' && (
|
||||||
<div className="absolute bottom-2 left-2">
|
<div className="absolute bottom-2 left-2">
|
||||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
|
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [onPhotoClick, onDownload, allowDownloads, feedbackEnabled, isSelectionMode, selectedPhotos, onPhotoSelect]);
|
})}
|
||||||
|
|
||||||
if (albumPhotos.length === 0) {
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="photo-grid">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
||||||
{photos.slice(0, 8).map((photo) => (
|
|
||||||
<div key={photo.id} className="aspect-square bg-neutral-200 rounded-lg animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="photo-grid">
|
|
||||||
<RowsPhotoAlbum
|
|
||||||
photos={albumPhotos}
|
|
||||||
targetRowHeight={targetRowHeight}
|
|
||||||
rowConstraints={{ minPhotos: 1, maxPhotos: 6 }}
|
|
||||||
spacing={gutter}
|
|
||||||
render={{ photo: renderPhoto }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -744,6 +774,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
|
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
|
||||||
|
columnWidth={columnWidth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,107 +1,22 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
|
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
|
||||||
// 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 type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
|
|
||||||
// Orientation types for aspect-ratio-aware layout
|
/**
|
||||||
type Orientation = 'landscape' | 'portrait' | 'square';
|
* Mosaic Gallery Layout
|
||||||
|
*
|
||||||
interface PhotoWithIndex {
|
* Uses CSS Columns for a gap-free masonry/mosaic effect.
|
||||||
photo: Photo;
|
* Images flow vertically within columns, maintaining their natural aspect ratios.
|
||||||
originalIndex: number;
|
* This approach eliminates gaps that occur with CSS Grid span rules.
|
||||||
orientation: Orientation;
|
*
|
||||||
}
|
* Based on:
|
||||||
|
* - https://css-tricks.com/seamless-responsive-photo-grid/
|
||||||
// Get photo orientation based on aspect ratio
|
* - https://www.30secondsofcode.org/css/s/image-mosaic/
|
||||||
const getOrientation = (photo: Photo): Orientation => {
|
*/
|
||||||
const width = photo.width || 1;
|
|
||||||
const height = photo.height || 1;
|
|
||||||
const ratio = width / height;
|
|
||||||
|
|
||||||
if (ratio > 1.2) return 'landscape';
|
|
||||||
if (ratio < 0.83) return 'portrait';
|
|
||||||
return 'square';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pattern types that work well with different orientation combinations
|
|
||||||
type PatternType =
|
|
||||||
| 'tall-left-2-right' // Tall photo left, 2 stacked right (good for 1 portrait + 2 landscape)
|
|
||||||
| 'tall-right-2-left' // Tall photo right, 2 stacked left (good for 1 portrait + 2 landscape)
|
|
||||||
| 'wide-top-2-bottom' // Wide photo top, 2 below (good for 1 landscape + 2 portrait)
|
|
||||||
| 'wide-bottom-2-top' // Wide photo bottom, 2 above (good for 1 landscape + 2 portrait)
|
|
||||||
| 'three-columns' // 3 equal columns (good for similar orientations)
|
|
||||||
| 'three-rows' // 3 equal rows (good for landscapes)
|
|
||||||
| 'two-portraits' // 2 tall side by side (good for portraits)
|
|
||||||
| 'hero-wide' // Single wide landscape hero
|
|
||||||
| 'hero-tall'; // Single tall portrait hero
|
|
||||||
|
|
||||||
// Analyze a group of photos and select the best pattern
|
|
||||||
const selectBestPattern = (photosWithIndex: PhotoWithIndex[]): { pattern: PatternType; arranged: PhotoWithIndex[] } => {
|
|
||||||
const count = photosWithIndex.length;
|
|
||||||
|
|
||||||
if (count === 1) {
|
|
||||||
const orientation = photosWithIndex[0].orientation;
|
|
||||||
return {
|
|
||||||
pattern: orientation === 'portrait' ? 'hero-tall' : 'hero-wide',
|
|
||||||
arranged: photosWithIndex
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count === 2) {
|
|
||||||
const portraits = photosWithIndex.filter(p => p.orientation === 'portrait');
|
|
||||||
const landscapes = photosWithIndex.filter(p => p.orientation === 'landscape');
|
|
||||||
|
|
||||||
if (portraits.length === 2) {
|
|
||||||
return { pattern: 'two-portraits', arranged: photosWithIndex };
|
|
||||||
}
|
|
||||||
// For 2 photos, treat as part of a larger pattern or use columns
|
|
||||||
return { pattern: 'three-columns', arranged: photosWithIndex };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count >= 3) {
|
|
||||||
const portraits = photosWithIndex.filter(p => p.orientation === 'portrait');
|
|
||||||
const landscapes = photosWithIndex.filter(p => p.orientation === 'landscape');
|
|
||||||
const squares = photosWithIndex.filter(p => p.orientation === 'square');
|
|
||||||
|
|
||||||
// All or mostly portraits - use vertical-friendly layout
|
|
||||||
if (portraits.length >= 2) {
|
|
||||||
if (landscapes.length >= 1) {
|
|
||||||
// 2 portraits + 1 landscape: landscape on top, portraits below
|
|
||||||
const arranged = [...landscapes.slice(0, 1), ...portraits.slice(0, 2)];
|
|
||||||
return { pattern: 'wide-top-2-bottom', arranged };
|
|
||||||
}
|
|
||||||
// All portraits - stack them or use 3 columns
|
|
||||||
return { pattern: 'three-columns', arranged: photosWithIndex.slice(0, 3) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// All or mostly landscapes - use horizontal-friendly layout
|
|
||||||
if (landscapes.length >= 2) {
|
|
||||||
if (portraits.length >= 1) {
|
|
||||||
// 1 portrait + 2 landscapes: portrait on left, landscapes stacked right
|
|
||||||
const arranged = [...portraits.slice(0, 1), ...landscapes.slice(0, 2)];
|
|
||||||
return { pattern: 'tall-left-2-right', arranged };
|
|
||||||
}
|
|
||||||
// All landscapes - use rows
|
|
||||||
return { pattern: 'three-rows', arranged: photosWithIndex.slice(0, 3) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mixed or mostly squares - use standard patterns with smart placement
|
|
||||||
if (portraits.length === 1 && landscapes.length === 1) {
|
|
||||||
// 1 portrait + 1 landscape + 1 square
|
|
||||||
const arranged = [...portraits, ...squares.slice(0, 1), ...landscapes];
|
|
||||||
return { pattern: 'tall-left-2-right', arranged: arranged.slice(0, 3) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to 3 columns for mixed content
|
|
||||||
return { pattern: 'three-columns', arranged: photosWithIndex.slice(0, 3) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { pattern: 'three-columns', arranged: photosWithIndex };
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MosaicPhotoProps {
|
interface MosaicPhotoProps {
|
||||||
photo: Photo;
|
photo: Photo;
|
||||||
@@ -110,7 +25,6 @@ interface MosaicPhotoProps {
|
|||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onDownload: (e: React.MouseEvent) => void;
|
onDownload: (e: React.MouseEvent) => void;
|
||||||
onToggleSelect: () => void;
|
onToggleSelect: () => void;
|
||||||
className?: string;
|
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
@@ -129,7 +43,6 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
className = '',
|
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
slug,
|
slug,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
@@ -142,25 +55,30 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
const [likedLocal, setLikedLocal] = React.useState(false);
|
const [likedLocal, setLikedLocal] = React.useState(false);
|
||||||
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
|
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
|
||||||
|
|
||||||
|
// Calculate aspect ratio from photo dimensions (fallback to 1 if unknown)
|
||||||
|
const aspectRatio = (photo.width && photo.height) ? photo.width / photo.height : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 mb-2"
|
||||||
|
style={{
|
||||||
|
breakInside: 'avoid',
|
||||||
|
aspectRatio: aspectRatio.toString()
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick(e);
|
onClick(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={photo.thumbnail_url || photo.url}
|
src={photo.thumbnail_url || photo.url}
|
||||||
alt={photo.filename}
|
alt={photo.filename}
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
isGallery={true}
|
isGallery={true}
|
||||||
protectFromDownload={!allowDownloads}
|
protectFromDownload={!allowDownloads}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||||
{!isSelectionMode && (
|
{!isSelectionMode && (
|
||||||
@@ -292,23 +210,26 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
feedbackOptions
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
// const { theme } = useTheme();
|
|
||||||
// const gallerySettings = theme.gallerySettings || {};
|
|
||||||
// const pattern = gallerySettings.mosaicPattern || 'structured';
|
|
||||||
|
|
||||||
// Pre-compute photos with their orientations
|
|
||||||
const photosWithOrientations = useMemo(() => {
|
|
||||||
return photos.map((photo, index) => ({
|
|
||||||
photo,
|
|
||||||
originalIndex: index,
|
|
||||||
orientation: getOrientation(photo)
|
|
||||||
}));
|
|
||||||
}, [photos]);
|
|
||||||
|
|
||||||
// Helper to render a MosaicPhoto with common props
|
|
||||||
const renderMosaicPhoto = (photoWithIndex: PhotoWithIndex, className: string = '') => {
|
|
||||||
const { photo, originalIndex } = photoWithIndex;
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className="photo-grid w-full"
|
||||||
|
style={{
|
||||||
|
columnCount: 4,
|
||||||
|
columnGap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.photo-grid { column-count: 3 !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.photo-grid { column-count: 2 !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.photo-grid { column-count: 1 !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{photos.map((photo, index) => (
|
||||||
<MosaicPhoto
|
<MosaicPhoto
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
@@ -318,168 +239,22 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
if (isSelectionMode && onPhotoSelect) {
|
if (isSelectionMode && onPhotoSelect) {
|
||||||
onPhotoSelect(photo.id);
|
onPhotoSelect(photo.id);
|
||||||
} else {
|
} else {
|
||||||
onPhotoClick(originalIndex);
|
onPhotoClick(index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDownload={(e) => onDownload(photo, e)}
|
onDownload={(e) => onDownload(photo, e)}
|
||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
className={className}
|
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
onQuickComment={() => {
|
onQuickComment={() => {
|
||||||
if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) {
|
if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) {
|
||||||
onOpenPhotoWithFeedback(originalIndex);
|
onOpenPhotoWithFeedback(index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
};
|
|
||||||
|
|
||||||
// Render pattern based on type and arranged photos
|
|
||||||
const renderPattern = (pattern: PatternType, arranged: PhotoWithIndex[], keyPrefix: string) => {
|
|
||||||
switch (pattern) {
|
|
||||||
case 'tall-left-2-right':
|
|
||||||
// Portrait/tall photo on left, 2 landscape/square stacked on right
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
|
||||||
{arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')}
|
|
||||||
<div className="grid grid-rows-2 gap-2">
|
|
||||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
|
||||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'tall-right-2-left':
|
|
||||||
// 2 landscape/square stacked on left, portrait/tall on right
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
|
||||||
<div className="grid grid-rows-2 gap-2">
|
|
||||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
|
||||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
|
||||||
</div>
|
|
||||||
{arranged[0] && renderMosaicPhoto(arranged[0], 'col-span-1')}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'wide-top-2-bottom':
|
|
||||||
// Wide landscape on top, 2 photos below
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="grid grid-rows-2 gap-2 mb-2 h-[450px]">
|
|
||||||
<div className="h-[250px]">
|
|
||||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 h-[192px]">
|
|
||||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
|
||||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'wide-bottom-2-top':
|
|
||||||
// 2 photos on top, wide landscape below
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="grid grid-rows-2 gap-2 mb-2 h-[450px]">
|
|
||||||
<div className="grid grid-cols-2 gap-2 h-[192px]">
|
|
||||||
{arranged[1] && renderMosaicPhoto(arranged[1])}
|
|
||||||
{arranged[2] && renderMosaicPhoto(arranged[2])}
|
|
||||||
</div>
|
|
||||||
<div className="h-[250px]">
|
|
||||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'three-rows':
|
|
||||||
// 3 horizontal rows - good for all landscapes
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="grid grid-rows-3 gap-2 mb-2 h-[500px]">
|
|
||||||
{arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'two-portraits':
|
|
||||||
// 2 side-by-side tall photos
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="grid grid-cols-2 gap-2 mb-2 h-[500px]">
|
|
||||||
{arranged.slice(0, 2).map((p) => renderMosaicPhoto(p))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'hero-wide':
|
|
||||||
// Single wide hero image
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="mb-2 h-[350px]">
|
|
||||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'hero-tall':
|
|
||||||
// Single tall hero image
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className="mb-2 h-[500px] max-w-md mx-auto">
|
|
||||||
{arranged[0] && renderMosaicPhoto(arranged[0])}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'three-columns':
|
|
||||||
default:
|
|
||||||
// 3 equal columns - adaptive height based on content
|
|
||||||
const hasPortrait = arranged.some(p => p.orientation === 'portrait');
|
|
||||||
const height = hasPortrait ? 'h-[350px]' : 'h-[250px]';
|
|
||||||
return (
|
|
||||||
<div key={keyPrefix} className={`grid grid-cols-3 gap-2 mb-2 ${height}`}>
|
|
||||||
{arranged.slice(0, 3).map((p) => renderMosaicPhoto(p))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create aspect-ratio-aware mosaic layout
|
|
||||||
const renderMosaicLayout = () => {
|
|
||||||
const elements: React.ReactNode[] = [];
|
|
||||||
let index = 0;
|
|
||||||
let patternCount = 0;
|
|
||||||
|
|
||||||
while (index < photosWithOrientations.length) {
|
|
||||||
const remaining = photosWithOrientations.length - index;
|
|
||||||
|
|
||||||
// Determine group size based on remaining photos
|
|
||||||
let groupSize = 3;
|
|
||||||
if (remaining === 1) groupSize = 1;
|
|
||||||
else if (remaining === 2) groupSize = 2;
|
|
||||||
else if (remaining === 4) groupSize = 2; // Split 4 into 2+2 for balance
|
|
||||||
else groupSize = 3;
|
|
||||||
|
|
||||||
// Get the next group of photos
|
|
||||||
const group = photosWithOrientations.slice(index, index + groupSize);
|
|
||||||
|
|
||||||
// Select the best pattern for this group based on orientations
|
|
||||||
const { pattern, arranged } = selectBestPattern(group);
|
|
||||||
|
|
||||||
// Alternate some patterns for visual variety
|
|
||||||
let finalPattern = pattern;
|
|
||||||
if (pattern === 'tall-left-2-right' && patternCount % 2 === 1) {
|
|
||||||
finalPattern = 'tall-right-2-left';
|
|
||||||
} else if (pattern === 'wide-top-2-bottom' && patternCount % 2 === 1) {
|
|
||||||
finalPattern = 'wide-bottom-2-top';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the pattern
|
|
||||||
elements.push(renderPattern(finalPattern, arranged, `pattern-${index}`));
|
|
||||||
|
|
||||||
index += groupSize;
|
|
||||||
patternCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="photo-grid w-full max-w-7xl mx-auto">
|
|
||||||
{renderMosaicLayout()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [settingsData, isAuthenticated, i18n]);
|
}, [settingsData, isAuthenticated, i18n]);
|
||||||
|
|
||||||
// Apply theme for login page
|
// Apply theme for gallery (both login page and authenticated view)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAuthenticated && galleryInfo && settingsData) {
|
if (galleryInfo && settingsData) {
|
||||||
let themeToApply = null;
|
let themeToApply = null;
|
||||||
|
|
||||||
if (galleryInfo.color_theme) {
|
if (galleryInfo.color_theme) {
|
||||||
@@ -161,7 +161,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
setTheme(themeToApply);
|
setTheme(themeToApply);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
}, [galleryInfo, settingsData, setTheme]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!resolvedSlug || isResolvingIdentifier) {
|
if (!resolvedSlug || isResolvingIdentifier) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface GalleryLayoutSettings {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Masonry specific
|
// Masonry specific
|
||||||
masonryMode?: 'columns' | 'rows' | 'flickr' | 'justified'; // columns = Pinterest-style, rows = custom rows, flickr = Flickr justified-layout, justified = react-photo-album (Google Photos style)
|
masonryMode?: 'columns' | 'rows' | 'flickr' | 'quilted'; // columns = Pinterest-style, rows = justified rows, flickr = Flickr justified-layout, quilted = mixed sizes based on aspect ratio
|
||||||
masonryGutter?: number;
|
masonryGutter?: number;
|
||||||
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
||||||
masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row
|
masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Script to backfill photo dimensions for photos that are missing them
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
|
// Dynamic require for knex to use the app's config
|
||||||
|
const config = require('../backend/knexfile');
|
||||||
|
const knex = require('knex')(config);
|
||||||
|
|
||||||
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
||||||
|
|
||||||
|
async function backfillDimensions() {
|
||||||
|
console.log('Storage path:', storagePath);
|
||||||
|
|
||||||
|
const photos = await knex('photos')
|
||||||
|
.whereNull('width')
|
||||||
|
.orWhereNull('height')
|
||||||
|
.select('id', 'path', 'filename', 'media_type');
|
||||||
|
|
||||||
|
console.log(`Found ${photos.length} photos without dimensions`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
if (photo.media_type === 'video') continue;
|
||||||
|
|
||||||
|
if (!photo.path) {
|
||||||
|
console.log(`Photo ${photo.id} has no path`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
console.log(`Not found: ${fullPath}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(fullPath).metadata();
|
||||||
|
if (metadata.width && metadata.height) {
|
||||||
|
await knex('photos')
|
||||||
|
.where('id', photo.id)
|
||||||
|
.update({ width: metadata.width, height: metadata.height });
|
||||||
|
updated++;
|
||||||
|
|
||||||
|
if (updated % 20 === 0) {
|
||||||
|
console.log(`Updated ${updated} photos...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error processing photo ${photo.id}:`, err.message);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nCompleted: ${updated} updated, ${failed} failed`);
|
||||||
|
await knex.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
backfillDimensions().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to create test galleries for each masonry layout mode
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://localhost:7100}"
|
||||||
|
ADMIN_USER="${ADMIN_USERNAME:-admin}"
|
||||||
|
ADMIN_PASS="${ADMIN_PASSWORD:-admin}"
|
||||||
|
TEST_IMAGES_DIR="${1:-./test-images}"
|
||||||
|
|
||||||
|
echo "=== Setting up Masonry Layout Test Galleries ==="
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
echo "Test images: $TEST_IMAGES_DIR"
|
||||||
|
|
||||||
|
# Login to get admin token
|
||||||
|
echo ""
|
||||||
|
echo "Logging in as admin..."
|
||||||
|
LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/api/auth/admin/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\": \"$ADMIN_USER\", \"password\": \"$ADMIN_PASS\"}")
|
||||||
|
|
||||||
|
TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Failed to login. Response: $LOGIN_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Login successful!"
|
||||||
|
|
||||||
|
# Function to create a gallery with specific masonry mode
|
||||||
|
create_gallery() {
|
||||||
|
local name="$1"
|
||||||
|
local masonry_mode="$2"
|
||||||
|
local description="$3"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Creating gallery: $name (masonry mode: $masonry_mode)"
|
||||||
|
|
||||||
|
# Build color_theme JSON with galleryLayout and gallerySettings
|
||||||
|
local color_theme=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"galleryLayout": "masonry",
|
||||||
|
"gallerySettings": {
|
||||||
|
"masonryMode": "$masonry_mode",
|
||||||
|
"masonryGutter": 8,
|
||||||
|
"masonryRowHeight": 250
|
||||||
|
},
|
||||||
|
"primaryColor": "#3B82F6",
|
||||||
|
"backgroundColor": "#FFFFFF",
|
||||||
|
"textColor": "#1F2937"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Escape for JSON
|
||||||
|
local color_theme_escaped=$(echo "$color_theme" | tr -d '\n' | sed 's/"/\\"/g')
|
||||||
|
|
||||||
|
local event_date=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
CREATE_RESPONSE=$(curl -s -X POST "$BASE_URL/api/admin/events" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d "{
|
||||||
|
\"event_type\": \"other\",
|
||||||
|
\"event_name\": \"$name\",
|
||||||
|
\"event_date\": \"$event_date\",
|
||||||
|
\"customer_name\": \"Test User\",
|
||||||
|
\"customer_email\": \"[email protected]\",
|
||||||
|
\"admin_email\": \"[email protected]\",
|
||||||
|
\"password\": \"MasonryTest2026!\",
|
||||||
|
\"welcome_message\": \"$description\",
|
||||||
|
\"color_theme\": \"$color_theme_escaped\",
|
||||||
|
\"expiration_days\": 30
|
||||||
|
}")
|
||||||
|
|
||||||
|
local event_id=$(echo "$CREATE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||||
|
local slug=$(echo "$CREATE_RESPONSE" | grep -o '"slug":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
local share_link=$(echo "$CREATE_RESPONSE" | grep -o '"share_link":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -z "$event_id" ]; then
|
||||||
|
echo " Failed to create gallery. Response: $CREATE_RESPONSE"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Created event ID: $event_id, slug: $slug"
|
||||||
|
echo " Share link: $share_link"
|
||||||
|
|
||||||
|
# Upload test images
|
||||||
|
echo " Uploading test images..."
|
||||||
|
|
||||||
|
for img in "$TEST_IMAGES_DIR"/*.jpg; do
|
||||||
|
if [ -f "$img" ]; then
|
||||||
|
local filename=$(basename "$img")
|
||||||
|
curl -s -X POST "$BASE_URL/api/admin/photos/$event_id/upload" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F "photos=@$img" > /dev/null
|
||||||
|
echo " Uploaded: $filename"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " Gallery URL: $share_link"
|
||||||
|
echo "$share_link" >> /tmp/masonry_test_galleries.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear previous results
|
||||||
|
> /tmp/masonry_test_galleries.txt
|
||||||
|
|
||||||
|
# Create galleries for each masonry mode
|
||||||
|
create_gallery "Masonry Columns Test" "columns" "Pinterest-style vertical columns with varied heights based on photo aspect ratios"
|
||||||
|
create_gallery "Masonry Rows Test" "rows" "Custom row-based justified layout that fills each row completely"
|
||||||
|
create_gallery "Masonry Flickr Test" "flickr" "Flickr's justified-layout algorithm for optimal row arrangement"
|
||||||
|
create_gallery "Masonry Quilted Test" "quilted" "Mixed sizes layout - landscape photos span 2 columns, portraits span 2 rows"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All Test Galleries Created ==="
|
||||||
|
echo ""
|
||||||
|
echo "Gallery URLs:"
|
||||||
|
cat /tmp/masonry_test_galleries.txt
|
||||||
|
echo ""
|
||||||
|
echo "You can also find these URLs in /tmp/masonry_test_galleries.txt"
|
||||||
Reference in New Issue
Block a user