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' && (