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 */} {/* Grid specific */}
{localTheme.galleryLayout === 'grid' && ( {localTheme.galleryLayout === 'grid' && (
<div> <>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> <div>
{t('branding.columns')} <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
</label> {t('branding.columns')}
<div className="grid grid-cols-3 gap-4"> </label>
<div> <div className="grid grid-cols-3 gap-4">
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label> <div>
<Input <label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
type="number" <Input
min="1" type="number"
max="4" min="1"
value={localTheme.gallerySettings?.gridColumns?.mobile || 2} max="4"
onChange={(e) => updateGallerySettings('gridColumns', { value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
...localTheme.gallerySettings?.gridColumns, onChange={(e) => updateGallerySettings('gridColumns', {
mobile: parseInt(e.target.value) ...localTheme.gallerySettings?.gridColumns,
})} mobile: parseInt(e.target.value)
/> })}
</div> />
<div> </div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label> <div>
<Input <label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
type="number" <Input
min="2" type="number"
max="6" min="2"
value={localTheme.gallerySettings?.gridColumns?.tablet || 3} max="6"
onChange={(e) => updateGallerySettings('gridColumns', { value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
...localTheme.gallerySettings?.gridColumns, onChange={(e) => updateGallerySettings('gridColumns', {
tablet: parseInt(e.target.value) ...localTheme.gallerySettings?.gridColumns,
})} tablet: parseInt(e.target.value)
/> })}
</div> />
<div> </div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label> <div>
<Input <label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
type="number" <Input
min="3" type="number"
max="8" min="3"
value={localTheme.gallerySettings?.gridColumns?.desktop || 4} max="8"
onChange={(e) => updateGallerySettings('gridColumns', { value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
...localTheme.gallerySettings?.gridColumns, onChange={(e) => updateGallerySettings('gridColumns', {
desktop: parseInt(e.target.value) ...localTheme.gallerySettings?.gridColumns,
})} desktop: parseInt(e.target.value)
/> })}
/>
</div>
</div> </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 */} {/* Carousel specific */}
@@ -437,6 +458,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</p> </p>
</div> </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 */} {/* Row-specific settings - show for all row-based modes */}
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && ( {['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> </div>
)} )}
</Card> </Card>
@@ -374,6 +374,10 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 }; const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
const spacing = gallerySettings.spacing || 'normal'; const spacing = gallerySettings.spacing || 'normal';
const animation = gallerySettings.photoAnimation || 'fade'; 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 [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null); 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 [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4'; const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `photo-grid grid ${spacingClass} const gridClass = `photo-grid grid ${spacingClass}
grid-cols-${columns.mobile} grid-cols-${applyScale(columns.mobile)}
sm:grid-cols-${columns.tablet} sm:grid-cols-${applyScale(columns.tablet)}
lg:grid-cols-${columns.desktop} lg:grid-cols-${applyScale(columns.desktop)}
xl:grid-cols-${columns.desktop + 1}`; xl:grid-cols-${applyScale(columns.desktop + 1)}`;
return ( return (
<div className={gridClass}> <div className={gridClass}>
@@ -241,6 +241,13 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const mode = gallerySettings.masonryMode || 'columns'; const mode = gallerySettings.masonryMode || 'columns';
const targetRowHeight = gallerySettings.masonryRowHeight || 250; const targetRowHeight = gallerySettings.masonryRowHeight || 250;
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left'; 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) // 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 // This creates a more balanced masonry layout instead of round-robin
const photoColumns: Photo[][] = useMemo(() => { const photoColumns: Photo[][] = useMemo(() => {
if (mode !== 'columns' || photos.length === 0) { if (mode !== 'columns' || photos.length === 0) {
return Array.from({ length: columns }, () => []); return Array.from({ length: scaledColumns }, () => []);
} }
const cols: Photo[][] = Array.from({ length: columns }, () => []); const cols: Photo[][] = Array.from({ length: scaledColumns }, () => []);
const colHeights: number[] = Array(columns).fill(0); const colHeights: number[] = Array(scaledColumns).fill(0);
// Calculate approximate column width for height estimation // 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) => { photos.forEach((photo) => {
// Find the shortest column // Find the shortest column
let shortestCol = 0; let shortestCol = 0;
let minHeight = colHeights[0]; let minHeight = colHeights[0];
for (let i = 1; i < columns; i++) { for (let i = 1; i < scaledColumns; i++) {
if (colHeights[i] < minHeight) { if (colHeights[i] < minHeight) {
minHeight = colHeights[i]; minHeight = colHeights[i];
shortestCol = i; shortestCol = i;
@@ -373,15 +380,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}); });
return cols; return cols;
}, [mode, photos, columns, containerWidth, gutter]); }, [mode, photos, scaledColumns, containerWidth, gutter]);
// Calculate approximate column width for aspect ratio calculations // Calculate approximate column width for aspect ratio calculations
const columnWidth = useMemo(() => { const columnWidth = useMemo(() => {
if (containerWidth <= 0 || columns <= 0) return 300; if (containerWidth <= 0 || scaledColumns <= 0) return 300;
// Account for gaps between columns // Account for gaps between columns
const totalGaps = (columns - 1) * gutter; const totalGaps = (scaledColumns - 1) * gutter;
return (containerWidth - totalGaps) / columns; return (containerWidth - totalGaps) / scaledColumns;
}, [containerWidth, columns, gutter]); }, [containerWidth, scaledColumns, gutter]);
// ROWS MODE - Google Photos style justified layout // ROWS MODE - Google Photos style justified layout
if (mode === 'rows') { if (mode === 'rows') {
@@ -1,5 +1,6 @@
import React 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';
@@ -210,23 +211,33 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackEnabled = false, feedbackEnabled = false,
feedbackOptions 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 ( return (
<div <div
className="photo-grid w-full" className="photo-grid w-full"
style={{ style={{
columnCount: 4, columnCount: desktop,
columnGap: '8px', columnGap: '8px',
}} }}
> >
<style>{` <style>{`
@media (max-width: 1280px) { @media (max-width: 1280px) {
.photo-grid { column-count: 3 !important; } .photo-grid { column-count: ${xlDown} !important; }
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.photo-grid { column-count: 2 !important; } .photo-grid { column-count: ${lgDown} !important; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.photo-grid { column-count: 1 !important; } .photo-grid { column-count: ${mobile} !important; }
} }
`}</style> `}</style>
{photos.map((photo, index) => ( {photos.map((photo, index) => (
+9
View File
@@ -1437,6 +1437,15 @@
"center": "Zentriert", "center": "Zentriert",
"justify": "Blocksatz (gestreckt)" "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", "showHeroSection": "Hero-Bereich anzeigen",
"showHeroSectionHint": "Zeigt ein hervorgehobenes Titelbild über der Galerie an", "showHeroSectionHint": "Zeigt ein hervorgehobenes Titelbild über der Galerie an",
"heroHeight": "Hero-Bereich Höhe", "heroHeight": "Hero-Bereich Höhe",
+9
View File
@@ -1152,6 +1152,15 @@
"center": "Centered", "center": "Centered",
"justify": "Justified (stretch)" "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", "showHeroSection": "Show Hero Section",
"showHeroSectionHint": "Display a featured hero image above the justified gallery", "showHeroSectionHint": "Display a featured hero image above the justified gallery",
"heroHeight": "Hero Section Height", "heroHeight": "Hero Section Height",
+9
View File
@@ -1130,6 +1130,15 @@
"center": "Centralizado", "center": "Centralizado",
"justify": "Justified (esticado)" "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", "showHeroSection": "Mostrar Seção Hero",
"showHeroSectionHint": "Exibir uma imagem hero em destaque acima da galeria justified", "showHeroSectionHint": "Exibir uma imagem hero em destaque acima da galeria justified",
"heroHeight": "Altura da Seção Hero", "heroHeight": "Altura da Seção Hero",
+9
View File
@@ -1130,6 +1130,15 @@
"center": "По центру", "center": "По центру",
"justify": "Justified (растянуть)" "justify": "Justified (растянуть)"
}, },
"thumbnailScale": "Масштаб миниатюр",
"thumbnailScaleOptions": {
"xs": "XS — Больше всего фото",
"sm": "SM — Больше фото",
"md": "MD — По умолчанию",
"lg": "LG — Крупнее фото",
"xl": "XL — Самые крупные фото"
},
"thumbnailScaleHint": "Изменяет количество колонок относительно базовых колонок сетки",
"showHeroSection": "Показывать секцию баннера", "showHeroSection": "Показывать секцию баннера",
"showHeroSectionHint": "Отображать главное фото баннера над justified-галереей", "showHeroSectionHint": "Отображать главное фото баннера над justified-галереей",
"heroHeight": "Высота секции баннера", "heroHeight": "Высота секции баннера",
+3
View File
@@ -47,6 +47,9 @@ export interface GalleryLayoutSettings {
// Mosaic specific // Mosaic specific
mosaicPattern?: 'random' | 'structured' | 'alternating'; mosaicPattern?: 'random' | 'structured' | 'alternating';
// Thumbnail scale (applies to Grid, Masonry columns, Mosaic)
thumbnailScale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
} }
export interface ThemeConfig { 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: [], plugins: [],
} }