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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user