Files
picpeak/frontend/src/pages/admin/BrandingPage.tsx
T
paul 909e760447 feat: implement gallery logo customization (Issue #17)
Added comprehensive logo customization features for gallery views:
- Logo size options (small, medium, large, xlarge, custom)
- Logo position control (left, center, right)
- Display mode settings (logo only, text only, logo and text)
- Visibility controls for header and hero sections
- Custom height configuration for fine-tuning

Changes:
- Added database migration for 6 new logo customization settings
- Extended backend APIs to handle logo customization fields
- Updated GalleryLayout.tsx with dynamic logo rendering logic
- Added logo upload functionality to BrandingPage.tsx
- Extended settings service with logo customization types

This addresses the issue where the gallery logo was "very large and centered"
by providing full control over logo appearance and positioning.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00

711 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { Save, Eye, Palette, Upload } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { buildResourceUrl } from '../../utils/url';
export const BrandingPage: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
company_name: '',
company_tagline: '',
footer_text: '© 2024 Your Company. All rights reserved.',
support_email: '',
watermark_enabled: false,
watermark_position: 'bottom-right',
watermark_opacity: 50,
watermark_size: 15,
watermark_logo_url: '',
favicon_url: '',
});
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
const [currentThemeName, setCurrentThemeName] = useState('default');
const [isPreviewMode, setIsPreviewMode] = useState(false);
const faviconInputRef = useRef<HTMLInputElement>(null);
// Fetch current settings
const { data: settings, isLoading } = useQuery({
queryKey: ['admin-settings', 'branding'],
queryFn: () => settingsService.getSettingsByType('branding'),
});
// Fetch theme settings
const { data: themeSettings } = useQuery({
queryKey: ['admin-settings', 'theme'],
queryFn: () => settingsService.getSettingsByType('theme'),
});
// Update branding mutation
const queryClient = useQueryClient();
const brandingMutation = useMutation({
mutationFn: settingsService.updateBranding,
onSuccess: () => {
toast.success(t('toast.brandingUpdated'));
// Invalidate all settings queries to refresh data
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Update theme mutation
const themeMutation = useMutation({
mutationFn: settingsService.updateTheme,
onSuccess: () => {
toast.success(t('toast.themeUpdated'));
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Initialize settings from database
useEffect(() => {
if (settings) {
const formatted = settingsService.formatBrandingSettings(settings);
// Don't set logo_url here - it will be synced from theme
const { logo_url, ...brandingWithoutLogo } = formatted;
setBrandingSettings(prev => ({ ...prev, ...brandingWithoutLogo }));
}
}, [settings]);
// Initialize theme from database
useEffect(() => {
if (themeSettings) {
const formatted = settingsService.formatThemeSettings(themeSettings) as ThemeConfig;
if (formatted && Object.keys(formatted).length > 0) {
// Use the theme's logo URL as stored in the theme config
setCurrentTheme(formatted);
setTheme(formatted);
// Always sync the logo URL from theme to branding settings - theme is source of truth
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl || '' }));
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) {
if (JSON.stringify(preset.config) === JSON.stringify(formatted)) {
setCurrentThemeName(key);
break;
}
}
}
}
}, [themeSettings, setTheme]);
const handleBrandingChange = (key: string, value: any) => {
setBrandingSettings(prev => ({ ...prev, [key]: value }));
};
const handleThemeChange = (newTheme: ThemeConfig) => {
setCurrentTheme(newTheme);
// Also update logo URL in branding settings if it changed
if (newTheme.logoUrl !== currentTheme.logoUrl) {
setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' }));
}
if (isPreviewMode) {
setTheme(newTheme);
}
};
const handlePresetChange = (presetName: string) => {
setCurrentThemeName(presetName);
// Get the preset theme config
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
if (isPreviewMode) {
setTheme(preset.config);
}
}
};
const handleFaviconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const faviconUrl = await settingsService.uploadFavicon(file);
setBrandingSettings(prev => ({ ...prev, favicon_url: faviconUrl }));
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload favicon:', error);
toast.error(t('toast.uploadError'));
}
}
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const logoUrl = await settingsService.uploadLogo(file);
setBrandingSettings(prev => ({ ...prev, logo_url: logoUrl }));
setCurrentTheme(prev => ({ ...prev, logoUrl }));
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload logo:', error);
toast.error(t('toast.uploadError'));
}
}
};
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const watermarkLogoUrl = await settingsService.uploadWatermarkLogo(file);
setBrandingSettings(prev => ({ ...prev, watermark_logo_url: watermarkLogoUrl }));
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload watermark logo:', error);
toast.error(t('toast.uploadError'));
}
}
};
const handleSave = async () => {
try {
// Sync logo URL from theme to branding settings
const updatedBrandingSettings = {
...brandingSettings,
logo_url: currentTheme.logoUrl || ''
};
// Save branding settings to database
await brandingMutation.mutateAsync(updatedBrandingSettings);
// Save theme settings to database
await themeMutation.mutateAsync(currentTheme);
// Apply theme globally
setTheme(currentTheme);
// Update local state to reflect saved values
setBrandingSettings(updatedBrandingSettings);
} catch (error) {
console.error('Failed to save settings:', error);
}
};
const handlePreview = () => {
const previewWindow = window.open('/gallery/preview', '_blank');
if (previewWindow) {
// Send theme data to preview window
setTimeout(() => {
previewWindow.postMessage({
type: 'THEME_PREVIEW',
theme: currentTheme,
branding: brandingSettings
}, window.location.origin);
}, 1000);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('branding.loadingBranding')} />
</div>
);
}
return (
<ErrorBoundary>
<div>
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('branding.title')}</h1>
<p className="text-neutral-600 mt-1">{t('branding.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
leftIcon={<Eye className="w-4 h-4" />}
onClick={handlePreview}
>
{t('branding.preview')}
</Button>
<Button
variant="primary"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSave}
>
{t('branding.saveChanges')}
</Button>
</div>
</div>
{/* Company Branding */}
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.companyInfo')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input
label={t('branding.companyName')}
value={brandingSettings.company_name}
onChange={(e) => handleBrandingChange('company_name', e.target.value)}
placeholder={t('branding.companyName')}
helperText={t('branding.companyNameHelp')}
/>
<Input
label={t('branding.companyTagline')}
value={brandingSettings.company_tagline}
onChange={(e) => handleBrandingChange('company_tagline', e.target.value)}
placeholder={t('branding.companyTagline')}
helperText={t('branding.companyTaglineHelp')}
/>
<Input
label={t('branding.supportEmail')}
type="email"
value={brandingSettings.support_email}
onChange={(e) => handleBrandingChange('support_email', e.target.value)}
placeholder="support@yourcompany.com"
helperText={t('branding.supportEmailHelp')}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.footerText')}
</label>
<textarea
value={brandingSettings.footer_text}
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
rows={2}
placeholder="© 2024 Your Company. All rights reserved."
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.favicon')}
</label>
<div className="space-y-2">
{brandingSettings.favicon_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : buildResourceUrl(brandingSettings.favicon_url)}
alt="Current favicon"
className="w-8 h-8"
/>
<span className="text-sm text-neutral-600">{t('branding.currentFavicon')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('favicon_url', '')}
>
{t('branding.removeFavicon')}
</Button>
</div>
)}
<div>
<input
ref={faviconInputRef}
type="file"
accept="image/png,image/x-icon"
onChange={handleFaviconUpload}
className="hidden"
/>
<Button
variant="secondary"
size="sm"
onClick={() => faviconInputRef.current?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{t('branding.uploadFavicon')}
</Button>
<p className="text-xs text-neutral-600 mt-1">{t('branding.faviconHelp')}</p>
</div>
</div>
</div>
</div>
{/* Logo Customization Settings */}
<div className="mt-6 pt-6 border-t border-neutral-200">
<h3 className="text-md font-semibold text-neutral-900 mb-4">{t('branding.logoCustomization', 'Logo Customization')}</h3>
<div className="space-y-4">
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logo', 'Logo')}
</label>
<div className="flex items-center gap-4">
{brandingSettings.logo_url && (
<div className="relative">
<img
src={brandingSettings.logo_url.startsWith('http') ? brandingSettings.logo_url : buildResourceUrl(brandingSettings.logo_url)}
alt="Logo"
className="h-16 object-contain bg-neutral-100 rounded p-2"
/>
<button
type="button"
onClick={() => handleBrandingChange('logo_url', '')}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
>
×
</button>
</div>
)}
<label className="cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleLogoUpload}
className="hidden"
/>
<span className="btn-secondary inline-flex items-center">
<Upload className="w-4 h-4 mr-2" />
{brandingSettings.logo_url ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
</span>
</label>
</div>
<p className="text-xs text-neutral-600 mt-1">
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
</p>
</div>
{/* Logo Size */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoSize', 'Logo Size')}
</label>
<select
value={brandingSettings.logo_size || 'medium'}
onChange={(e) => handleBrandingChange('logo_size', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="small">{t('branding.logoSizeSmall', 'Small (32px)')}</option>
<option value="medium">{t('branding.logoSizeMedium', 'Medium (48px)')}</option>
<option value="large">{t('branding.logoSizeLarge', 'Large (64px)')}</option>
<option value="xlarge">{t('branding.logoSizeXLarge', 'Extra Large (96px)')}</option>
<option value="custom">{t('branding.logoSizeCustom', 'Custom')}</option>
</select>
</div>
{/* Custom Height (only shown when size is custom) */}
{brandingSettings.logo_size === 'custom' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoMaxHeight', 'Maximum Height (pixels)')}
</label>
<input
type="number"
min="20"
max="200"
value={brandingSettings.logo_max_height || 48}
onChange={(e) => handleBrandingChange('logo_max_height', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-neutral-600 mt-1">
{t('branding.logoMaxHeightHelp', 'Set a custom maximum height for the logo (20-200 pixels)')}
</p>
</div>
)}
{/* Logo Position */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoPosition', 'Logo Position in Header')}
</label>
<div className="flex gap-2">
{(['left', 'center', 'right'] as const).map((position) => (
<button
key={position}
type="button"
onClick={() => handleBrandingChange('logo_position', position)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
brandingSettings.logo_position === position
? 'bg-primary-600 text-white'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
{t(`branding.position${position.charAt(0).toUpperCase() + position.slice(1)}`, position.charAt(0).toUpperCase() + position.slice(1))}
</button>
))}
</div>
</div>
{/* Display Mode */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoDisplayMode', 'Display Mode')}
</label>
<select
value={brandingSettings.logo_display_mode || 'logo_and_text'}
onChange={(e) => handleBrandingChange('logo_display_mode', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="logo_only">{t('branding.logoOnly', 'Logo Only')}</option>
<option value="text_only">{t('branding.textOnly', 'Company Name Only')}</option>
<option value="logo_and_text">{t('branding.logoAndText', 'Logo and Company Name')}</option>
</select>
</div>
{/* Display Options */}
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.logo_display_header !== false}
onChange={(e) => handleBrandingChange('logo_display_header', e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900">
{t('branding.showLogoInHeader', 'Show logo in gallery header')}
</span>
<p className="text-xs text-neutral-600">
{t('branding.showLogoInHeaderHelp', 'Display the logo in the main header bar')}
</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.logo_display_hero !== false}
onChange={(e) => handleBrandingChange('logo_display_hero', e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900">
{t('branding.showLogoInHero', 'Show logo in hero section')}
</span>
<p className="text-xs text-neutral-600">
{t('branding.showLogoInHeroHelp', 'Display the logo in hero sections (for non-grid layouts)')}
</p>
</div>
</label>
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.watermark_enabled}
onChange={(e) => handleBrandingChange('watermark_enabled', e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900">{t('branding.enableWatermarks')}</span>
<p className="text-xs text-neutral-600">{t('branding.watermarkHelp')}</p>
</div>
</label>
</div>
{/* Watermark Settings */}
{brandingSettings.watermark_enabled && (
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
<h3 className="text-md font-semibold text-neutral-900">{t('branding.watermarkSettings')}</h3>
{/* Watermark Logo Upload */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkLogo')}
</label>
<div className="space-y-2">
{brandingSettings.watermark_logo_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : buildResourceUrl(brandingSettings.watermark_logo_url)}
alt="Current watermark"
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded"
/>
<span className="text-sm text-neutral-600">{t('branding.currentWatermark')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('watermark_logo_url', '')}
>
{t('common.delete')}
</Button>
</div>
)}
<div>
<input
type="file"
accept="image/png"
onChange={handleWatermarkLogoUpload}
className="hidden"
id="watermark-upload"
/>
<label htmlFor="watermark-upload">
<Button
variant="secondary"
size="sm"
onClick={() => document.getElementById('watermark-upload')?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{t('branding.uploadWatermarkLogo')}
</Button>
</label>
<p className="text-xs text-neutral-600 mt-1">{t('branding.watermarkHelp')}</p>
</div>
</div>
</div>
{/* Position Selector */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkPosition')}
</label>
<div className="grid grid-cols-3 gap-2 max-w-xs">
{[
{ value: 'top-left', label: t('branding.topLeft') },
{ value: 'top-right', label: t('branding.topRight') },
{ value: 'center', label: t('branding.center') },
{ value: 'bottom-left', label: t('branding.bottomLeft') },
{ value: 'bottom-right', label: t('branding.bottomRight') }
].map((position) => (
<button
key={position.value}
type="button"
onClick={() => handleBrandingChange('watermark_position', position.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
brandingSettings.watermark_position === position.value
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
}`}
>
{position.label}
</button>
))}
</div>
</div>
{/* Opacity Slider */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkOpacity')}: {brandingSettings.watermark_opacity || 50}%
</label>
<input
type="range"
min="10"
max="100"
step="10"
value={brandingSettings.watermark_opacity || 50}
onChange={(e) => handleBrandingChange('watermark_opacity', parseInt(e.target.value))}
className="w-full slider"
style={{
WebkitAppearance: 'none',
appearance: 'none',
height: '8px',
background: '#d4d4d4',
borderRadius: '4px',
outline: 'none'
}}
/>
<div className="flex justify-between text-xs text-neutral-500 mt-1">
<span>10%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
{/* Size Slider */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkSize')}: {brandingSettings.watermark_size || 15}%
</label>
<input
type="range"
min="5"
max="30"
step="5"
value={brandingSettings.watermark_size || 15}
onChange={(e) => handleBrandingChange('watermark_size', parseInt(e.target.value))}
className="w-full slider"
style={{
WebkitAppearance: 'none',
appearance: 'none',
height: '8px',
background: '#d4d4d4',
borderRadius: '4px',
outline: 'none'
}}
/>
<div className="flex justify-between text-xs text-neutral-500 mt-1">
<span>5%</span>
<span>15%</span>
<span>30%</span>
</div>
</div>
</div>
)}
</Card>
{/* Theme Customization */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('branding.galleryTheme')}
</h2>
<div className="mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPreviewMode}
onChange={(e) => setIsPreviewMode(e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-neutral-700">{t('branding.applyLivePreview')}</span>
</label>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left side - Theme Customizer */}
<div>
<ThemeCustomizerEnhanced
value={currentTheme}
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
showGalleryLayouts={true}
hideActions={true}
/>
</div>
{/* Right side - Gallery Preview */}
<div className="lg:sticky lg:top-4 lg:h-fit">
<Card className="p-4">
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={currentTheme}
className="shadow-lg"
/>
</Card>
</div>
</div>
</div>
{/* Event-Specific Themes Info */}
<Card padding="md" className="bg-blue-50 border-blue-200">
<div className="flex items-start gap-3">
<Palette className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-medium text-blue-900">{t('branding.eventSpecificThemes')}</h3>
<p className="text-sm text-blue-700 mt-1">
{t('branding.eventThemesInfo')}
</p>
</div>
</div>
</Card>
</div>
</ErrorBoundary>
);
};