From dadef81158972d28aa32812203500f77ed08a999 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 22 Jan 2026 09:37:18 +0100
Subject: [PATCH 1/6] fix: event-specific custom CSS settings not being saved
The ThemeCustomizerEnhanced component stored customCss in a separate
local state that was never propagated to the parent component when
hideActions was true (used in both CreateEventPage and EventDetailsPage).
Changes:
- handleChange() now includes customCss when propagating theme changes
- CSS textarea onChange now propagates customCss to parent in preview mode
- handlePresetSelect() clears customCss when selecting a preset
Fixes #136
---
.../admin/ThemeCustomizerEnhanced.tsx | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
index fffb70d5..df1df102 100644
--- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
+++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
@@ -67,15 +67,16 @@ export const ThemeCustomizerEnhanced: React.FC = (
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
-
+
// When any change is made, mark it as custom
if (selectedPreset !== 'custom' && onPresetChange) {
setSelectedPreset('custom');
onPresetChange('custom');
}
-
+
if (isPreviewMode) {
- onChange(updated);
+ // Include customCss in the propagated theme
+ onChange({ ...updated, customCss });
}
};
@@ -84,6 +85,7 @@ export const ThemeCustomizerEnhanced: React.FC = (
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
+ setCustomCss(''); // Clear custom CSS when selecting a preset
if (onPresetChange) {
onPresetChange(presetKey);
}
@@ -722,12 +724,17 @@ export const ThemeCustomizerEnhanced: React.FC = (
+
+ {/* Hero Logo Settings */}
+
+
+
+ {t('events.heroLogoSettings', 'Hero Logo Settings')}
+
+
+
+
+ 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"
+ />
+
+ {t('events.heroLogoVisible', 'Display logo in hero section')}
+
+
+ {editForm.hero_logo_visible && (
+ <>
+
+
+ {t('events.heroLogoSize', 'Logo Size')}
+
+ 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"
+ >
+ {t('events.heroLogoSizeSmall', 'Small')}
+ {t('events.heroLogoSizeMedium', 'Medium')}
+ {t('events.heroLogoSizeLarge', 'Large')}
+ {t('events.heroLogoSizeXLarge', 'Extra Large')}
+
+
+
+
+
+ {t('events.heroLogoPosition', 'Logo Position')}
+
+ 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"
+ >
+ {t('events.heroLogoPositionTop', 'Top (above title)')}
+ {t('events.heroLogoPositionCenter', 'Center (between title and dates)')}
+ {t('events.heroLogoPositionBottom', 'Bottom (below dates)')}
+
+
+ >
+ )}
+
+
+ {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 {
From d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 22 Jan 2026 11:56:34 +0100
Subject: [PATCH 5/6] fix: remove non-functional watermark toggle from Feature
Toggles
The "Enable watermark on photos" checkbox in Settings > General > Feature
Toggles was not connected to any backend logic - it stored a setting that
was never read or used. The actual working watermark functionality exists
in Settings > Branding.
This removes the dead toggle to eliminate user confusion (fixes #140).
---
.../src/features/settings/hooks/useSettingsState.ts | 3 ---
frontend/src/features/settings/tabs/GeneralTab.tsx | 10 ----------
frontend/src/i18n/locales/de.json | 2 --
frontend/src/i18n/locales/en.json | 2 --
4 files changed, 17 deletions(-)
diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts
index e6676828..002bde44 100644
--- a/frontend/src/features/settings/hooks/useSettingsState.ts
+++ b/frontend/src/features/settings/hooks/useSettingsState.ts
@@ -16,7 +16,6 @@ export interface GeneralSettings {
max_file_size_mb: number;
max_files_per_upload: number;
allowed_file_types: string;
- enable_watermark: boolean;
enable_analytics: boolean;
enable_registration: boolean;
maintenance_mode: boolean;
@@ -76,7 +75,6 @@ export function useSettingsState() {
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
- enable_watermark: false,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
@@ -146,7 +144,6 @@ export function useSettingsState() {
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
- enable_watermark: toBoolean(settings.general_enable_watermark, false),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx
index e0a26e14..c945e234 100644
--- a/frontend/src/features/settings/tabs/GeneralTab.tsx
+++ b/frontend/src/features/settings/tabs/GeneralTab.tsx
@@ -184,16 +184,6 @@ export const GeneralTab: React.FC = ({
{t('settings.general.featureToggles')}
-
- setGeneralSettings(prev => ({ ...prev, enable_watermark: e.target.checked }))}
- className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
- />
- {t('settings.general.enableWatermark')}
-
-
Date: Thu, 22 Jan 2026 13:04:43 +0100
Subject: [PATCH 6/6] fix: handle null dates in dashboard and gallery pages
Add null checks for expires_at and event_date fields to prevent
TypeError when calling parseISO() on null values. This fixes crashes
that occurred after making event dates optional.
- AdminDashboard: skip events with null expires_at in expiring filter
- GalleryPage: handle null expires_at in expiration calculation
- GalleryView: make daysUntilExpiration nullable with explicit checks
- EventDetailsPage: return null from safeParseDate for null inputs
---
.../src/components/gallery/GalleryView.tsx | 12 ++---
frontend/src/pages/GalleryPage.tsx | 12 ++---
frontend/src/pages/admin/AdminDashboard.tsx | 9 ++--
frontend/src/pages/admin/EventDetailsPage.tsx | 45 +++++++++++--------
4 files changed, 47 insertions(+), 31 deletions(-)
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index 5b8e5090..bd4d95ab 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -310,10 +310,12 @@ export const GalleryView: React.FC = ({ slug, event }) => {
}
}, [settingsData, data, setTheme]); // Use data instead of event prop
- // Calculate days until expiration
- const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
- const showUrgentWarning = daysUntilExpiration <= 7;
- const isExpired = daysUntilExpiration < 0;
+ // Calculate days until expiration (null means never expires)
+ const daysUntilExpiration = event.expires_at
+ ? differenceInDays(parseISO(event.expires_at), new Date())
+ : null;
+ const showUrgentWarning = daysUntilExpiration !== null && daysUntilExpiration <= 7;
+ const isExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
// Filter and sort photos
const filteredPhotos = useMemo(() => {
@@ -602,7 +604,7 @@ export const GalleryView: React.FC = ({ slug, event }) => {
headerExtra={(() => {
const items = [];
- if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
+ if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
items.push(
);
diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx
index 12fd94b3..f4d1fafd 100644
--- a/frontend/src/pages/GalleryPage.tsx
+++ b/frontend/src/pages/GalleryPage.tsx
@@ -187,8 +187,8 @@ export const GalleryPage: React.FC = () => {
}
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
- // Calculate days until expiration
- const daysUntilExpiration = galleryInfo
+ // Calculate days until expiration (null if no expiration set)
+ const daysUntilExpiration = galleryInfo?.expires_at
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
: null;
@@ -394,9 +394,11 @@ export const GalleryPage: React.FC = () => {
{t('gallery.expired')}
-
- {t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
-
+ {galleryInfo.expires_at && (
+
+ {t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
+
+ )}
{t('gallery.contactOrganizer')}
diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx
index 92d8a24e..4a4495af 100644
--- a/frontend/src/pages/admin/AdminDashboard.tsx
+++ b/frontend/src/pages/admin/AdminDashboard.tsx
@@ -73,6 +73,7 @@ export const AdminDashboard: React.FC = () => {
// Calculate expiring events
const activeEvents = eventsData?.events.filter(e => e.is_active && !e.is_archived) || [];
const expiringEvents = activeEvents.filter(e => {
+ if (!e.expires_at) return false;
const days = differenceInDays(parseISO(e.expires_at), new Date());
return days <= 7 && days > 0;
});
@@ -204,9 +205,11 @@ export const AdminDashboard: React.FC = () => {
>
{event.event_name}
-
- {format(parseISO(event.event_date), 'PP')}
-
+ {event.event_date && (
+
+ {format(parseISO(event.event_date), 'PP')}
+
+ )}
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index 958519af..c2867881 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -31,9 +31,9 @@ import {
import { parseISO, differenceInDays, isValid } from 'date-fns';
// Helper to safely parse dates that might be strings, Date objects, or timestamps
-const safeParseDate = (dateValue: unknown): Date => {
+const safeParseDate = (dateValue: unknown): Date | null => {
if (!dateValue) {
- return new Date();
+ return null;
}
if (dateValue instanceof Date) {
return dateValue;
@@ -45,7 +45,7 @@ const safeParseDate = (dateValue: unknown): Date => {
const parsed = parseISO(dateValue);
return isValid(parsed) ? parsed : new Date(dateValue);
}
- return new Date();
+ return null;
};
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -389,16 +389,17 @@ export const EventDetailsPage: React.FC = () => {
);
}
- const daysUntilExpiration = differenceInDays(safeParseDate(event.expires_at), new Date());
- const isExpired = daysUntilExpiration <= 0;
- const isExpiring = daysUntilExpiration > 0 && daysUntilExpiration <= 7;
+ const expiresAtDate = safeParseDate(event.expires_at);
+ const daysUntilExpiration = expiresAtDate ? differenceInDays(expiresAtDate, new Date()) : null;
+ const isExpired = daysUntilExpiration !== null && daysUntilExpiration <= 0;
+ const isExpiring = daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7;
const handleStartEdit = () => {
setEditForm({
welcome_message: event.welcome_message || '',
color_theme: event.color_theme || '',
css_template_id: event.css_template_id || null,
- expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'),
+ expires_at: expiresAtDate ? format(expiresAtDate, 'yyyy-MM-dd') : '',
allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null,
@@ -616,10 +617,12 @@ export const EventDetailsPage: React.FC = () => {
{event.event_name}
-
-
- {format(safeParseDate(event.event_date), 'PPP')}
-
+ {event.event_date && (
+
+
+ {format(safeParseDate(event.event_date)!, 'PPP')}
+
+ )}
{event.event_type}
{
{t('events.created')}
- {format(safeParseDate(event.created_at), 'PP')}
+ {event.created_at && format(safeParseDate(event.created_at)!, 'PP')}
{t('events.expires')}
- {format(safeParseDate(event.expires_at), 'PP')}
- {!event.is_archived && daysUntilExpiration > 0 && (
-
- {t('events.daysLeft', { count: daysUntilExpiration })}
-
+ {event.expires_at ? (
+ <>
+ {format(safeParseDate(event.expires_at)!, 'PP')}
+ {!event.is_archived && daysUntilExpiration !== null && daysUntilExpiration > 0 && (
+
+ {t('events.daysLeft', { count: daysUntilExpiration })}
+
+ )}
+ >
+ ) : (
+ {t('events.neverExpires', 'Never')}
)}
@@ -1517,7 +1526,7 @@ export const EventDetailsPage: React.FC = () => {
{t('events.archivedOn')}
- {event.archived_at && format(safeParseDate(event.archived_at), 'PPp')}
+ {event.archived_at && format(safeParseDate(event.archived_at)!, 'PPp')}