diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index e0f5a475..0d4519a4 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -1,5 +1,5 @@ const express = require('express'); -const { body, query, validationResult } = require('express-validator'); +const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); @@ -17,10 +17,20 @@ const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const logger = require('../utils/logger'); const { buildShareLinkVariants } = require('../services/shareLinkService'); -const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers'); +const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); const eventTypeService = require('../services/eventTypeService'); const { validateFileType } = require('../utils/fileSecurityUtils'); +// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point +const validateHeroImageAnchor = (value) => { + if (['top', 'center', 'bottom'].includes(value)) return true; + if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { + const [x, y] = value.split(/\s+/).map(v => parseInt(v)); + if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; + } + throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); +}; + // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -198,14 +208,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point - body('hero_image_anchor').optional().custom((value) => { - if (['top', 'center', 'bottom'].includes(value)) return true; - if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { - const [x, y] = value.split(/\s+/).map(v => parseInt(v)); - if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; - } - throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); - }) + body('hero_image_anchor').optional().custom(validateHeroImageAnchor) ], async (req, res) => { try { logger.debug('Create event request body', { body: req.body }); @@ -313,10 +316,6 @@ router.post('/', adminAuth, requirePermission('events.create'), [ } } - // Get event type info for slug generation - const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type); - const slugPrefix = eventTypeInfo.slug_prefix || event_type; - // Generate unique slug const processedEventName = event_name .toLowerCase() @@ -337,7 +336,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // Generate share link respecting configured format const shareToken = crypto.randomBytes(16).toString('hex'); - const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); // Hash password with configurable rounds (random placeholder when not required) const password_hash = requirePassword @@ -665,7 +664,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ body('overlay_protection').optional().isBoolean(), body('image_quality').optional().isInt({ min: 1, max: 100 }), body('fragmentation_level').optional().isInt({ min: 1, max: 10 }), - body('password').optional().isString().custom((value, { req }) => { + body('password').optional().isString().custom((value) => { if (value === undefined || value === null || value === '') { return true; } @@ -683,14 +682,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point - body('hero_image_anchor').optional().custom((value) => { - if (['top', 'center', 'bottom'].includes(value)) return true; - if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { - const [x, y] = value.split(/\s+/).map(v => parseInt(v)); - if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; - } - throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); - }) + body('hero_image_anchor').optional().custom(validateHeroImageAnchor) ], async (req, res) => { try { const errors = validationResult(req); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 128c49ff..91c08f58 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -795,7 +795,7 @@ router.get('/:slug/photo/:photoId', if (range) { // Parse range header - const parts = range.replace(/bytes=/, "").split("-"); + const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunksize = (end - start) + 1; diff --git a/frontend/src/components/admin/FocalPointPicker.tsx b/frontend/src/components/admin/FocalPointPicker.tsx index 3793a381..ad5394ec 100644 --- a/frontend/src/components/admin/FocalPointPicker.tsx +++ b/frontend/src/components/admin/FocalPointPicker.tsx @@ -1,6 +1,6 @@ import React, { useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { AuthenticatedImage } from '../common'; +import { AuthenticatedImage, Button } from '../common'; interface FocalPointPickerProps { imageUrl: string; @@ -64,7 +64,7 @@ export const FocalPointPicker: React.FC = ({ > = ({ {/* Outer ring (dark) for contrast on light areas */}
{/* Inner ring (white) for contrast on dark areas */} -
+
{/* Center dot */}
@@ -94,18 +94,20 @@ export const FocalPointPicker: React.FC = ({ {/* Preset buttons */}
{presets.map((p) => ( - + ))}
diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index a823d11e..4af2906d 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -111,7 +111,7 @@ export const PhotoGridWithLayouts: React.FC = ({ // Clear selection when category changes useEffect(() => { setSelectedPhotos(new Set()); - }, [categoryId]); + }, [categoryId, setSelectedPhotos]); const handlePhotoClick = (index: number) => { setOpenFeedbackInitially(false); @@ -171,7 +171,7 @@ export const PhotoGridWithLayouts: React.FC = ({ try { await galleryService.downloadSelectedPhotos(slug, ids); analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length }); - } catch (error) { + } catch { toastify.error(t('gallery.downloadError')); } finally { setSelectedPhotos(new Set()); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index faa9ecdc..c7c01b0f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -863,6 +863,12 @@ "selectHeroPhoto": "Hero-Foto auswählen", "noHeroPhotoSelected": "Kein Hero-Foto ausgewählt", "heroPhotoSelected": "Hero-Foto ausgewählt", + "heroImageAnchor": "Hero-Bild Zuschneideposition", + "heroImageAnchorDescription": "Klicken Sie auf das Bild, um den Fokuspunkt für den Zuschnitt festzulegen.", + "heroImageAnchorTop": "Oben", + "heroImageAnchorCenter": "Mitte", + "heroImageAnchorBottom": "Unten", + "heroPreview": "Hero-Vorschau", "noPhotosAvailable": "Keine Fotos verfügbar", "processingRequest": "Ihre Anfrage wird verarbeitet...", "eventTypeWedding": "Hochzeit", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0605d225..d94ba9b6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -489,6 +489,12 @@ "selectHeroPhoto": "Select Hero Photo", "noHeroPhotoSelected": "No hero photo selected", "heroPhotoSelected": "Hero photo selected", + "heroImageAnchor": "Hero Image Crop Position", + "heroImageAnchorDescription": "Click on the image to set the focal point for cropping.", + "heroImageAnchorTop": "Top", + "heroImageAnchorCenter": "Center", + "heroImageAnchorBottom": "Bottom", + "heroPreview": "Hero preview", "noPhotosAvailable": "No photos available", "processingRequest": "Processing your request...", "eventTypeWedding": "Wedding", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 0812ac00..d78e7d18 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -89,6 +89,7 @@ const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => v } }; + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { load(currentPath || ''); }, []); const navigateUp = () => { @@ -295,7 +296,7 @@ export const EventDetailsPage: React.FC = () => { const mediaTypes = useMemo(() => { const types = new Set<'photo' | 'video'>(); - photos.forEach((p: any) => { + photos.forEach((p) => { const mediaType = (p.media_type as 'photo' | 'video' | undefined) || ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo'); if (mediaType === 'video' || mediaType === 'photo') { @@ -436,7 +437,7 @@ export const EventDetailsPage: React.FC = () => { setCurrentPresetName(event.color_theme); } } - } catch (e) { + } catch { setCurrentTheme(GALLERY_THEME_PRESETS.default.config); setCurrentPresetName('default'); } @@ -578,7 +579,7 @@ export const EventDetailsPage: React.FC = () => { // Update feedback settings separately try { await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings); - } catch (error) { + } catch { // Error already handled by mutation } }; @@ -869,7 +870,7 @@ export const EventDetailsPage: React.FC = () => { {/* Hero Image Focal Point Picker (#162) */} {editForm.hero_photo_id && (() => { - const heroPhoto = (photos || []).find((p: any) => p.id === editForm.hero_photo_id); + const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id); const heroImageUrl = heroPhoto?.thumbnail_url || heroPhoto?.url; if (!heroImageUrl) return null; return ( @@ -1477,7 +1478,7 @@ export const EventDetailsPage: React.FC = () => { try { await eventsService.resendCreationEmail(event.id); toast.success(t('events.creationEmailResent')); - } catch (error) { + } catch { toast.error(t('events.failedToResendEmail')); } }} @@ -1653,7 +1654,7 @@ export const EventDetailsPage: React.FC = () => { toast.info(t('events.downloadingArchive', { name: event.event_name })); await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`); toast.success(t('events.downloadStarted')); - } catch (error) { + } catch { toast.error(t('events.failedToDownloadArchive')); } }} diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index 4b6d20f5..659d3209 100644 --- a/frontend/src/types/theme.types.ts +++ b/frontend/src/types/theme.types.ts @@ -43,7 +43,6 @@ export interface GalleryLayoutSettings { // Hero specific heroImageId?: number; - heroImagePosition?: 'top' | 'center' | 'bottom'; heroOverlayOpacity?: number; // Mosaic specific