diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index beae3989..5dcbbd6b 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -581,7 +581,8 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ throw new Error('Password must be at least 6 characters long'); } return true; - }) + }), + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt() ], async (req, res) => { try { const errors = validationResult(req); diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index ba14e9f0..fffb70d5 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; -import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info } from 'lucide-react'; +import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react'; import { Button, Card, Input } from '../common'; import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types'; +import type { EnabledTemplate } from '../../services/cssTemplates.service'; // import { settingsService } from '../../services/settings.service'; // import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; @@ -16,6 +17,10 @@ interface ThemeCustomizerEnhancedProps { hideActions?: boolean; onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise | void; isApplying?: boolean; + // CSS Template props + cssTemplates?: EnabledTemplate[]; + cssTemplateId?: number | null; + onCssTemplateChange?: (templateId: number | null) => void; } const layoutIcons: Record = { @@ -38,7 +43,10 @@ export const ThemeCustomizerEnhanced: React.FC = ( showGalleryLayouts = true, hideActions = false, onApply, - isApplying = false + isApplying = false, + cssTemplates, + cssTemplateId, + onCssTemplateChange }) => { const { t } = useTranslation(); const [localTheme, setLocalTheme] = useState(value); @@ -566,11 +574,67 @@ export const ThemeCustomizerEnhanced: React.FC = ( - {/* Custom CSS */} + {/* CSS Template Selector - only show if templates are provided */} + {cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && ( + +

+ + {t('branding.cssTemplate', 'CSS Template')} +

+

+ {t('branding.cssTemplateDescription', 'Select a pre-built CSS template to apply application-wide styling to this gallery. Templates can be managed in Settings > CSS Templates.')} +

+
+ {/* No template option */} + + {/* Template options */} + {cssTemplates.map((template) => ( + + ))} +
+
+ )} + + {/* Event-specific Custom CSS */}

- {t('branding.customCSS')} + {t('branding.eventCustomCSS', 'Event-specific Custom CSS')}

{/* Collapsible Instructions Panel */} diff --git a/frontend/src/components/admin/ThemeEditorModal.tsx b/frontend/src/components/admin/ThemeEditorModal.tsx index 85bfe7e5..ecd1f904 100644 --- a/frontend/src/components/admin/ThemeEditorModal.tsx +++ b/frontend/src/components/admin/ThemeEditorModal.tsx @@ -4,13 +4,15 @@ import { Button } from '../common'; import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; import { GalleryPreview } from './GalleryPreview'; import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types'; +import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service'; import { useTranslation } from 'react-i18next'; interface ThemeEditorModalProps { isOpen: boolean; onClose: () => void; - onSave: (theme: ThemeConfig, presetName: string) => void; + onSave: (theme: ThemeConfig, presetName: string, cssTemplateId: number | null) => void; currentTheme: ThemeConfig | string; + currentCssTemplateId?: number | null; eventName: string; } @@ -28,12 +30,29 @@ export const ThemeEditorModal: React.FC = ({ onClose, onSave, currentTheme, + currentCssTemplateId, eventName }) => { const { t } = useTranslation(); const [theme, setTheme] = useState(GALLERY_THEME_PRESETS.default.config); const [presetName, setPresetName] = useState('default'); const [previewLayout, setPreviewLayout] = useState(undefined); + const [cssTemplates, setCssTemplates] = useState([]); + const [cssTemplateId, setCssTemplateId] = useState(currentCssTemplateId ?? null); + + // Fetch CSS templates when modal opens + useEffect(() => { + if (isOpen) { + cssTemplatesService.getEnabledTemplates() + .then(setCssTemplates) + .catch(err => console.error('Failed to load CSS templates:', err)); + } + }, [isOpen]); + + // Update cssTemplateId when prop changes + useEffect(() => { + setCssTemplateId(currentCssTemplateId ?? null); + }, [currentCssTemplateId]); useEffect(() => { if (currentTheme) { @@ -82,7 +101,7 @@ export const ThemeEditorModal: React.FC = ({ }; const handleSave = () => { - onSave(theme, presetName); + onSave(theme, presetName, cssTemplateId); onClose(); }; @@ -128,6 +147,9 @@ export const ThemeEditorModal: React.FC = ({ isPreviewMode={true} showGalleryLayouts={true} hideActions={true} + cssTemplates={cssTemplates} + cssTemplateId={cssTemplateId} + onCssTemplateChange={setCssTemplateId} /> diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index e501e6e3..753fc5e6 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -58,6 +58,7 @@ import { archiveService } from '../../services/archive.service'; import { externalMediaService } from '../../services/externalMedia.service'; import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.service'; import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service'; +import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; const resolveShareLink = (link: string): string => { @@ -141,6 +142,7 @@ export const EventDetailsPage: React.FC = () => { type EditFormState = { welcome_message: string; color_theme: string; + css_template_id: number | null; expires_at: string; allow_user_uploads: boolean; upload_category_id: number | null; @@ -164,6 +166,7 @@ export const EventDetailsPage: React.FC = () => { const [editForm, setEditForm] = useState({ welcome_message: '', color_theme: '', + css_template_id: null, expires_at: '', allow_user_uploads: false, upload_category_id: null, @@ -207,7 +210,17 @@ export const EventDetailsPage: React.FC = () => { const [showRenameDialog, setShowRenameDialog] = useState(false); const [currentTheme, setCurrentTheme] = useState(null); const [currentPresetName, setCurrentPresetName] = useState('default'); - + const [cssTemplates, setCssTemplates] = useState([]); + + // Fetch CSS templates when component mounts or editing starts + useEffect(() => { + if (isEditing) { + cssTemplatesService.getEnabledTemplates() + .then(setCssTemplates) + .catch(err => console.error('Failed to load CSS templates:', err)); + } + }, [isEditing]); + // Photo filters state const [photoFilters, setPhotoFilters] = useState({ category_id: undefined as number | null | undefined, @@ -375,6 +388,7 @@ export const EventDetailsPage: React.FC = () => { 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'), allow_user_uploads: event.allow_user_uploads || false, upload_category_id: event.upload_category_id || null, @@ -474,6 +488,7 @@ export const EventDetailsPage: React.FC = () => { expires_at: editForm.expires_at, allow_user_uploads: editForm.allow_user_uploads, require_password: editForm.require_password, + css_template_id: editForm.css_template_id, // Download protection settings protection_level: editForm.protection_level, disable_right_click: editForm.disable_right_click, @@ -1357,6 +1372,9 @@ export const EventDetailsPage: React.FC = () => { isPreviewMode={true} showGalleryLayouts={true} hideActions={true} + cssTemplates={cssTemplates} + cssTemplateId={editForm.css_template_id} + onCssTemplateChange={(templateId) => setEditForm(prev => ({ ...prev, css_template_id: templateId }))} />
)}