From 7d6d2f56883a4402f0d97c95b0432a8a783c8024 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 4 Mar 2026 22:55:06 +0100 Subject: [PATCH] feat: add thumbnail settings UI to admin settings page (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "Thumbnails" tab in the admin settings page allowing users to configure thumbnail dimensions, quality, format, and fit mode from the UI. Also fix backend route column name mismatch (key/value → setting_key/setting_value) that caused a 500 error, and add a button to regenerate all thumbnails. --- backend/src/routes/adminThumbnails.js | 36 ++- frontend/src/features/settings/index.ts | 1 + .../features/settings/tabs/ThumbnailsTab.tsx | 292 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 29 ++ frontend/src/i18n/locales/en.json | 29 ++ frontend/src/pages/admin/SettingsPage.tsx | 6 +- 6 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 frontend/src/features/settings/tabs/ThumbnailsTab.tsx diff --git a/backend/src/routes/adminThumbnails.js b/backend/src/routes/adminThumbnails.js index e80b333e..e6eaf073 100644 --- a/backend/src/routes/adminThumbnails.js +++ b/backend/src/routes/adminThumbnails.js @@ -10,27 +10,33 @@ const logger = require('../utils/logger'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +// Parse JSON-encoded setting values +function parseSettingValue(value) { + if (value === null || value === undefined) return null; + try { return JSON.parse(value); } catch (e) { return value; } +} + // Get thumbnail settings router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const settings = await db('app_settings') - .whereIn('key', [ + .whereIn('setting_key', [ 'thumbnail_width', 'thumbnail_height', 'thumbnail_fit', 'thumbnail_quality', 'thumbnail_format' ]) - .select('key', 'value', 'description'); - + .select('setting_key', 'setting_value'); + const settingsMap = {}; settings.forEach(s => { - settingsMap[s.key] = { - value: s.value, - description: s.description + const parsed = parseSettingValue(s.setting_value); + settingsMap[s.setting_key] = { + value: String(parsed ?? '') }; }); - + res.json({ settings: settingsMap, fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'], @@ -66,17 +72,17 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, // Update settings const updates = []; - if (width) updates.push({ key: 'thumbnail_width', value: width.toString() }); - if (height) updates.push({ key: 'thumbnail_height', value: height.toString() }); - if (fit) updates.push({ key: 'thumbnail_fit', value: fit }); - if (quality) updates.push({ key: 'thumbnail_quality', value: quality.toString() }); - if (format) updates.push({ key: 'thumbnail_format', value: format }); - + if (width) updates.push({ setting_key: 'thumbnail_width', setting_value: width }); + if (height) updates.push({ setting_key: 'thumbnail_height', setting_value: height }); + if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) }); + if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality }); + if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) }); + for (const update of updates) { await db('app_settings') - .where('key', update.key) + .where('setting_key', update.setting_key) .update({ - value: update.value, + setting_value: update.setting_value, updated_at: db.fn.now() }); } diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index 9e784c77..922978e5 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -14,3 +14,4 @@ export { AnalyticsTab } from './tabs/AnalyticsTab'; export { ModerationTab } from './tabs/ModerationTab'; export { StylingTab } from './tabs/StylingTab'; export { SEOTab } from './tabs/SEOTab'; +export { ThumbnailsTab } from './tabs/ThumbnailsTab'; diff --git a/frontend/src/features/settings/tabs/ThumbnailsTab.tsx b/frontend/src/features/settings/tabs/ThumbnailsTab.tsx new file mode 100644 index 00000000..51fba1ce --- /dev/null +++ b/frontend/src/features/settings/tabs/ThumbnailsTab.tsx @@ -0,0 +1,292 @@ +import React, { useState, useEffect } from 'react'; +import { Save, Image, RefreshCw, AlertCircle, Loader2 } from 'lucide-react'; +import { Button, Card, Loading } from '../../../components/common'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { api } from '../../../config/api'; + +interface ThumbnailSettings { + width: number; + height: number; + quality: number; + fit: string; + format: string; +} + +const defaultSettings: ThumbnailSettings = { + width: 300, + height: 300, + quality: 85, + fit: 'cover', + format: 'jpeg', +}; + +interface FetchedSettings { + settings: Record; + fitOptions: string[]; + formatOptions: string[]; +} + +export const ThumbnailsTab: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [settings, setSettings] = useState(defaultSettings); + const [isDirty, setIsDirty] = useState(false); + + const { data: fetchedData, isLoading, error } = useQuery({ + queryKey: ['thumbnail-settings'], + queryFn: async () => { + const response = await api.get('/admin/thumbnails/settings'); + return response.data; + }, + }); + + useEffect(() => { + if (fetchedData?.settings) { + const s = fetchedData.settings; + setSettings({ + width: parseInt(s.thumbnail_width?.value) || defaultSettings.width, + height: parseInt(s.thumbnail_height?.value) || defaultSettings.height, + quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality, + fit: s.thumbnail_fit?.value || defaultSettings.fit, + format: s.thumbnail_format?.value || defaultSettings.format, + }); + } + }, [fetchedData]); + + const saveMutation = useMutation({ + mutationFn: async (newSettings: ThumbnailSettings) => { + const response = await api.put('/admin/thumbnails/settings', newSettings); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['thumbnail-settings'] }); + toast.success(t('settings.thumbnails.saveSuccess', 'Thumbnail settings saved')); + setIsDirty(false); + }, + onError: () => { + toast.error(t('settings.thumbnails.saveError', 'Failed to save thumbnail settings')); + }, + }); + + const regenerateMutation = useMutation({ + mutationFn: async () => { + const response = await api.post('/admin/thumbnails/regenerate'); + return response.data; + }, + onSuccess: (data) => { + toast.success(data.message || t('settings.thumbnails.regenerateStarted', 'Thumbnail regeneration started')); + }, + onError: () => { + toast.error(t('settings.thumbnails.regenerateError', 'Failed to start thumbnail regeneration')); + }, + }); + + const handleChange = ( + key: K, + value: ThumbnailSettings[K] + ) => { + setSettings(prev => ({ ...prev, [key]: value })); + setIsDirty(true); + }; + + const handleSave = () => { + saveMutation.mutate(settings); + }; + + const handleReset = () => { + if (fetchedData?.settings) { + const s = fetchedData.settings; + setSettings({ + width: parseInt(s.thumbnail_width?.value) || defaultSettings.width, + height: parseInt(s.thumbnail_height?.value) || defaultSettings.height, + quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality, + fit: s.thumbnail_fit?.value || defaultSettings.fit, + format: s.thumbnail_format?.value || defaultSettings.format, + }); + setIsDirty(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + +
+ +

{t('settings.thumbnails.loadError', 'Failed to load thumbnail settings')}

+
+
+ ); + } + + const fitOptions = fetchedData?.fitOptions || ['cover', 'contain', 'fill', 'inside', 'outside']; + const formatOptions = fetchedData?.formatOptions || ['jpeg', 'png', 'webp']; + + return ( +
+ {/* Dimensions & Quality */} + +

+ + {t('settings.thumbnails.dimensionsTitle', 'Thumbnail Dimensions & Quality')} +

+

+ {t('settings.thumbnails.dimensionsHelp', 'Configure the size and quality of auto-generated thumbnails. Higher values produce better-looking previews but increase storage and load times.')} +

+ +
+
+ + handleChange('width', parseInt(e.target.value) || 300)} + 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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +

+ {t('settings.thumbnails.widthHelp', '50-1000 pixels')} +

+
+ +
+ + handleChange('height', parseInt(e.target.value) || 300)} + 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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +

+ {t('settings.thumbnails.heightHelp', '50-1000 pixels')} +

+
+ +
+ + handleChange('quality', parseInt(e.target.value) || 85)} + 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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +

+ {t('settings.thumbnails.qualityHelp', '1-100, higher = better quality but larger files')} +

+
+ +
+ + +
+
+ +
+ + +

+ {t('settings.thumbnails.fitHelp', 'How images are resized to fit the thumbnail dimensions. "Cover" crops to fill, "Contain" fits within bounds.')} +

+
+
+ + {/* Regenerate */} + +

+ + {t('settings.thumbnails.regenerateTitle', 'Regenerate Thumbnails')} +

+

+ {t('settings.thumbnails.regenerateHelp', 'After changing thumbnail settings, regenerate all existing thumbnails to apply the new configuration. This runs in the background and may take a while for large galleries.')} +

+ + +
+ + {/* Info Box */} + +
+ +
+

{t('settings.thumbnails.infoTitle', 'About Thumbnails')}

+

+ {t('settings.thumbnails.infoText', 'Thumbnails are smaller preview images generated from your originals. Increasing the size or quality improves how photos look in the gallery grid but uses more storage and bandwidth. After changing settings, use "Regenerate All Thumbnails" to update existing photos.')} +

+
+
+
+ + {/* Action Buttons */} +
+ + + {isDirty && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 33f8ac02..d24da913 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1218,6 +1218,35 @@ "hidePreview": "robots.txt-Vorschau ausblenden", "saveSettings": "SEO-Einstellungen speichern" }, + "thumbnails": { + "title": "Vorschaubilder", + "dimensionsTitle": "Abmessungen & Qualität der Vorschaubilder", + "dimensionsHelp": "Konfigurieren Sie Größe und Qualität der automatisch generierten Vorschaubilder. Höhere Werte erzeugen bessere Vorschauen, erhöhen aber den Speicherbedarf und die Ladezeiten.", + "width": "Breite (px)", + "widthHelp": "50-1000 Pixel", + "height": "Höhe (px)", + "heightHelp": "50-1000 Pixel", + "quality": "Qualität", + "qualityHelp": "1-100, höher = bessere Qualität, aber größere Dateien", + "format": "Format", + "fit": "Anpassungsmodus", + "fitHelp": "Wie Bilder an die Vorschaubildgröße angepasst werden. \"Füllen\" beschneidet das Bild, \"Einpassen\" passt es innerhalb der Grenzen an.", + "fit_cover": "Füllen (zuschneiden)", + "fit_contain": "Einpassen (innerhalb)", + "fit_fill": "Strecken", + "fit_inside": "Innen (verkleinern)", + "fit_outside": "Außen (vergrößern)", + "regenerateTitle": "Vorschaubilder neu generieren", + "regenerateHelp": "Nach dem Ändern der Einstellungen können Sie alle vorhandenen Vorschaubilder neu generieren. Dies läuft im Hintergrund und kann bei großen Galerien eine Weile dauern.", + "regenerateButton": "Alle Vorschaubilder neu generieren", + "regenerateStarted": "Neugenerierung der Vorschaubilder gestartet", + "regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden", + "saveSuccess": "Vorschaubild-Einstellungen gespeichert", + "saveError": "Vorschaubild-Einstellungen konnten nicht gespeichert werden", + "loadError": "Vorschaubild-Einstellungen konnten nicht geladen werden", + "infoTitle": "Über Vorschaubilder", + "infoText": "Vorschaubilder sind kleinere Vorschauversionen Ihrer Originalbilder. Das Erhöhen der Größe oder Qualität verbessert die Darstellung in der Galerieübersicht, benötigt aber mehr Speicherplatz und Bandbreite. Nach dem Ändern der Einstellungen verwenden Sie \"Alle Vorschaubilder neu generieren\", um bestehende Fotos zu aktualisieren." + }, "categories": { "title": "Kategorien", "about": "Über Fotokategorien", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 89e0022f..e81e4768 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -898,6 +898,35 @@ "styling": { "title": "Custom CSS" }, + "thumbnails": { + "title": "Thumbnails", + "dimensionsTitle": "Thumbnail Dimensions & Quality", + "dimensionsHelp": "Configure the size and quality of auto-generated thumbnails. Higher values produce better-looking previews but increase storage and load times.", + "width": "Width (px)", + "widthHelp": "50-1000 pixels", + "height": "Height (px)", + "heightHelp": "50-1000 pixels", + "quality": "Quality", + "qualityHelp": "1-100, higher = better quality but larger files", + "format": "Format", + "fit": "Fit Mode", + "fitHelp": "How images are resized to fit the thumbnail dimensions. \"Cover\" crops to fill, \"Contain\" fits within bounds.", + "fit_cover": "Cover (crop to fill)", + "fit_contain": "Contain (fit within)", + "fit_fill": "Fill (stretch)", + "fit_inside": "Inside (shrink to fit)", + "fit_outside": "Outside (expand to cover)", + "regenerateTitle": "Regenerate Thumbnails", + "regenerateHelp": "After changing thumbnail settings, regenerate all existing thumbnails to apply the new configuration. This runs in the background and may take a while for large galleries.", + "regenerateButton": "Regenerate All Thumbnails", + "regenerateStarted": "Thumbnail regeneration started", + "regenerateError": "Failed to start thumbnail regeneration", + "saveSuccess": "Thumbnail settings saved", + "saveError": "Failed to save thumbnail settings", + "loadError": "Failed to load thumbnail settings", + "infoTitle": "About Thumbnails", + "infoText": "Thumbnails are smaller preview images generated from your originals. Increasing the size or quality improves how photos look in the gallery grid but uses more storage and bandwidth. After changing settings, use \"Regenerate All Thumbnails\" to update existing photos." + }, "seo": { "title": "SEO & Robots", "indexingTitle": "Search Engine Indexing", diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index bc9ee4e0..d05c42e2 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -13,9 +13,10 @@ import { ModerationTab, StylingTab, SEOTab, + ThumbnailsTab, } from '../../features/settings'; -type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling'; +type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling'; export const SettingsPage: React.FC = () => { const [activeTab, setActiveTab] = useState('general'); @@ -74,6 +75,7 @@ export const SettingsPage: React.FC = () => { { key: 'status', label: t('settings.systemStatus.title') }, { key: 'security', label: t('settings.security.title') }, { key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') }, + { key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails') }, { key: 'seo', label: t('settings.seo.title', 'SEO & Robots') }, { key: 'categories', label: t('settings.categories.title') }, { key: 'analytics', label: t('settings.analytics.title') }, @@ -168,6 +170,8 @@ export const SettingsPage: React.FC = () => { {activeTab === 'imageSecurity' && } + {activeTab === 'thumbnails' && } + {activeTab === 'categories' && } {activeTab === 'analytics' && (