From 0790a1ddad774af89827a0a392e9fae0a945bff2 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 22 Jan 2026 11:41:51 +0100 Subject: [PATCH] feat: add per-event hero logo customization options Add configurable hero logo settings for individual events: - Logo visibility toggle (show/hide in hero section) - Logo size options (small, medium, large, xlarge) - Logo position options (top, center, bottom) Changes include: - Database migration for hero_logo_visible, hero_logo_size, hero_logo_position fields - Backend routes updated to handle new settings - Frontend admin page with logo customization controls - HeroGalleryLayout component with dynamic logo rendering - i18n translations for EN and DE Also updates .gitignore to exclude test files and artifacts. --- .gitignore | 8 ++ .../core/062_add_hero_logo_settings.js | 70 +++++++++++ backend/src/routes/adminEvents.js | 28 ++++- backend/src/routes/gallery.js | 13 ++- .../src/components/gallery/GalleryView.tsx | 3 + .../gallery/PhotoGridWithLayouts.tsx | 12 +- .../gallery/layouts/HeroGalleryLayout.tsx | 100 +++++++++++++--- frontend/src/i18n/locales/de.json | 16 +++ frontend/src/i18n/locales/en.json | 16 +++ frontend/src/pages/admin/EventDetailsPage.tsx | 110 +++++++++++++++++- frontend/src/types/index.ts | 4 + 11 files changed, 353 insertions(+), 27 deletions(-) create mode 100644 backend/migrations/core/062_add_hero_logo_settings.js diff --git a/.gitignore b/.gitignore index 46f90243..7a1c752e 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,11 @@ backup/ # Local SQLite files in backend backend/*.sqlite* + +# Test files and artifacts +test-logo*.jpg +test-logo*.png +test-results/ + +# Development docker compose +docker-compose.dev.yml diff --git a/backend/migrations/core/062_add_hero_logo_settings.js b/backend/migrations/core/062_add_hero_logo_settings.js new file mode 100644 index 00000000..1c8f651e --- /dev/null +++ b/backend/migrations/core/062_add_hero_logo_settings.js @@ -0,0 +1,70 @@ +/** + * Migration: Add hero logo customization settings to events table + * + * Allows per-event customization of the hero gallery logo: + * - hero_logo_visible: Show/hide the logo overlay + * - hero_logo_size: Logo size (small, medium, large, xlarge) + * - hero_logo_position: Logo position (top, center, bottom) + * + * Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout + */ + +exports.up = async function (knex) { + console.log('Adding hero logo settings to events table...'); + + // Add hero_logo_visible column + const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible'); + if (!hasVisibleColumn) { + await knex.schema.table('events', (table) => { + table.boolean('hero_logo_visible').notNullable().defaultTo(true); + }); + console.log('Added hero_logo_visible column'); + } + + // Add hero_logo_size column + const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size'); + if (!hasSizeColumn) { + await knex.schema.table('events', (table) => { + table.string('hero_logo_size', 20).notNullable().defaultTo('medium'); + }); + console.log('Added hero_logo_size column'); + } + + // Add hero_logo_position column + const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position'); + if (!hasPositionColumn) { + await knex.schema.table('events', (table) => { + table.string('hero_logo_position', 20).notNullable().defaultTo('top'); + }); + console.log('Added hero_logo_position column'); + } + + console.log('Migration 062_add_hero_logo_settings completed successfully'); +}; + +exports.down = async function (knex) { + console.log('Rolling back hero logo settings...'); + + const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible'); + if (hasVisibleColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_visible'); + }); + } + + const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size'); + if (hasSizeColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_size'); + }); + } + + const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position'); + if (hasPositionColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_position'); + }); + } + + console.log('Hero logo settings columns dropped'); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 2742c2fc..cdd84912 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -158,7 +158,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('disable_right_click').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), body('watermark_text').optional().trim(), - body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt() + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), + // Hero logo settings + body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) ], async (req, res) => { try { logger.debug('Create event request body', { body: req.body }); @@ -197,7 +201,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ moderate_comments = true, show_feedback_to_guests = true, // CSS Template - css_template_id = null + css_template_id = null, + // Hero logo settings + hero_logo_visible = true, + hero_logo_size = 'medium', + hero_logo_position = 'top' } = req.body; const customerName = getCustomerNameFromPayload(req.body); @@ -335,7 +343,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_text, require_password: formatBoolean(requirePassword), - css_template_id: css_template_id || null + css_template_id: css_template_id || null, + hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true), + hero_logo_size: hero_logo_size || 'medium', + hero_logo_position: hero_logo_position || 'top' }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) @@ -612,7 +623,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ } return true; }), - body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt() + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), + // Hero logo settings + body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) ], async (req, res) => { try { const errors = validationResult(req); @@ -724,6 +739,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); } + // Format hero logo settings if provided + if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) { + updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); + } + // Update event await db('events') .where('id', id) diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index a8a3db71..eb86f923 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -115,7 +115,10 @@ router.get('/:slug/info', async (req, res) => { 'require_password', 'color_theme', 'enable_devtools_protection', - 'use_canvas_rendering' + 'use_canvas_rendering', + 'hero_logo_visible', + 'hero_logo_size', + 'hero_logo_position' ) .first(); @@ -162,7 +165,10 @@ router.get('/:slug/info', async (req, res) => { watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1', watermark_text: event.watermark_text, enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1', - use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1' + use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1', + hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0', + hero_logo_size: event.hero_logo_size || 'medium', + hero_logo_position: event.hero_logo_position || 'top' }); } catch (error) { console.error('Error fetching gallery info:', error); @@ -332,6 +338,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { watermark_text: req.event.watermark_text, enable_devtools_protection: req.event.enable_devtools_protection === true, use_canvas_rendering: req.event.use_canvas_rendering === true, + hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0', + hero_logo_size: req.event.hero_logo_size || 'medium', + hero_logo_position: req.event.hero_logo_position || 'top', ...protectionSettings }, categories: categories, diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 4304082d..5b8e5090 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -689,6 +689,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { disableRightClick={disableRightClick} enableDevtoolsProtection={enableDevtoolsProtection} useCanvasRendering={useCanvasRendering} + heroLogoVisible={data?.event?.hero_logo_visible !== false} + heroLogoSize={data?.event?.hero_logo_size || 'medium'} + heroLogoPosition={data?.event?.hero_logo_position || 'top'} /> diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 3c645865..2c123b79 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -52,6 +52,10 @@ interface PhotoGridWithLayoutsProps { requireNameEmail?: boolean; }; onFeedbackChange?: () => void; + // Hero logo customization options + heroLogoVisible?: boolean; + heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge'; + heroLogoPosition?: 'top' | 'center' | 'bottom'; } export const PhotoGridWithLayouts: React.FC = ({ @@ -76,7 +80,10 @@ export const PhotoGridWithLayouts: React.FC = ({ eventName, eventLogo, eventDate, - expiresAt + expiresAt, + heroLogoVisible = true, + heroLogoSize = 'medium', + heroLogoPosition = 'top' }) => { const { t } = useTranslation(); const { theme } = useTheme(); @@ -200,6 +207,9 @@ export const PhotoGridWithLayouts: React.FC = ({ expiresAt, feedbackEnabled, feedbackOptions, + heroLogoVisible, + heroLogoSize, + heroLogoPosition, }; let LayoutComponent; diff --git a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx index e06c725a..4004d021 100644 --- a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx @@ -18,6 +18,10 @@ interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps { expiresAt?: string; // Use a static hero photo independent of current filter heroPhotoOverride?: Photo | null; + // Hero logo customization options + heroLogoVisible?: boolean; + heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge'; + heroLogoPosition?: 'top' | 'center' | 'bottom'; } export const HeroGalleryLayout: React.FC = ({ @@ -34,6 +38,9 @@ export const HeroGalleryLayout: React.FC = ({ eventDate, expiresAt, heroPhotoOverride, + heroLogoVisible = true, + heroLogoSize = 'medium', + heroLogoPosition = 'top', allowDownloads = true, protectionLevel = 'standard', useEnhancedProtection = false, @@ -51,6 +58,22 @@ export const HeroGalleryLayout: React.FC = ({ const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const gallerySettings = theme.gallerySettings || {}; const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3; + + // Helper function to get logo size classes + const getLogoSizeClasses = (size: string): string => { + switch (size) { + case 'small': + return 'h-12 sm:h-14 lg:h-16'; + case 'medium': + return 'h-20 sm:h-24 lg:h-32'; + case 'large': + return 'h-28 sm:h-32 lg:h-40'; + case 'xlarge': + return 'h-36 sm:h-40 lg:h-48'; + default: + return 'h-20 sm:h-24 lg:h-32'; + } + }; const [likedIds, setLikedIds] = useState>(new Set()); const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback); const gridRef = useRef(null); @@ -133,31 +156,51 @@ export const HeroGalleryLayout: React.FC = ({ {/* Hero Content */}
- {/* Logo - Show custom logo or fallback to PicPeak logo */} -
- Event logo -
- + {/* Logo at top position */} + {heroLogoVisible && heroLogoPosition === 'top' && ( +
+ Event logo +
+ )} + {/* Event Title */} {eventName && (

{eventName}

)} - + + {/* Logo at center position (between title and dates) */} + {heroLogoVisible && heroLogoPosition === 'center' && ( +
+ Event logo +
+ )} + {/* Event Dates */} {(eventDate || expiresAt) && (
@@ -175,6 +218,25 @@ export const HeroGalleryLayout: React.FC = ({ )}
)} + + {/* Logo at bottom position */} + {heroLogoVisible && heroLogoPosition === 'bottom' && ( +
+ Event logo +
+ )}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 4b5377eb..fdbb515f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -833,6 +833,22 @@ "protectionLevelStandard": "Standard - Tastenkombinationen blockiert", "protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung", "protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering", + "heroLogoSettings": "Hero-Logo-Einstellungen", + "heroLogoVisible": "Logo im Hero-Bereich anzeigen", + "heroLogoSize": "Logo-Größe", + "heroLogoSizeSmall": "Klein", + "heroLogoSizeMedium": "Mittel", + "heroLogoSizeLarge": "Groß", + "heroLogoSizeXLarge": "Extra Groß", + "heroLogoPosition": "Logo-Position", + "heroLogoPositionTop": "Oben (über dem Titel)", + "heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)", + "heroLogoPositionBottom": "Unten (unter dem Datum)", + "heroLogoInfo": "Diese Einstellungen gelten für das Hero-Layout der Galerie. Sie können das Logo ausblenden oder Größe und Position anpassen.", + "heroLogoVisibleLabel": "Logo sichtbar", + "heroLogoSizeLabel": "Größe", + "heroLogoPositionLabel": "Position", + "heroLogoHidden": "Logo ausgeblendet", "heroPhoto": "Hero-Foto", "heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout", "selectHeroPhoto": "Hero-Foto auswählen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 43fbe80c..2a436507 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -459,6 +459,22 @@ "protectionLevelStandard": "Standard - Keyboard shortcuts blocked", "protectionLevelEnhanced": "Enhanced - Print screen detection", "protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering", + "heroLogoSettings": "Hero Logo Settings", + "heroLogoVisible": "Display logo in hero section", + "heroLogoSize": "Logo Size", + "heroLogoSizeSmall": "Small", + "heroLogoSizeMedium": "Medium", + "heroLogoSizeLarge": "Large", + "heroLogoSizeXLarge": "Extra Large", + "heroLogoPosition": "Logo Position", + "heroLogoPositionTop": "Top (above title)", + "heroLogoPositionCenter": "Center (between title and dates)", + "heroLogoPositionBottom": "Bottom (below dates)", + "heroLogoInfo": "These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.", + "heroLogoVisibleLabel": "Logo visible", + "heroLogoSizeLabel": "Size", + "heroLogoPositionLabel": "Position", + "heroLogoHidden": "Logo hidden", "heroPhoto": "Hero Photo", "heroPhotoHelp": "Select a featured photo for the hero gallery layout", "selectHeroPhoto": "Select Hero Photo", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 753fc5e6..958519af 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -25,7 +25,8 @@ import { Shield, Monitor, Droplets, - MousePointer + MousePointer, + Layout } from 'lucide-react'; import { parseISO, differenceInDays, isValid } from 'date-fns'; @@ -160,6 +161,10 @@ export const EventDetailsPage: React.FC = () => { watermark_downloads: boolean; enable_devtools_protection: boolean; use_canvas_rendering: boolean; + // Hero logo settings + hero_logo_visible: boolean; + hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge'; + hero_logo_position: 'top' | 'center' | 'bottom'; }; const [isEditing, setIsEditing] = useState(false); @@ -184,6 +189,10 @@ export const EventDetailsPage: React.FC = () => { watermark_downloads: false, enable_devtools_protection: true, use_canvas_rendering: false, + // Hero logo settings + hero_logo_visible: true, + hero_logo_size: 'medium', + hero_logo_position: 'top', }); const [feedbackSettings, setFeedbackSettings] = useState({ feedback_enabled: false, @@ -406,6 +415,10 @@ export const EventDetailsPage: React.FC = () => { watermark_downloads: event.watermark_downloads ?? false, enable_devtools_protection: event.enable_devtools_protection ?? true, use_canvas_rendering: event.use_canvas_rendering ?? false, + // Load hero logo settings from event + hero_logo_visible: event.hero_logo_visible ?? true, + hero_logo_size: event.hero_logo_size || 'medium', + hero_logo_position: event.hero_logo_position || 'top', }); setShowNewPassword(false); @@ -496,6 +509,10 @@ export const EventDetailsPage: React.FC = () => { watermark_downloads: editForm.watermark_downloads, enable_devtools_protection: editForm.enable_devtools_protection, use_canvas_rendering: editForm.use_canvas_rendering, + // Hero logo settings + hero_logo_visible: editForm.hero_logo_visible, + hero_logo_size: editForm.hero_logo_size, + hero_logo_position: editForm.hero_logo_position, }; // Only include fields that have defined values @@ -1062,6 +1079,66 @@ export const EventDetailsPage: React.FC = () => {

+ + {/* Hero Logo Settings */} +
+

+ + {t('events.heroLogoSettings', 'Hero Logo Settings')} +

+ +
+ + + {editForm.hero_logo_visible && ( + <> +
+ + +
+ +
+ + +
+ + )} + +

+ {t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')} +

+
+
) : (
@@ -1197,6 +1274,37 @@ export const EventDetailsPage: React.FC = () => { + + {/* Hero Logo Settings Display */} +
+
+ + {t('events.heroLogoSettings', 'Hero Logo Settings')} +
+
+
+ {event.hero_logo_visible !== false ? ( + <> + + + {t('events.heroLogoVisibleLabel', 'Logo visible')} + + + {t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'} + + + {t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'} + + + ) : ( + + + {t('events.heroLogoHidden', 'Logo hidden')} + + )} +
+
+
)} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 95bc67bb..75c6815d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -41,6 +41,10 @@ export interface Event { watermark_downloads?: boolean; enable_devtools_protection?: boolean; use_canvas_rendering?: boolean; + // Hero logo customization fields + hero_logo_visible?: boolean; + hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge'; + hero_logo_position?: 'top' | 'center' | 'bottom'; } export interface GalleryInfo {