diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index b0901fdd..7ac528d1 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -1,45 +1,20 @@ import React, { useState, useEffect } from 'react'; -import { Palette, RotateCcw, Check, Layout, LayoutTemplate, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film, AlertTriangle } from 'lucide-react'; -import { Button, Card, Input } from '../common'; -import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types'; +import { Palette, RotateCcw } from 'lucide-react'; +import { Button } 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 { fontsService, extractFamilyName, type FontDefinition } from '../../services/fonts.service'; +import { fontsService, type FontDefinition } from '../../services/fonts.service'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; - -/** - * Build the CSS font-family value for a scanned font, using the generic - * fallback the backend supplied (from each family's optional meta.json). - * Defaults to 'sans-serif' when the backend doesn't report one — keeps - * compatibility with backends that predate the generic field. - */ -function buildFontFamilyValue(font: FontDefinition): string { - const generic = font.generic ?? 'sans-serif'; - // Always quote the family name (covers multi-word like 'Playfair Display'). - return `'${font.family}', ${generic}`; -} - -/** - * Match a saved CSS font-family string against the available scanned families - * and return the canonical option value the dropdown renders. Handles both - * legacy unquoted strings ("Inter, sans-serif") and the new quoted format - * ("'Inter', sans-serif"), so events saved before this change still show the - * right option as selected. - */ -function resolveFontDropdownValue( - saved: string | undefined, - available: FontDefinition[] | undefined, - fallback: string -): string { - if (!saved) return fallback; - const family = extractFamilyName(saved); - if (!family) return saved; // generic family like "system-ui, sans-serif" - const match = (available || []).find( - (f) => f.family.toLowerCase() === family.toLowerCase() - ); - return match ? buildFontFamilyValue(match) : saved; -} +import { ThemePresetsCard } from './theme-customizer/ThemePresetsCard'; +import { GalleryLayoutCard } from './theme-customizer/GalleryLayoutCard'; +import { HeaderStyleCard } from './theme-customizer/HeaderStyleCard'; +import { ControlsStyleCard } from './theme-customizer/ControlsStyleCard'; +import { ColorCustomizationCard } from './theme-customizer/ColorCustomizationCard'; +import { TypographyStyleCard } from './theme-customizer/TypographyStyleCard'; +import { CssTemplateCard } from './theme-customizer/CssTemplateCard'; +import { CustomCssCard } from './theme-customizer/CustomCssCard'; interface ThemeCustomizerEnhancedProps { value: ThemeConfig; @@ -74,96 +49,6 @@ interface ThemeCustomizerEnhancedProps { slotBeforeCustomCss?: React.ReactNode; } -/** - * Compact color-picker row used by the 8-token palette. - * Renders [Label + Info icon (tooltip)] / [color swatch + hex input]. - * Help text is hidden in the static layout (lives on the Info icon's title - * attribute) so all rows are the same height — keeps the four Surfaces - * pickers and the two Accent pickers grid-aligned without forcing the user - * to read every help string up front. - */ -const ColorPickerRow: React.FC<{ - label: string; - help: string; - value: string; - fallback: string; - onChange: (value: string) => void; -}> = ({ label, help, value, fallback, onChange }) => ( -
- {t('branding.betaThumbnailWarningTitle')} -
-- {t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: MIN_RECOMMENDED_THUMBNAIL_SIZE })} -
- { - e.preventDefault(); - window.location.href = '/admin/settings'; - }} - > - {t('branding.betaThumbnailWarningLink')} → - -- {t('branding.betaThumbnailWarningTitle')} -
-- {t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: MIN_RECOMMENDED_THUMBNAIL_SIZE })} -
- { - e.preventDefault(); - window.location.href = '/admin/settings'; - }} - > - {t('branding.betaThumbnailWarningLink')} → - -- {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} -
-- {localTheme.gallerySettings?.masonryMode === 'columns' - ? t('branding.masonryModeHint.columns', 'Pinterest-style vertical columns with varied heights') - : localTheme.gallerySettings?.masonryMode === 'flickr' - ? t('branding.masonryModeHint.flickr', 'Flickr\'s open-source justified layout algorithm') - : localTheme.gallerySettings?.masonryMode === 'justified' - ? t('branding.masonryModeHint.justified', 'Google Photos-style rows using Knuth-Plass algorithm for optimal breaks') - : t('branding.masonryModeHint.rows', 'Custom row-based justified layout')} -
-- {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} -
-- {t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')} -
-- {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} -
-- {t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')} -
-- {t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')} -
-- {t('branding.controlsStyleDescription', 'Choose how gallery filters and controls are displayed.')} -
-
-
- {t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')} -
- >)} - - {/* - * Force color mode (instance-wide). Lives next to the per-theme - * Color Mode picker so the admin can find both controls in one - * place. The data flows through props from BrandingPage which - * persists it to branding settings; only renders when the - * onForceColorModeChange handler is provided (i.e. only on the - * Branding admin page, not in event-level theme editors). - */} - {onForceColorModeChange && ( -- {t( - 'branding.forceColorModeHelp', - 'Lock the entire admin and public site to dark or light. The user-facing dark/light toggle is hidden whenever a lock is active. Per-event themes that try to override the colour mode are also forced to follow.' - )} -
-- {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.')} -
-
- {t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
- - {t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')} -
-
-{`--color-background: ${localTheme.backgroundColor || '#fafafa'};
---color-surface: ${localTheme.surfaceColor || '#ffffff'};
---color-elevated: ${localTheme.elevatedColor || '#f5f5f5'};
---color-surface-border: ${localTheme.surfaceBorderColor || '#e5e5e5'};
---color-text: ${localTheme.textColor || '#171717'};
---color-muted-text: ${localTheme.mutedTextColor || '#737373'};
---color-accent: ${localTheme.accentColor || '#22c55e'};
---color-accent-dark: ${localTheme.accentDarkColor || localTheme.primaryColor || '#5C8762'};
---font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
---heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
-
- - {t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')} -
-
-{`.gallery-container { /* Main gallery wrapper */ }
-.gallery-grid { /* Photo grid container */ }
-.gallery-item { /* Individual photo card */ }
-.gallery-header { /* Header section */ }
-.gallery-hero { /* Hero image area */ }
-.photo-overlay { /* Photo hover overlay */ }
-.photo-actions { /* Like/favorite buttons */ }`}
-
- - {t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')} -
-
-{`.glass-panel {
- background: rgba(255, 255, 255, 0.25);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.18);
- border-radius: 16px;
-}`}
-
- + {t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')} +
+ >)} + + {/* + * Force color mode (instance-wide). Lives next to the per-theme + * Color Mode picker so the admin can find both controls in one + * place. The data flows through props from BrandingPage which + * persists it to branding settings; only renders when the + * onForceColorModeChange handler is provided (i.e. only on the + * Branding admin page, not in event-level theme editors). + */} + {onForceColorModeChange && ( ++ {t( + 'branding.forceColorModeHelp', + 'Lock the entire admin and public site to dark or light. The user-facing dark/light toggle is hidden whenever a lock is active. Per-event themes that try to override the colour mode are also forced to follow.' + )} +
++ {t('branding.controlsStyleDescription', 'Choose how gallery filters and controls are displayed.')} +
+
+
+ {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.')} +
+
+ {t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
+ + {t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')} +
+
+{`--color-background: ${localTheme.backgroundColor || '#fafafa'};
+--color-surface: ${localTheme.surfaceColor || '#ffffff'};
+--color-elevated: ${localTheme.elevatedColor || '#f5f5f5'};
+--color-surface-border: ${localTheme.surfaceBorderColor || '#e5e5e5'};
+--color-text: ${localTheme.textColor || '#171717'};
+--color-muted-text: ${localTheme.mutedTextColor || '#737373'};
+--color-accent: ${localTheme.accentColor || '#22c55e'};
+--color-accent-dark: ${localTheme.accentDarkColor || localTheme.primaryColor || '#5C8762'};
+--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
+--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
+
+ + {t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')} +
+
+{`.gallery-container { /* Main gallery wrapper */ }
+.gallery-grid { /* Photo grid container */ }
+.gallery-item { /* Individual photo card */ }
+.gallery-header { /* Header section */ }
+.gallery-hero { /* Hero image area */ }
+.photo-overlay { /* Photo hover overlay */ }
+.photo-actions { /* Like/favorite buttons */ }`}
+
+ + {t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')} +
+
+{`.glass-panel {
+ background: rgba(255, 255, 255, 0.25);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 16px;
+}`}
+
+ + {t('branding.betaThumbnailWarningTitle')} +
++ {t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: minRecommendedThumbnailSize })} +
+ { + e.preventDefault(); + window.location.href = '/admin/settings'; + }} + > + {t('branding.betaThumbnailWarningLink')} → + ++ {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} +
++ {localTheme.gallerySettings?.masonryMode === 'columns' + ? t('branding.masonryModeHint.columns', 'Pinterest-style vertical columns with varied heights') + : localTheme.gallerySettings?.masonryMode === 'flickr' + ? t('branding.masonryModeHint.flickr', 'Flickr\'s open-source justified layout algorithm') + : localTheme.gallerySettings?.masonryMode === 'justified' + ? t('branding.masonryModeHint.justified', 'Google Photos-style rows using Knuth-Plass algorithm for optimal breaks') + : t('branding.masonryModeHint.rows', 'Custom row-based justified layout')} +
++ {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} +
++ {t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')} +
++ {t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')} +
++ {t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')} +
++ {t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')} +
++ {t('branding.betaThumbnailWarningTitle')} +
++ {t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: minRecommendedThumbnailSize })} +
+ { + e.preventDefault(); + window.location.href = '/admin/settings'; + }} + > + {t('branding.betaThumbnailWarningLink')} → + +- {t('events.draft')} -
-- {t('events.draftBanner')} -
-- {isExpired - ? t('events.eventExpiredMessage') - : t('events.eventExpiresIn', { days: daysUntilExpiration }) - } -
-- {isExpired - ? t('events.guestsCannotAccessGallery') - : t('events.warningEmailsHaveBeenSent')} -
-- {t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')} -
-- {t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')} -
-- {t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')} -
-- {t('events.allowUserUploadsHelp')} -
-- {t('events.uploadCategoryHelp')} -
-- {t('events.promoBanner.help', 'Choose how this gallery handles the promotional banner. "Inherit" uses your global default; "Custom" overrides it for this event; "Off" hides it entirely.')} -
-- {t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')} -
-- {t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')} -
- - {event.hero_logo_url ? ( -- {t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')} -
-- {t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'} -
- )} -- {isGalleryPublic(event.require_password) - ? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.') - : t('events.shareWithGuests')} -
- - {!event.is_archived && ( -- {t('events.draftBanner')} -
- > - ) : ( - <> - } - onClick={() => { - if (confirm(t('events.archiveConfirm'))) { - archiveMutation.mutate(); - } - }} - isLoading={archiveMutation.isPending} - className="w-full justify-center" - > - {t('events.archiveEvent')} - -- {t('events.archivingInfo')} -
- > - )} - {/* Duplicate (#626) — visible in both draft and live mode. - Creates a new draft inheriting this gallery's config. */} - } - onClick={() => setShowDuplicateDialog(true)} - isLoading={duplicateMutation.isPending} - className="w-full justify-center" - > - {t('events.duplicateEvent', 'Duplicate gallery')} - -{t('events.archivedOn')}
-- {event.archived_at && fmtDateTime(safeParseDate(event.archived_at)!)} -
-- {t('events.organizeCategoriesInfo')} -
-- {t('events.categoriesTip')} -
-{t('events.archivedOn')}
++ {event.archived_at && fmtDateTime(safeParseDate(event.archived_at)!)} +
++ {t('events.organizeCategoriesInfo')} +
++ {t('events.categoriesTip')} +
++ {t('events.draftBanner')} +
+ > + ) : ( + <> + } + onClick={() => { + if (confirm(t('events.archiveConfirm'))) { + onArchive(); + } + }} + isLoading={isArchiving} + className="w-full justify-center" + > + {t('events.archiveEvent')} + ++ {t('events.archivingInfo')} +
+ > + )} + {/* Duplicate (#626) — visible in both draft and live mode. + Creates a new draft inheriting this gallery's config. */} + } + onClick={() => setShowDuplicateDialog(true)} + isLoading={isDuplicating} + className="w-full justify-center" + > + {t('events.duplicateEvent', 'Duplicate gallery')} + ++ {t('events.draft')} +
++ {t('events.draftBanner')} +
++ {isExpired + ? t('events.eventExpiredMessage') + : t('events.eventExpiresIn', { days: daysUntilExpiration }) + } +
++ {isExpired + ? t('events.guestsCannotAccessGallery') + : t('events.warningEmailsHaveBeenSent')} +
++ {t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')} +
++ {t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')} +
++ {t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')} +
++ {t('events.allowUserUploadsHelp')} +
++ {t('events.uploadCategoryHelp')} +
++ {t('events.promoBanner.help', 'Choose how this gallery handles the promotional banner. "Inherit" uses your global default; "Custom" overrides it for this event; "Off" hides it entirely.')} +
++ {t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')} +
++ {t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')} +
+ + {event.hero_logo_url ? ( ++ {t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')} +
++ {t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'} +
+ )} ++ {isGalleryPublic(event.require_password) + ? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.') + : t('events.shareWithGuests')} +
+ + {!event.is_archived && ( +