Add missing i18n translations for hero image focal point picker in both EN and DE locales. Fix lint errors across touched files: remove unused imports/variables, replace raw buttons with shared Button component, eliminate inline styles, extract duplicated backend validation, and remove dead heroImagePosition type.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
@@ -17,10 +17,20 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
|
|||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||||
const eventTypeService = require('../services/eventTypeService');
|
const eventTypeService = require('../services/eventTypeService');
|
||||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
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
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
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('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', '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
|
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||||
body('hero_image_anchor').optional().custom((value) => {
|
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||||
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)');
|
|
||||||
})
|
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Create event request body', { body: req.body });
|
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
|
// Generate unique slug
|
||||||
const processedEventName = event_name
|
const processedEventName = event_name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -337,7 +336,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
|
|
||||||
// Generate share link respecting configured format
|
// Generate share link respecting configured format
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
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)
|
// Hash password with configurable rounds (random placeholder when not required)
|
||||||
const password_hash = requirePassword
|
const password_hash = requirePassword
|
||||||
@@ -665,7 +664,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
|||||||
body('overlay_protection').optional().isBoolean(),
|
body('overlay_protection').optional().isBoolean(),
|
||||||
body('image_quality').optional().isInt({ min: 1, max: 100 }),
|
body('image_quality').optional().isInt({ min: 1, max: 100 }),
|
||||||
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
|
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 === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -683,14 +682,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
|||||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', '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
|
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||||
body('hero_image_anchor').optional().custom((value) => {
|
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||||
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)');
|
|
||||||
})
|
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ router.get('/:slug/photo/:photoId',
|
|||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
// Parse range header
|
// Parse range header
|
||||||
const parts = range.replace(/bytes=/, "").split("-");
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
const start = parseInt(parts[0], 10);
|
const start = parseInt(parts[0], 10);
|
||||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
const chunksize = (end - start) + 1;
|
const chunksize = (end - start) + 1;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef, useCallback } from 'react';
|
import React, { useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AuthenticatedImage } from '../common';
|
import { AuthenticatedImage, Button } from '../common';
|
||||||
|
|
||||||
interface FocalPointPickerProps {
|
interface FocalPointPickerProps {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
@@ -64,7 +64,7 @@ export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
|
|||||||
>
|
>
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt="Hero preview"
|
alt={t('events.heroPreview', 'Hero preview')}
|
||||||
className="w-full h-full object-cover pointer-events-none"
|
className="w-full h-full object-cover pointer-events-none"
|
||||||
style={{ objectPosition: `${x}% ${y}%` }}
|
style={{ objectPosition: `${x}% ${y}%` }}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
@@ -78,7 +78,7 @@ export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
|
|||||||
{/* Outer ring (dark) for contrast on light areas */}
|
{/* Outer ring (dark) for contrast on light areas */}
|
||||||
<div className="w-6 h-6 rounded-full border-2 border-black/50" />
|
<div className="w-6 h-6 rounded-full border-2 border-black/50" />
|
||||||
{/* Inner ring (white) for contrast on dark areas */}
|
{/* Inner ring (white) for contrast on dark areas */}
|
||||||
<div className="absolute inset-0 w-6 h-6 rounded-full border-2 border-white" style={{ margin: '1px' }} />
|
<div className="absolute inset-0 m-px w-6 h-6 rounded-full border-2 border-white" />
|
||||||
{/* Center dot */}
|
{/* Center dot */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-white shadow-sm" />
|
<div className="w-1.5 h-1.5 rounded-full bg-white shadow-sm" />
|
||||||
@@ -94,18 +94,20 @@ export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
|
|||||||
{/* Preset buttons */}
|
{/* Preset buttons */}
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
{presets.map((p) => (
|
{presets.map((p) => (
|
||||||
<button
|
<Button
|
||||||
key={p.value}
|
key={p.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => onChange(p.value)}
|
onClick={() => onChange(p.value)}
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-md border transition-colors ${
|
className={
|
||||||
keywordToPercent(currentValue) === p.value
|
keywordToPercent(currentValue) === p.value
|
||||||
? 'bg-primary-50 border-primary-300 text-primary-700'
|
? 'bg-primary-50 border-primary-300 text-primary-700'
|
||||||
: 'bg-white border-neutral-300 text-neutral-600 hover:bg-neutral-50'
|
: ''
|
||||||
}`}
|
}
|
||||||
>
|
>
|
||||||
{p.label}
|
{p.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
// Clear selection when category changes
|
// Clear selection when category changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
}, [categoryId]);
|
}, [categoryId, setSelectedPhotos]);
|
||||||
|
|
||||||
const handlePhotoClick = (index: number) => {
|
const handlePhotoClick = (index: number) => {
|
||||||
setOpenFeedbackInitially(false);
|
setOpenFeedbackInitially(false);
|
||||||
@@ -171,7 +171,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
try {
|
try {
|
||||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||||
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||||
} catch (error) {
|
} catch {
|
||||||
toastify.error(t('gallery.downloadError'));
|
toastify.error(t('gallery.downloadError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
|
|||||||
@@ -863,6 +863,12 @@
|
|||||||
"selectHeroPhoto": "Hero-Foto auswählen",
|
"selectHeroPhoto": "Hero-Foto auswählen",
|
||||||
"noHeroPhotoSelected": "Kein Hero-Foto ausgewählt",
|
"noHeroPhotoSelected": "Kein Hero-Foto ausgewählt",
|
||||||
"heroPhotoSelected": "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",
|
"noPhotosAvailable": "Keine Fotos verfügbar",
|
||||||
"processingRequest": "Ihre Anfrage wird verarbeitet...",
|
"processingRequest": "Ihre Anfrage wird verarbeitet...",
|
||||||
"eventTypeWedding": "Hochzeit",
|
"eventTypeWedding": "Hochzeit",
|
||||||
|
|||||||
@@ -489,6 +489,12 @@
|
|||||||
"selectHeroPhoto": "Select Hero Photo",
|
"selectHeroPhoto": "Select Hero Photo",
|
||||||
"noHeroPhotoSelected": "No hero photo selected",
|
"noHeroPhotoSelected": "No hero photo selected",
|
||||||
"heroPhotoSelected": "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",
|
"noPhotosAvailable": "No photos available",
|
||||||
"processingRequest": "Processing your request...",
|
"processingRequest": "Processing your request...",
|
||||||
"eventTypeWedding": "Wedding",
|
"eventTypeWedding": "Wedding",
|
||||||
|
|||||||
@@ -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 || ''); }, []);
|
useEffect(() => { load(currentPath || ''); }, []);
|
||||||
|
|
||||||
const navigateUp = () => {
|
const navigateUp = () => {
|
||||||
@@ -295,7 +296,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
|
|
||||||
const mediaTypes = useMemo(() => {
|
const mediaTypes = useMemo(() => {
|
||||||
const types = new Set<'photo' | 'video'>();
|
const types = new Set<'photo' | 'video'>();
|
||||||
photos.forEach((p: any) => {
|
photos.forEach((p) => {
|
||||||
const mediaType = (p.media_type as 'photo' | 'video' | undefined)
|
const mediaType = (p.media_type as 'photo' | 'video' | undefined)
|
||||||
|| ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo');
|
|| ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo');
|
||||||
if (mediaType === 'video' || mediaType === 'photo') {
|
if (mediaType === 'video' || mediaType === 'photo') {
|
||||||
@@ -436,7 +437,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
setCurrentPresetName(event.color_theme);
|
setCurrentPresetName(event.color_theme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
|
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
|
||||||
setCurrentPresetName('default');
|
setCurrentPresetName('default');
|
||||||
}
|
}
|
||||||
@@ -578,7 +579,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
// Update feedback settings separately
|
// Update feedback settings separately
|
||||||
try {
|
try {
|
||||||
await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
|
await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Error already handled by mutation
|
// Error already handled by mutation
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -869,7 +870,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Hero Image Focal Point Picker (#162) */}
|
{/* Hero Image Focal Point Picker (#162) */}
|
||||||
{editForm.hero_photo_id && (() => {
|
{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;
|
const heroImageUrl = heroPhoto?.thumbnail_url || heroPhoto?.url;
|
||||||
if (!heroImageUrl) return null;
|
if (!heroImageUrl) return null;
|
||||||
return (
|
return (
|
||||||
@@ -1477,7 +1478,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await eventsService.resendCreationEmail(event.id);
|
await eventsService.resendCreationEmail(event.id);
|
||||||
toast.success(t('events.creationEmailResent'));
|
toast.success(t('events.creationEmailResent'));
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error(t('events.failedToResendEmail'));
|
toast.error(t('events.failedToResendEmail'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1653,7 +1654,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
toast.info(t('events.downloadingArchive', { name: event.event_name }));
|
toast.info(t('events.downloadingArchive', { name: event.event_name }));
|
||||||
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
|
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
|
||||||
toast.success(t('events.downloadStarted'));
|
toast.success(t('events.downloadStarted'));
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error(t('events.failedToDownloadArchive'));
|
toast.error(t('events.failedToDownloadArchive'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export interface GalleryLayoutSettings {
|
|||||||
|
|
||||||
// Hero specific
|
// Hero specific
|
||||||
heroImageId?: number;
|
heroImageId?: number;
|
||||||
heroImagePosition?: 'top' | 'center' | 'bottom';
|
|
||||||
heroOverlayOpacity?: number;
|
heroOverlayOpacity?: number;
|
||||||
|
|
||||||
// Mosaic specific
|
// Mosaic specific
|
||||||
|
|||||||
Reference in New Issue
Block a user