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.
This commit is contained in:
Paul Nothaft
2026-01-22 13:54:23 +01:00
parent f8881d5bd6
commit 0790a1ddad
11 changed files with 353 additions and 27 deletions
@@ -689,6 +689,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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'}
/>
</div>
@@ -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<PhotoGridWithLayoutsProps> = ({
@@ -76,7 +80,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
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<PhotoGridWithLayoutsProps> = ({
expiresAt,
feedbackEnabled,
feedbackOptions,
heroLogoVisible,
heroLogoSize,
heroLogoPosition,
};
let LayoutComponent;
@@ -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<HeroGalleryLayoutProps> = ({
@@ -34,6 +38,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
eventDate,
expiresAt,
heroPhotoOverride,
heroLogoVisible = true,
heroLogoSize = 'medium',
heroLogoPosition = 'top',
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
@@ -51,6 +58,22 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
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<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
const gridRef = useRef<HTMLDivElement | null>(null);
@@ -133,31 +156,51 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center px-4">
{/* Logo - Show custom logo or fallback to PicPeak logo */}
<div className="mb-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className="h-20 sm:h-24 lg:h-32 mx-auto"
style={{
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
{/* Logo at top position */}
{heroLogoVisible && heroLogoPosition === 'top' && (
<div className="mb-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Title */}
{eventName && (
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
{eventName}
</h1>
)}
{/* Logo at center position (between title and dates) */}
{heroLogoVisible && heroLogoPosition === 'center' && (
<div className="my-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Dates */}
{(eventDate || expiresAt) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
@@ -175,6 +218,25 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
)}
</div>
)}
{/* Logo at bottom position */}
{heroLogoVisible && heroLogoPosition === 'bottom' && (
<div className="mt-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
</div>
</div>
+16
View File
@@ -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",
+16
View File
@@ -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",
+109 -1
View File
@@ -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<FeedbackSettingsType>({
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 = () => {
</p>
</div>
</div>
{/* Hero Logo Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
<Layout className="w-4 h-4 text-primary-600" />
{t('events.heroLogoSettings', 'Hero Logo Settings')}
</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.hero_logo_visible}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
</label>
{editForm.hero_logo_visible && (
<>
<div className="ml-6">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.heroLogoSize', 'Logo Size')}
</label>
<select
value={editForm.hero_logo_size}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
>
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
<option value="large">{t('events.heroLogoSizeLarge', 'Large')}</option>
<option value="xlarge">{t('events.heroLogoSizeXLarge', 'Extra Large')}</option>
</select>
</div>
<div className="ml-6">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.heroLogoPosition', 'Logo Position')}
</label>
<select
value={editForm.hero_logo_position}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
>
<option value="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
</select>
</div>
</>
)}
<p className="text-xs text-neutral-500 mt-2">
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
</p>
</div>
</div>
</div>
) : (
<dl className="space-y-4">
@@ -1197,6 +1274,37 @@ export const EventDetailsPage: React.FC = () => {
</div>
</dd>
</div>
{/* Hero Logo Settings Display */}
<div className="pt-3 mt-3 border-t border-neutral-200">
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
<Layout className="w-4 h-4" />
{t('events.heroLogoSettings', 'Hero Logo Settings')}
</dt>
<dd className="mt-2 text-sm text-neutral-900">
<div className="flex flex-wrap gap-2">
{event.hero_logo_visible !== false ? (
<>
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded">
<Image className="w-3 h-3 mr-1" />
{t('events.heroLogoVisibleLabel', 'Logo visible')}
</span>
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
{t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'}
</span>
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
{t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'}
</span>
</>
) : (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
<Image className="w-3 h-3 mr-1" />
{t('events.heroLogoHidden', 'Logo hidden')}
</span>
)}
</div>
</dd>
</div>
</dl>
)}
</Card>
+4
View File
@@ -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 {