Implement complete frontend with admin panel and theme system

- Add admin authentication and dashboard
- Create event management pages (list, create, edit, archive)
- Implement gallery enhancements (search, sorting, bulk download)
- Add email configuration and archive management pages
- Integrate Umami analytics with tracking throughout the app
- Add comprehensive error boundaries and loading states
- Implement accessibility features (WCAG 2.1 AA compliance)
- Create theme system with preset themes and customization
- Add branding settings and company information management
- Fix backend database initialization and health check
- Configure proper API URLs and environment variables

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-06 22:04:45 +02:00
parent 6c82958c79
commit 28632e8970
53 changed files with 13843 additions and 181 deletions
@@ -0,0 +1,154 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell } from 'lucide-react';
import { format } from 'date-fns';
import { useAdminAuth } from '../../contexts';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
interface AdminHeaderProps {
onMenuClick: () => void;
}
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
useOnClickOutside(userMenuRef, () => setShowUserMenu(false));
useOnClickOutside(notificationRef, () => setShowNotifications(false));
const handleLogout = () => {
logout();
navigate('/admin/login');
};
// Mock notifications
const notifications = [
{
id: 1,
type: 'warning',
message: '3 events expiring in the next 7 days',
time: new Date(),
},
{
id: 2,
type: 'success',
message: 'Wedding Smith-Jones archived successfully',
time: new Date(Date.now() - 3600000),
},
];
return (
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Mobile menu button */}
<button
onClick={onMenuClick}
className="lg:hidden text-neutral-500 hover:text-neutral-700"
>
<Menu className="w-6 h-6" />
</button>
{/* Desktop breadcrumb or page title could go here */}
<div className="hidden lg:block">
<h2 className="text-lg font-semibold text-neutral-900">
{format(new Date(), 'EEEE, MMMM d, yyyy')}
</h2>
</div>
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{notifications.length > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
{/* Notifications dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-neutral-200 py-2">
<div className="px-4 py-2 border-b border-neutral-100">
<h3 className="text-sm font-semibold text-neutral-900">Notifications</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
<div
key={notification.id}
className="px-4 py-3 hover:bg-neutral-50 cursor-pointer"
>
<p className="text-sm text-neutral-900">{notification.message}</p>
<p className="text-xs text-neutral-500 mt-1">
{format(notification.time, 'h:mm a')}
</p>
</div>
))}
</div>
<div className="px-4 py-2 border-t border-neutral-100">
<button className="text-sm text-primary-600 hover:text-primary-700">
View all notifications
</button>
</div>
</div>
)}
</div>
{/* User menu */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<div className="text-right hidden sm:block">
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
<p className="text-xs text-neutral-500">{user?.email}</p>
</div>
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
</button>
{/* User dropdown */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-neutral-200 py-1">
<div className="px-4 py-2 border-b border-neutral-100 sm:hidden">
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
<p className="text-xs text-neutral-500">{user?.email}</p>
</div>
<button
onClick={() => {
setShowUserMenu(false);
navigate('/admin/settings');
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Settings className="w-4 h-4" />
Settings
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
)}
</div>
</div>
</div>
</div>
</header>
);
};
@@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAdminAuth } from '../../contexts';
import { AdminSidebar } from './AdminSidebar';
import { AdminHeader } from './AdminHeader';
export const AdminLayout: React.FC = () => {
const { isAuthenticated } = useAdminAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />;
}
return (
<div className="min-h-screen bg-neutral-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main content */}
<div className="lg:pl-64">
{/* Header */}
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
{/* Page content */}
<main id="main-content" className="px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
</div>
</div>
);
};
@@ -0,0 +1,105 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Mail,
Archive,
BarChart3,
Settings,
Camera,
X,
Palette
} from 'lucide-react';
interface AdminSidebarProps {
isOpen: boolean;
onClose: () => void;
}
interface NavItem {
name: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ name: 'Events', href: '/admin/events', icon: Calendar },
{ name: 'Archives', href: '/admin/archives', icon: Archive },
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
{ name: 'Email Settings', href: '/admin/email', icon: Mail },
{ name: 'Branding', href: '/admin/branding', icon: Palette },
{ name: 'Settings', href: '/admin/settings', icon: Settings },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
const location = useLocation();
return (
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-neutral-200 transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-full">
{/* Logo/Brand */}
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200">
<div className="flex items-center">
<Camera className="w-8 h-8 text-primary-600" />
<span className="ml-2 text-xl font-bold text-neutral-900">Photo Admin</span>
</div>
<button
onClick={onClose}
className="lg:hidden text-neutral-400 hover:text-neutral-600"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto">
{navigation.map((item) => {
const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
return (
<NavLink
key={item.name}
to={item.href}
onClick={() => onClose()}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900'
}`}
>
<item.icon className={`w-5 h-5 mr-3 ${
isActive ? 'text-primary-600' : 'text-neutral-400'
}`} />
{item.name}
</NavLink>
);
})}
</nav>
{/* Storage Info */}
<div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-700">Storage Used</span>
<span className="font-medium text-neutral-900">2.4 GB</span>
</div>
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: '24%' }}
/>
</div>
<p className="text-xs text-neutral-600 mt-1">24% of 10 GB</p>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,335 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { PRESET_THEMES, type ThemeConfig } from '../../contexts/ThemeContext';
interface ThemeCustomizerProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
presetName?: string;
onPresetChange?: (presetName: string) => void;
}
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
value,
onChange,
presetName = 'default',
onPresetChange
}) => {
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
useEffect(() => {
setLocalTheme(value);
setCustomCss(value.customCss || '');
}, [value]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
if (isPreviewMode) {
onChange(updated);
}
};
const handlePresetSelect = (presetKey: string) => {
const preset = PRESET_THEMES[presetKey];
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
if (onPresetChange) {
onPresetChange(presetKey);
}
if (isPreviewMode) {
onChange(preset.config);
}
}
};
const handleApply = () => {
onChange({ ...localTheme, customCss });
};
const handleReset = () => {
handlePresetSelect('default');
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
handleChange('logoUrl', dataUrl);
};
reader.readAsDataURL(file);
}
};
return (
<div className="space-y-6">
{/* Preset Themes */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Preset Themes</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(PRESET_THEMES).map(([key, theme]) => (
<button
key={key}
onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all ${
selectedPreset === key
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
{selectedPreset === key && (
<Check className="w-4 h-4 text-primary-600" />
)}
</div>
<div className="flex gap-2">
<div
className="w-6 h-6 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.primaryColor }}
/>
<div
className="w-6 h-6 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.accentColor }}
/>
<div
className="w-6 h-6 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
</div>
</button>
))}
</div>
</Card>
{/* Color Customization */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Colors</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Primary Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
placeholder="#5C8762"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Accent Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
placeholder="#22c55e"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Background Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
placeholder="#fafafa"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Text Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
placeholder="#171717"
className="flex-1"
/>
</div>
</div>
</div>
</Card>
{/* Typography & Style */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Typography & Style</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Font Family
</label>
<select
value={localTheme.fontFamily || 'Inter, sans-serif'}
onChange={(e) => handleChange('fontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="Inter, sans-serif">Inter (Default)</option>
<option value="Georgia, serif">Georgia (Elegant)</option>
<option value="Helvetica, Arial, sans-serif">Helvetica (Clean)</option>
<option value="'Playfair Display', serif">Playfair Display (Sophisticated)</option>
<option value="'Comic Sans MS', cursive">Comic Sans (Playful)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Border Radius
</label>
<div className="flex gap-2">
{(['none', 'sm', 'md', 'lg'] as const).map((radius) => (
<button
key={radius}
onClick={() => handleChange('borderRadius', radius)}
className={`px-4 py-2 rounded-lg border-2 transition-all ${
localTheme.borderRadius === radius
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
{radius === 'none' ? 'None' : radius.toUpperCase()}
</button>
))}
</div>
</div>
</div>
</Card>
{/* Logo Upload */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Branding</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Custom Logo
</label>
<div className="flex items-center gap-4">
{localTheme.logoUrl && (
<img
src={localTheme.logoUrl}
alt="Custom logo"
className="h-16 w-auto object-contain"
/>
)}
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Logo
</Button>
</label>
{localTheme.logoUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleChange('logoUrl', undefined)}
>
Remove
</Button>
)}
</div>
</div>
</div>
</Card>
{/* Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Custom CSS</h3>
<textarea
value={customCss}
onChange={(e) => setCustomCss(e.target.value)}
placeholder="/* Add custom CSS here */"
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<p className="mt-2 text-sm text-neutral-600">
Advanced: Add custom CSS to further customize the appearance
</p>
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPreviewMode}
onChange={(e) => setIsPreviewMode(e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-neutral-700">Live Preview</span>
</label>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
Reset to Default
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
Apply Theme
</Button>
</div>
</div>
</div>
);
};
+4
View File
@@ -0,0 +1,4 @@
export { AdminLayout } from './AdminLayout';
export { AdminSidebar } from './AdminSidebar';
export { AdminHeader } from './AdminHeader';
export { ThemeCustomizer } from './ThemeCustomizer';