Fix language setting not being saved to database on admin settings page

- Added default_language field to general settings state in SettingsPage
- Replaced LanguageSelector component with simple select dropdown on settings page
- Fixed public settings endpoint to read general_default_language from database
- Language setting now properly saved when clicking Save Settings button
- Setting is correctly used by gallery login page and legal pages

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

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
2025-07-08 09:49:45 +02:00
co-authored by Claude
parent cfa0b0da69
commit 2012b0bab9
91 changed files with 6183 additions and 577 deletions
+153 -43
View File
@@ -1,23 +1,44 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
import { format, differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Set language from admin settings when on login page
React.useEffect(() => {
if (!isAuthenticated && settingsData?.default_language) {
i18n.changeLanguage(settingsData.default_language);
}
}, [settingsData, isAuthenticated, i18n]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
@@ -27,7 +48,7 @@ export const GalleryPage: React.FC = () => {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setLoginError('Please enter a password');
setLoginError(t('auth.pleaseEnterPassword'));
return;
}
@@ -42,7 +63,7 @@ export const GalleryPage: React.FC = () => {
success: true
});
} catch (error: any) {
setLoginError(error.response?.data?.error || 'Invalid password');
setLoginError(error.response?.data?.error || t('auth.invalidPassword'));
// Track failed password entry
analyticsService.trackGalleryEvent('password_entry', {
@@ -57,8 +78,10 @@ export const GalleryPage: React.FC = () => {
// Show loading state
if (isLoadingInfo) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Loading size="lg" text="Loading gallery..." />
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
</div>
);
}
@@ -66,16 +89,50 @@ export const GalleryPage: React.FC = () => {
// Show error state
if (infoError) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Gallery Not Found</h2>
<p className="text-neutral-600">
This gallery does not exist or has been removed.
</p>
</CardContent>
</Card>
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
<p className="text-neutral-600">
{t('errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -83,19 +140,53 @@ export const GalleryPage: React.FC = () => {
// Show expired state
if (galleryInfo?.is_expired) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Gallery Expired</h2>
<p className="text-neutral-600 mb-4">
This gallery expired on {format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy')}.
</p>
<p className="text-sm text-neutral-500">
Please contact the event organizer if you need access to these photos.
</p>
</CardContent>
</Card>
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
<p className="text-neutral-600 mb-4">
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy') })}
</p>
<p className="text-sm text-neutral-500">
{t('gallery.contactOrganizer')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -112,9 +203,17 @@ export const GalleryPage: React.FC = () => {
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
<Camera className="w-10 h-10 text-white" />
</div>
{settingsData?.branding_logo_url ? (
<img
src={settingsData.branding_logo_url}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-20 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
<Camera className="w-10 h-10 text-white" />
</div>
)}
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
{galleryInfo?.event_name}
</h1>
@@ -131,10 +230,10 @@ export const GalleryPage: React.FC = () => {
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-amber-800">
Gallery expires in {daysUntilExpiration} {daysUntilExpiration === 1 ? 'day' : 'days'}
{t('gallery.expiresIn', { count: daysUntilExpiration })}
</p>
<p className="text-xs text-amber-700 mt-1">
Download your photos before they're no longer available.
{t('gallery.downloadBefore')}
</p>
</div>
</div>
@@ -144,13 +243,13 @@ export const GalleryPage: React.FC = () => {
{/* Login Card */}
<Card>
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-6">Enter Gallery Password</h2>
<h2 className="text-xl font-semibold mb-6">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label="Password"
placeholder="Enter the gallery password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
@@ -165,22 +264,33 @@ export const GalleryPage: React.FC = () => {
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
View Gallery
{t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-6">
The password was provided by the event organizer.
Contact them if you don't have it.
{t('auth.passwordHint')}
</p>
</CardContent>
</Card>
{/* Event Type Badge */}
{/* Legal Links */}
<div className="text-center mt-6">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
{galleryInfo?.event_type}
</span>
<div className="flex items-center justify-center gap-4">
<a
href="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</a>
<span className="text-xs text-neutral-400">|</span>
<a
href="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</a>
</div>
</div>
</div>
</div>
+28 -26
View File
@@ -14,6 +14,7 @@ import {
Image
} from 'lucide-react';
import { format, differenceInDays, parseISO, formatDistanceToNow } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { Button, Card, Loading } from '../../components/common';
import { useQuery } from '@tanstack/react-query';
@@ -29,6 +30,7 @@ interface StatCard {
}
export const AdminDashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
// Fetch dashboard statistics
@@ -54,7 +56,7 @@ export const AdminDashboard: React.FC = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text="Loading dashboard..." />
<Loading size="lg" text={t('admin.loadingDashboard')} />
</div>
);
}
@@ -76,26 +78,26 @@ export const AdminDashboard: React.FC = () => {
// Build statistics cards
const stats: StatCard[] = [
{
title: 'Active Events',
title: t('admin.activeEvents'),
value: dashboardStats?.activeEvents || 0,
icon: Calendar,
color: 'text-green-600',
},
{
title: 'Expiring Soon',
title: t('admin.expiringSoon'),
value: dashboardStats?.expiringEvents || 0,
change: 'Next 7 days',
change: t('admin.next7Days'),
icon: AlertTriangle,
color: 'text-orange-600',
},
{
title: 'Total Photos',
title: t('admin.totalPhotos'),
value: formatNumber(dashboardStats?.totalPhotos || 0),
icon: Image,
color: 'text-blue-600',
},
{
title: 'Storage Used',
title: t('admin.storageUsed'),
value: adminService.formatBytes(dashboardStats?.storageUsed || 0),
icon: HardDrive,
color: 'text-purple-600',
@@ -106,16 +108,16 @@ export const AdminDashboard: React.FC = () => {
if (dashboardStats?.totalViews !== undefined) {
stats.push(
{
title: 'Total Views',
title: t('admin.totalViews'),
value: formatNumber(dashboardStats.totalViews),
change: dashboardStats.viewsTrend > 0 ? `+${dashboardStats.viewsTrend}% from last week` : undefined,
change: dashboardStats.viewsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.viewsTrend}` }) : undefined,
icon: Eye,
color: 'text-indigo-600',
},
{
title: 'Downloads',
title: t('admin.downloads'),
value: formatNumber(dashboardStats.totalDownloads),
change: dashboardStats.downloadsTrend > 0 ? `+${dashboardStats.downloadsTrend}% from last week` : undefined,
change: dashboardStats.downloadsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.downloadsTrend}` }) : undefined,
icon: Download,
color: 'text-pink-600',
}
@@ -127,15 +129,15 @@ export const AdminDashboard: React.FC = () => {
{/* Page Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-neutral-900">Dashboard</h1>
<p className="text-neutral-600 mt-1">Welcome back! Here's what's happening with your galleries.</p>
<h1 className="text-2xl font-bold text-neutral-900">{t('navigation.dashboard')}</h1>
<p className="text-neutral-600 mt-1">{t('admin.dashboardSubtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => navigate('/admin/events/new')}
>
Create Event
{t('events.createEvent')}
</Button>
</div>
@@ -165,12 +167,12 @@ export const AdminDashboard: React.FC = () => {
<div className="lg:col-span-2">
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Events Expiring Soon</h2>
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.eventsExpiringSoon')}</h2>
<AlertTriangle className="w-5 h-5 text-orange-600" />
</div>
{expiringEvents.length === 0 ? (
<p className="text-neutral-600 py-8 text-center">No events expiring in the next 7 days</p>
<p className="text-neutral-600 py-8 text-center">{t('admin.noEventsExpiring')}</p>
) : (
<div className="space-y-3">
{expiringEvents.slice(0, 5).map((event) => {
@@ -190,10 +192,10 @@ export const AdminDashboard: React.FC = () => {
</div>
<div className="text-right">
<p className="text-sm font-medium text-orange-600">
{daysLeft} {daysLeft === 1 ? 'day' : 'days'} left
{t('admin.daysLeft', { count: daysLeft })}
</p>
<p className="text-xs text-neutral-500">
Expires {format(parseISO(event.expires_at), 'MMM d')}
{t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')}
</p>
</div>
</div>
@@ -207,7 +209,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/events?filter=expiring')}
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
View all {expiringEvents.length} expiring events
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })}
</button>
)}
</Card>
@@ -216,13 +218,13 @@ export const AdminDashboard: React.FC = () => {
{/* Recent Activity */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Recent Activity</h2>
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.recentActivity')}</h2>
<Clock className="w-5 h-5 text-neutral-500" />
</div>
<div className="space-y-4">
{!recentActivity || recentActivity.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-4">No recent activity</p>
<p className="text-sm text-neutral-500 text-center py-4">{t('admin.noRecentActivity')}</p>
) : (
recentActivity.slice(0, 5).map((activity) => {
// Get color based on activity type
@@ -265,7 +267,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/activity')}
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
View all activity
{t('admin.viewAllActivity')}
</button>
)}
</Card>
@@ -273,7 +275,7 @@ export const AdminDashboard: React.FC = () => {
{/* Quick Actions */}
<Card padding="md" className="mt-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Quick Actions</h2>
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('admin.quickActions')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Button
variant="outline"
@@ -281,7 +283,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/events/new')}
className="justify-center"
>
Create Event
{t('events.createEvent')}
</Button>
<Button
variant="outline"
@@ -289,7 +291,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/archives')}
className="justify-center"
>
View Archives
{t('admin.viewArchives')}
</Button>
<Button
variant="outline"
@@ -297,7 +299,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/analytics')}
className="justify-center"
>
Analytics
{t('admin.analytics')}
</Button>
<Button
variant="outline"
@@ -305,7 +307,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/settings')}
className="justify-center"
>
Settings
{t('navigation.settings')}
</Button>
</div>
</Card>
+225 -10
View File
@@ -1,25 +1,31 @@
import React, { useState, useEffect } from 'react';
import { Save, Eye, Palette } from 'lucide-react';
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 { ThemeCustomizer } from '../../components/admin/ThemeCustomizer';
import { useTheme, type ThemeConfig, PRESET_THEMES } from '../../contexts/ThemeContext';
import { useQuery, useMutation } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
export const BrandingPage: React.FC = () => {
const { theme, setTheme } = useTheme();
const [brandingSettings, setBrandingSettings] = useState({
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({
@@ -68,8 +74,12 @@ export const BrandingPage: React.FC = () => {
if (themeSettings) {
const formatted = settingsService.formatThemeSettings(themeSettings);
if (formatted && Object.keys(formatted).length > 0) {
setCurrentTheme(formatted);
setTheme(formatted);
// Merge logo URL from branding settings if available
const logoUrl = settings?.branding_logo_url || brandingSettings.logo_url;
const themeWithLogo = logoUrl ? { ...formatted, logoUrl } : formatted;
setCurrentTheme(themeWithLogo);
setTheme(themeWithLogo);
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(PRESET_THEMES)) {
@@ -80,7 +90,7 @@ export const BrandingPage: React.FC = () => {
}
}
}
}, [themeSettings, setTheme]);
}, [themeSettings, settings, brandingSettings.logo_url, setTheme]);
const handleBrandingChange = (key: string, value: any) => {
setBrandingSettings(prev => ({ ...prev, [key]: value }));
@@ -88,6 +98,10 @@ export const BrandingPage: React.FC = () => {
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);
}
@@ -105,16 +119,47 @@ export const BrandingPage: React.FC = () => {
}
};
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('Favicon uploaded successfully');
} catch (error) {
console.error('Failed to upload favicon:', error);
toast.error('Failed to upload favicon. Please use PNG or ICO format.');
}
}
};
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('Watermark logo uploaded successfully');
} catch (error) {
console.error('Failed to upload watermark logo:', error);
toast.error('Failed to upload watermark logo. Please use PNG format with transparency.');
}
}
};
const handleSave = async () => {
try {
// Save branding settings to database
await brandingMutation.mutateAsync(brandingSettings);
// Save theme settings to database
await themeMutation.mutateAsync(currentTheme);
// Save theme settings to database (including logo URL if present)
const themeToSave = brandingSettings.logo_url
? { ...currentTheme, logoUrl: brandingSettings.logo_url }
: currentTheme;
await themeMutation.mutateAsync(themeToSave);
// Apply theme globally
setTheme(currentTheme);
setTheme(themeToSave);
} catch (error) {
console.error('Failed to save settings:', error);
}
@@ -223,6 +268,175 @@ export const BrandingPage: React.FC = () => {
</div>
</label>
</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">
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 : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.favicon_url}`}
alt="Current favicon"
className="w-8 h-8"
/>
<span className="text-sm text-neutral-600">Current favicon</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('favicon_url', '')}
>
Remove
</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" />}
>
Upload Favicon
</Button>
<p className="text-xs text-neutral-600 mt-1">PNG or ICO format, recommended size: 32x32px</p>
</div>
</div>
</div>
</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">Watermark Settings</h3>
{/* Watermark Logo Upload */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Watermark Logo
</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 : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${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">Current watermark</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('watermark_logo_url', '')}
>
Remove
</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" />}
>
Upload Watermark Logo
</Button>
</label>
<p className="text-xs text-neutral-600 mt-1">PNG format with transparency recommended</p>
</div>
</div>
</div>
{/* Position Selector */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Watermark Position
</label>
<div className="grid grid-cols-3 gap-2 max-w-xs">
{[
{ value: 'top-left', label: 'Top Left' },
{ value: 'top-right', label: 'Top Right' },
{ value: 'center', label: 'Center' },
{ value: 'bottom-left', label: 'Bottom Left' },
{ value: 'bottom-right', label: 'Bottom Right' }
].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">
Watermark Opacity: {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 h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
/>
<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">
Watermark Size: {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 h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
/>
<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 */}
@@ -247,6 +461,7 @@ export const BrandingPage: React.FC = () => {
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
/>
</div>
+212
View File
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, FileText, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
export const CMSPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
queryKey: ['cms-pages'],
queryFn: cmsService.getPages,
});
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
cmsService.updatePage(slug, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
toast.success('Page updated successfully');
},
onError: () => {
toast.error('Failed to update page');
},
});
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text="Loading pages..." />
</div>
);
}
const currentPage = pages?.find(p => p.slug === selectedPage);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">CMS Pages</h1>
<p className="text-neutral-600 mt-1">Manage legal and informational pages</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Pages</h2>
<div className="space-y-2">
{pages?.map((page) => (
<button
key={page.slug}
onClick={() => setSelectedPage(page.slug)}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
selectedPage === page.slug
? 'bg-primary-100 text-primary-700 border border-primary-300'
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
<FileText className="w-5 h-5" />
<div>
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
<p className="text-sm text-neutral-500">/{page.slug}</p>
</div>
</button>
))}
</div>
</Card>
<Card padding="md" className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Preview Links</h3>
<div className="space-y-2 text-sm">
<a
href={`${window.location.origin}/${selectedPage}?lang=en`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
English Version
</a>
<a
href={`${window.location.origin}/${selectedPage}?lang=de`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
German Version
</a>
</div>
</Card>
</div>
{/* Editor */}
<div className="lg:col-span-3">
<Card padding="md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900">
Edit {t(`legal.${selectedPage}`)}
</h2>
{/* Language Tabs */}
<div className="flex gap-2">
<button
onClick={() => setEditingLang('en')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇬🇧 English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇩🇪 Deutsch
</button>
</div>
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Page Title ({editingLang === 'en' ? 'English' : 'German'})
</label>
<Input
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Enter page title..."
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Page Content ({editingLang === 'en' ? 'English' : 'German'})
</label>
<CMSEditor
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
onChange={handleContentChange}
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="primary"
onClick={handleSave}
isLoading={updateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
</div>
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 mt-4">
Last updated: {new Date(currentPage.updated_at).toLocaleString()}
</p>
)}
</Card>
</div>
</div>
</div>
);
};
@@ -20,7 +20,7 @@ import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload } from '../../components/admin';
import { PhotoUpload, EventCategoryManager } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
@@ -408,10 +408,14 @@ export const EventDetailsPage: React.FC = () => {
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
</p>
<p className="text-xs text-blue-600 mt-1">
Photos can also be added by placing them in the 'individual' or 'collages' folders.
Photos are organized by categories you define.
</p>
</div>
</div>
<div className="mt-6 pt-4 border-t border-neutral-200">
<EventCategoryManager eventId={parseInt(id!)} />
</div>
</Card>
{/* Actions */}
+67 -5
View File
@@ -4,17 +4,21 @@ import {
Database,
Globe,
Key,
AlertCircle
AlertCircle,
Image
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CategoryManager } from '../../components/admin/CategoryManager';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general');
const queryClient = useQueryClient();
const { t } = useTranslation();
// Fetch settings
const { data: settings, isLoading } = useQuery({
@@ -38,7 +42,8 @@ export const SettingsPage: React.FC = () => {
enable_watermark: false,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false
maintenance_mode: false,
default_language: 'en'
});
// Security settings state
@@ -64,7 +69,8 @@ export const SettingsPage: React.FC = () => {
enable_watermark: settings.general_enable_watermark || false,
enable_analytics: settings.general_enable_analytics || true,
enable_registration: settings.general_enable_registration || false,
maintenance_mode: settings.general_maintenance_mode || false
maintenance_mode: settings.general_maintenance_mode || false,
default_language: settings.general_default_language || 'en'
});
// Extract security settings
@@ -166,6 +172,16 @@ export const SettingsPage: React.FC = () => {
>
Security
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'categories'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
Categories
</button>
</nav>
</div>
@@ -280,6 +296,29 @@ export const SettingsPage: React.FC = () => {
<span className="ml-2 text-sm text-neutral-700">Enable maintenance mode</span>
</label>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.language')}
</label>
<select
value={generalSettings.default_language}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
Sets the default language for all gallery pages and login screens
</p>
</div>
</div>
<div className="mt-6">
<Button
@@ -288,7 +327,7 @@ export const SettingsPage: React.FC = () => {
isLoading={saveGeneralMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
Save General Settings
{t('settings.general.saveSettings')}
</Button>
</div>
</Card>
@@ -510,6 +549,29 @@ export const SettingsPage: React.FC = () => {
</Card>
</div>
)}
{/* Categories Tab */}
{activeTab === 'categories' && (
<div className="space-y-6">
<Card padding="md">
<CategoryManager />
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-blue-900">About Photo Categories</h3>
<p className="text-sm text-blue-700 mt-1">
Global categories are available for all events. You can also create event-specific
categories when editing individual events. Categories help organize photos and
allow guests to filter photos by type in the gallery view.
</p>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
+2 -1
View File
@@ -7,4 +7,5 @@ export { EmailConfigPage } from './EmailConfigPage';
export { ArchivesPage } from './ArchivesPage';
export { AnalyticsPage } from './AnalyticsPage';
export { BrandingPage } from './BrandingPage';
export { SettingsPage } from './SettingsPage';
export { SettingsPage } from './SettingsPage';
export { CMSPage } from './CMSPage';
+143
View File
@@ -0,0 +1,143 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import { GalleryLayout, PhotoFilterBar } from '../../components/gallery';
import { Card } from '../../components/common';
import { Camera } from 'lucide-react';
// Mock photo data for preview
const generateMockPhotos = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
filename: `photo-${i + 1}.jpg`,
url: '',
thumbnail_url: '',
type: i % 3 === 0 ? 'collage' : 'individual',
category_id: (i % 4) + 1,
category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4],
category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4],
size: Math.floor(Math.random() * 5000000) + 1000000,
uploaded_at: new Date().toISOString(),
}));
};
const mockCategories = [
{ id: 1, name: 'Ceremony', slug: 'ceremony', is_global: true },
{ id: 2, name: 'Reception', slug: 'reception', is_global: true },
{ id: 3, name: 'Portraits', slug: 'portraits', is_global: true },
{ id: 4, name: 'Party', slug: 'party', is_global: true },
];
export const PreviewPage: React.FC = () => {
const { setTheme } = useTheme();
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
const mockEvent = {
event_name: 'Preview Wedding Gallery',
event_date: new Date().toISOString(),
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
};
useEffect(() => {
// Listen for theme preview messages from the branding page
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'THEME_PREVIEW') {
setTheme(event.data.theme);
setBrandingSettings(event.data.branding);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setTheme]);
// Filter photos
const filteredPhotos = useMemo(() => {
let photos = [...mockPhotos];
// Apply category filter
if (selectedCategoryId) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
if (searchTerm) {
photos = photos.filter(photo =>
photo.filename.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Apply sorting
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
}
});
return photos;
}, [mockPhotos, selectedCategoryId, searchTerm, sortBy]);
// Custom photo renderer for preview
const PreviewPhotoGrid: React.FC<{ photos: any[] }> = ({ photos }) => (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo) => (
<Card key={photo.id} className="overflow-hidden group cursor-pointer">
<div className="aspect-[4/3] bg-gradient-to-br from-neutral-200 to-neutral-300 relative">
<div className="absolute inset-0 flex items-center justify-center">
<Camera className="w-12 h-12 text-neutral-400" />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
{photo.category_name && (
<p className="text-white/70 text-xs">{photo.category_name}</p>
)}
</div>
</div>
</Card>
))}
</div>
);
return (
<GalleryLayout
event={mockEvent}
brandingSettings={brandingSettings}
showLogout={false}
showDownloadAll={false}
>
<div className="mt-8">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold text-neutral-900">Theme Preview</h2>
<p className="text-neutral-600">This is how your galleries will look with the current theme settings</p>
</div>
{/* Filters */}
<PhotoFilterBar
categories={mockCategories}
photos={mockPhotos}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
/>
{/* Photo Grid */}
<div className="mt-6">
<PreviewPhotoGrid photos={filteredPhotos} />
</div>
</div>
</GalleryLayout>
);
};
+138
View File
@@ -0,0 +1,138 @@
import React, { useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Home } from 'lucide-react';
import { Loading, Card } from '../../components/common';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
export const LegalPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { i18n } = useTranslation();
const navigate = useNavigate();
// Extract page slug from pathname if not in params (for static routes like /impressum)
const pathname = window.location.pathname;
const pageSlug = slug || pathname.split('/').pop() || '';
// Fetch settings to get default language
const { data: settingsData } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Use admin settings language
const lang = settingsData?.default_language || 'en';
// Fetch page content
const { data: page, isLoading, error } = useQuery({
queryKey: ['legal-page', pageSlug, lang],
queryFn: () => cmsService.getPublicPage(pageSlug, lang),
enabled: !!pageSlug && pageSlug !== '' && !!settingsData,
});
// Set i18n language when settings are loaded
useEffect(() => {
if (settingsData?.default_language) {
i18n.changeLanguage(settingsData.default_language);
}
}, [settingsData, i18n]);
// Update page title
useEffect(() => {
if (page?.title) {
document.title = `${page.title} - Wedding Photo Sharing`;
}
}, [page?.title]);
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Loading size="lg" text="Loading..." />
</div>
);
}
if (error || !page) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<div className="text-center py-12 px-6">
<h2 className="text-xl font-semibold mb-2">Page Not Found</h2>
<p className="text-neutral-600 mb-6">
The page you're looking for doesn't exist.
</p>
<Link
to="/"
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Home className="w-4 h-4" />
Go to Homepage
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-neutral-50">
{/* Header */}
<header className="bg-white border-b border-neutral-200">
<div className="container py-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-neutral-600 hover:text-neutral-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{i18n.language === 'de' ? 'Zurück' : 'Back'}
</button>
</div>
</header>
{/* Content */}
<main className="container py-12">
<div className="max-w-4xl mx-auto">
<Card padding="lg">
<h1 className="text-3xl font-bold text-neutral-900 mb-8">{page.title}</h1>
<div
className="prose prose-neutral max-w-none"
dangerouslySetInnerHTML={{ __html: page.content }}
/>
</Card>
</div>
</main>
{/* Footer */}
<footer className="mt-auto py-8 border-t border-neutral-200">
<div className="container text-center">
<div className="flex justify-center gap-4 text-sm">
<Link
to="/impressum"
className="text-neutral-600 hover:text-neutral-900"
>
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
</Link>
<span className="text-neutral-400"></span>
<Link
to="/datenschutz"
className="text-neutral-600 hover:text-neutral-900"
>
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
</Link>
</div>
<p className="text-sm text-neutral-500 mt-4">
© 2024 Wedding Photo Sharing. All rights reserved.
</p>
</div>
</footer>
</div>
);
};