Add a thumbnailScale field (xs/sm/md/lg/xl) to gallery layout settings that adjusts column counts for Grid, Masonry (columns mode), and Mosaic layouts. Each scale maps to a column offset applied on top of the layout's base columns, letting photographers control photo density. - Add thumbnailScale to GalleryLayoutSettings type - Apply scale offset in Grid, Masonry, and Mosaic layout components - Add thumbnail scale dropdown to admin theme customizer - Conditionally show dropdown only for applicable layouts - Safelist dynamic grid-cols classes in Tailwind config - Add i18n keys for EN, DE, PT, RU locales Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
3742d71535
commit
ee46088985
@@ -312,52 +312,73 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Grid specific */}
|
||||
{localTheme.galleryLayout === 'grid' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
mobile: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
max="6"
|
||||
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
tablet: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="8"
|
||||
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
desktop: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
mobile: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
max="6"
|
||||
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
tablet: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="8"
|
||||
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
desktop: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.thumbnailScale', 'Thumbnail Scale')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
|
||||
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
|
||||
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
|
||||
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
|
||||
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
|
||||
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Carousel specific */}
|
||||
@@ -437,6 +458,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail scale - only for columns mode */}
|
||||
{(!localTheme.gallerySettings?.masonryMode || localTheme.gallerySettings?.masonryMode === 'columns') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.thumbnailScale', 'Thumbnail Scale')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
|
||||
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
|
||||
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
|
||||
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
|
||||
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
|
||||
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row-specific settings - show for all row-based modes */}
|
||||
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
|
||||
<>
|
||||
@@ -476,6 +520,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mosaic specific */}
|
||||
{localTheme.galleryLayout === 'mosaic' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.thumbnailScale', 'Thumbnail Scale')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
|
||||
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
|
||||
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
|
||||
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
|
||||
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
|
||||
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -374,6 +374,10 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
|
||||
const spacing = gallerySettings.spacing || 'normal';
|
||||
const animation = gallerySettings.photoAnimation || 'fade';
|
||||
const scale = gallerySettings.thumbnailScale || 'md';
|
||||
|
||||
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
|
||||
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
|
||||
|
||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
@@ -381,12 +385,12 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||
|
||||
|
||||
const gridClass = `photo-grid grid ${spacingClass}
|
||||
grid-cols-${columns.mobile}
|
||||
sm:grid-cols-${columns.tablet}
|
||||
lg:grid-cols-${columns.desktop}
|
||||
xl:grid-cols-${columns.desktop + 1}`;
|
||||
grid-cols-${applyScale(columns.mobile)}
|
||||
sm:grid-cols-${applyScale(columns.tablet)}
|
||||
lg:grid-cols-${applyScale(columns.desktop)}
|
||||
xl:grid-cols-${applyScale(columns.desktop + 1)}`;
|
||||
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
|
||||
@@ -241,6 +241,13 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const mode = gallerySettings.masonryMode || 'columns';
|
||||
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
|
||||
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
|
||||
const scale = gallerySettings.thumbnailScale || 'md';
|
||||
|
||||
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
|
||||
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
|
||||
|
||||
// Apply scale to columns only in columns mode
|
||||
const scaledColumns = mode === 'columns' ? applyScale(columns) : columns;
|
||||
|
||||
|
||||
// Calculate number of columns based on container width (for columns mode)
|
||||
@@ -339,20 +346,20 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// This creates a more balanced masonry layout instead of round-robin
|
||||
const photoColumns: Photo[][] = useMemo(() => {
|
||||
if (mode !== 'columns' || photos.length === 0) {
|
||||
return Array.from({ length: columns }, () => []);
|
||||
return Array.from({ length: scaledColumns }, () => []);
|
||||
}
|
||||
|
||||
const cols: Photo[][] = Array.from({ length: columns }, () => []);
|
||||
const colHeights: number[] = Array(columns).fill(0);
|
||||
const cols: Photo[][] = Array.from({ length: scaledColumns }, () => []);
|
||||
const colHeights: number[] = Array(scaledColumns).fill(0);
|
||||
|
||||
// Calculate approximate column width for height estimation
|
||||
const approxColWidth = containerWidth > 0 ? (containerWidth - (columns - 1) * gutter) / columns : 300;
|
||||
const approxColWidth = containerWidth > 0 ? (containerWidth - (scaledColumns - 1) * gutter) / scaledColumns : 300;
|
||||
|
||||
photos.forEach((photo) => {
|
||||
// Find the shortest column
|
||||
let shortestCol = 0;
|
||||
let minHeight = colHeights[0];
|
||||
for (let i = 1; i < columns; i++) {
|
||||
for (let i = 1; i < scaledColumns; i++) {
|
||||
if (colHeights[i] < minHeight) {
|
||||
minHeight = colHeights[i];
|
||||
shortestCol = i;
|
||||
@@ -373,15 +380,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
});
|
||||
|
||||
return cols;
|
||||
}, [mode, photos, columns, containerWidth, gutter]);
|
||||
}, [mode, photos, scaledColumns, containerWidth, gutter]);
|
||||
|
||||
// Calculate approximate column width for aspect ratio calculations
|
||||
const columnWidth = useMemo(() => {
|
||||
if (containerWidth <= 0 || columns <= 0) return 300;
|
||||
if (containerWidth <= 0 || scaledColumns <= 0) return 300;
|
||||
// Account for gaps between columns
|
||||
const totalGaps = (columns - 1) * gutter;
|
||||
return (containerWidth - totalGaps) / columns;
|
||||
}, [containerWidth, columns, gutter]);
|
||||
const totalGaps = (scaledColumns - 1) * gutter;
|
||||
return (containerWidth - totalGaps) / scaledColumns;
|
||||
}, [containerWidth, scaledColumns, gutter]);
|
||||
|
||||
// ROWS MODE - Google Photos style justified layout
|
||||
if (mode === 'rows') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
@@ -210,23 +211,33 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const scale = theme.gallerySettings?.thumbnailScale || 'md';
|
||||
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
|
||||
const applyScale = (cols: number, min = 1) => Math.max(min, cols + (scaleOffsets[scale] ?? 0));
|
||||
|
||||
const desktop = applyScale(4);
|
||||
const xlDown = applyScale(3);
|
||||
const lgDown = applyScale(2);
|
||||
const mobile = Math.min(applyScale(1), 2); // Cap mobile at 2
|
||||
|
||||
return (
|
||||
<div
|
||||
className="photo-grid w-full"
|
||||
style={{
|
||||
columnCount: 4,
|
||||
columnCount: desktop,
|
||||
columnGap: '8px',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@media (max-width: 1280px) {
|
||||
.photo-grid { column-count: 3 !important; }
|
||||
.photo-grid { column-count: ${xlDown} !important; }
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.photo-grid { column-count: 2 !important; }
|
||||
.photo-grid { column-count: ${lgDown} !important; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.photo-grid { column-count: 1 !important; }
|
||||
.photo-grid { column-count: ${mobile} !important; }
|
||||
}
|
||||
`}</style>
|
||||
{photos.map((photo, index) => (
|
||||
|
||||
@@ -1437,6 +1437,15 @@
|
||||
"center": "Zentriert",
|
||||
"justify": "Blocksatz (gestreckt)"
|
||||
},
|
||||
"thumbnailScale": "Vorschau-Skalierung",
|
||||
"thumbnailScaleOptions": {
|
||||
"xs": "XS — Meiste Fotos",
|
||||
"sm": "SM — Mehr Fotos",
|
||||
"md": "MD — Standard",
|
||||
"lg": "LG — Größere Fotos",
|
||||
"xl": "XL — Größte Fotos"
|
||||
},
|
||||
"thumbnailScaleHint": "Passt die Spaltenanzahl relativ zu den Basis-Rasterspalten an",
|
||||
"showHeroSection": "Hero-Bereich anzeigen",
|
||||
"showHeroSectionHint": "Zeigt ein hervorgehobenes Titelbild über der Galerie an",
|
||||
"heroHeight": "Hero-Bereich Höhe",
|
||||
|
||||
@@ -1152,6 +1152,15 @@
|
||||
"center": "Centered",
|
||||
"justify": "Justified (stretch)"
|
||||
},
|
||||
"thumbnailScale": "Thumbnail Scale",
|
||||
"thumbnailScaleOptions": {
|
||||
"xs": "XS — Most photos",
|
||||
"sm": "SM — More photos",
|
||||
"md": "MD — Default",
|
||||
"lg": "LG — Larger photos",
|
||||
"xl": "XL — Largest photos"
|
||||
},
|
||||
"thumbnailScaleHint": "Adjusts column count relative to the base grid columns",
|
||||
"showHeroSection": "Show Hero Section",
|
||||
"showHeroSectionHint": "Display a featured hero image above the justified gallery",
|
||||
"heroHeight": "Hero Section Height",
|
||||
|
||||
@@ -1130,6 +1130,15 @@
|
||||
"center": "Centralizado",
|
||||
"justify": "Justified (esticado)"
|
||||
},
|
||||
"thumbnailScale": "Escala de Miniaturas",
|
||||
"thumbnailScaleOptions": {
|
||||
"xs": "XS — Mais fotos",
|
||||
"sm": "SM — Muitas fotos",
|
||||
"md": "MD — Padrão",
|
||||
"lg": "LG — Fotos maiores",
|
||||
"xl": "XL — Fotos grandes"
|
||||
},
|
||||
"thumbnailScaleHint": "Ajusta a contagem de colunas em relação às colunas base da grade",
|
||||
"showHeroSection": "Mostrar Seção Hero",
|
||||
"showHeroSectionHint": "Exibir uma imagem hero em destaque acima da galeria justified",
|
||||
"heroHeight": "Altura da Seção Hero",
|
||||
|
||||
@@ -1130,6 +1130,15 @@
|
||||
"center": "По центру",
|
||||
"justify": "Justified (растянуть)"
|
||||
},
|
||||
"thumbnailScale": "Масштаб миниатюр",
|
||||
"thumbnailScaleOptions": {
|
||||
"xs": "XS — Больше всего фото",
|
||||
"sm": "SM — Больше фото",
|
||||
"md": "MD — По умолчанию",
|
||||
"lg": "LG — Крупнее фото",
|
||||
"xl": "XL — Самые крупные фото"
|
||||
},
|
||||
"thumbnailScaleHint": "Изменяет количество колонок относительно базовых колонок сетки",
|
||||
"showHeroSection": "Показывать секцию баннера",
|
||||
"showHeroSectionHint": "Отображать главное фото баннера над justified-галереей",
|
||||
"heroHeight": "Высота секции баннера",
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface GalleryLayoutSettings {
|
||||
|
||||
// Mosaic specific
|
||||
mosaicPattern?: 'random' | 'structured' | 'alternating';
|
||||
|
||||
// Thumbnail scale (applies to Grid, Masonry columns, Mosaic)
|
||||
thumbnailScale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
|
||||
@@ -84,6 +84,13 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
safelist: [
|
||||
// Dynamic grid-cols classes used by thumbnail scale offsets
|
||||
...Array.from({ length: 12 }, (_, i) => `grid-cols-${i + 1}`),
|
||||
...Array.from({ length: 12 }, (_, i) => `sm:grid-cols-${i + 1}`),
|
||||
...Array.from({ length: 12 }, (_, i) => `lg:grid-cols-${i + 1}`),
|
||||
...Array.from({ length: 12 }, (_, i) => `xl:grid-cols-${i + 1}`),
|
||||
],
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user