a3638fe954
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 3s
- Add URL utility functions for building resource URLs - Update all components to use relative URLs in production - Add production deployment documentation - Update nginx config to proxy all required endpoints - Add .env.production.example with proper configuration
536 lines
20 KiB
TypeScript
536 lines
20 KiB
TypeScript
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 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>
|
|
|
|
<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>
|
|
);
|
|
}; |