From ee46088985ebbbb81d16e5bac23be2060c94397f Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:44:07 +0100 Subject: [PATCH] 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 --- .../admin/ThemeCustomizerEnhanced.tsx | 155 +++++++++++++----- .../gallery/layouts/GridGalleryLayout.tsx | 14 +- .../gallery/layouts/MasonryGalleryLayout.tsx | 27 +-- .../gallery/layouts/MosaicGalleryLayout.tsx | 19 ++- frontend/src/i18n/locales/de.json | 9 + frontend/src/i18n/locales/en.json | 9 + frontend/src/i18n/locales/pt.json | 9 + frontend/src/i18n/locales/ru.json | 9 + frontend/src/types/theme.types.ts | 3 + frontend/tailwind.config.js | 7 + 10 files changed, 198 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index 37d90279..11fd423b 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -312,52 +312,73 @@ export const ThemeCustomizerEnhanced: React.FC = ( {/* Grid specific */} {localTheme.galleryLayout === 'grid' && ( -
- -
-
- - updateGallerySettings('gridColumns', { - ...localTheme.gallerySettings?.gridColumns, - mobile: parseInt(e.target.value) - })} - /> -
-
- - updateGallerySettings('gridColumns', { - ...localTheme.gallerySettings?.gridColumns, - tablet: parseInt(e.target.value) - })} - /> -
-
- - updateGallerySettings('gridColumns', { - ...localTheme.gallerySettings?.gridColumns, - desktop: parseInt(e.target.value) - })} - /> + <> +
+ +
+
+ + updateGallerySettings('gridColumns', { + ...localTheme.gallerySettings?.gridColumns, + mobile: parseInt(e.target.value) + })} + /> +
+
+ + updateGallerySettings('gridColumns', { + ...localTheme.gallerySettings?.gridColumns, + tablet: parseInt(e.target.value) + })} + /> +
+
+ + updateGallerySettings('gridColumns', { + ...localTheme.gallerySettings?.gridColumns, + desktop: parseInt(e.target.value) + })} + /> +
-
+
+ + +

+ {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} +

+
+ )} {/* Carousel specific */} @@ -437,6 +458,29 @@ export const ThemeCustomizerEnhanced: React.FC = (

+ {/* Thumbnail scale - only for columns mode */} + {(!localTheme.gallerySettings?.masonryMode || localTheme.gallerySettings?.masonryMode === 'columns') && ( +
+ + +

+ {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} +

+
+ )} + {/* 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 = ( )} )} + + {/* Mosaic specific */} + {localTheme.galleryLayout === 'mosaic' && ( +
+ + +

+ {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} +

+
+ )}
)} diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index c3236355..7118d61b 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -374,6 +374,10 @@ export const GridGalleryLayout: React.FC = ({ 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 = { 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); @@ -381,12 +385,12 @@ export const GridGalleryLayout: React.FC = ({ 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 (
diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index e698ba3b..ff05be8a 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -241,6 +241,13 @@ export const MasonryGalleryLayout: React.FC = ({ const mode = gallerySettings.masonryMode || 'columns'; const targetRowHeight = gallerySettings.masonryRowHeight || 250; const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left'; + const scale = gallerySettings.thumbnailScale || 'md'; + + const scaleOffsets: Record = { 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 = ({ // 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 = ({ }); 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') { diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index 00778e23..1506c3a6 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -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 = ({ feedbackEnabled = false, feedbackOptions }) => { + const { theme } = useTheme(); + const scale = theme.gallerySettings?.thumbnailScale || 'md'; + const scaleOffsets: Record = { 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 (
{photos.map((photo, index) => ( diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0cc825d8..311a0979 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5f001149..b49b62cc 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index b37991aa..982e20db 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 6218704b..00c139b5 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1130,6 +1130,15 @@ "center": "По центру", "justify": "Justified (растянуть)" }, + "thumbnailScale": "Масштаб миниатюр", + "thumbnailScaleOptions": { + "xs": "XS — Больше всего фото", + "sm": "SM — Больше фото", + "md": "MD — По умолчанию", + "lg": "LG — Крупнее фото", + "xl": "XL — Самые крупные фото" + }, + "thumbnailScaleHint": "Изменяет количество колонок относительно базовых колонок сетки", "showHeroSection": "Показывать секцию баннера", "showHeroSectionHint": "Отображать главное фото баннера над justified-галереей", "heroHeight": "Высота секции баннера", diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index 7d9b8603..e5aaf620 100644 --- a/frontend/src/types/theme.types.ts +++ b/frontend/src/types/theme.types.ts @@ -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 { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 746fd026..a3d70ede 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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: [], }