feat: add per-gallery thumbnail scale setting (#172) (#251)

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:
Paul Nothaft
2026-03-18 10:44:07 +01:00
committed by GitHub
co-authored by Paul Nothaft
parent 3742d71535
commit ee46088985
10 changed files with 198 additions and 63 deletions
@@ -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) => (
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -1130,6 +1130,15 @@
"center": "По центру",
"justify": "Justified (растянуть)"
},
"thumbnailScale": "Масштаб миниатюр",
"thumbnailScaleOptions": {
"xs": "XS — Больше всего фото",
"sm": "SM — Больше фото",
"md": "MD — По умолчанию",
"lg": "LG — Крупнее фото",
"xl": "XL — Самые крупные фото"
},
"thumbnailScaleHint": "Изменяет количество колонок относительно базовых колонок сетки",
"showHeroSection": "Показывать секцию баннера",
"showHeroSectionHint": "Отображать главное фото баннера над justified-галереей",
"heroHeight": "Высота секции баннера",
+3
View File
@@ -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 {
+7
View File
@@ -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: [],
}