Add complete frontend implementation and Docker deployment setup

- Implement React frontend with TypeScript and Tailwind CSS
- Add scrappbook.de-inspired UI design with photo galleries
- Implement authentication, photo viewing, and download features
- Add Docker Swarm configuration with Traefik reverse proxy
- Set up Drone CI/CD pipeline for automated deployments
- Add monitoring stack with Prometheus and Grafana
- Create comprehensive deployment documentation
- Add simple local development setup with docker-compose.local.yml

Features:
- Password-protected galleries with expiration warnings
- Responsive photo grid with lightbox viewer
- Bulk download functionality
- Hot reload development environment
- Email testing with Mailhog
- Production-ready deployment scripts

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-06 20:23:13 +02:00
parent 032bbae50d
commit 6c82958c79
73 changed files with 10611 additions and 2 deletions
+176
View File
@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
import { format, differenceInDays, parseISO } from 'date-fns';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery/GalleryView';
export const GalleryPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
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!);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
: null;
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setLoginError('Please enter a password');
return;
}
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, password);
} catch (error: any) {
setLoginError(error.response?.data?.error || 'Invalid password');
} finally {
setIsLoggingIn(false);
}
};
// 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>
);
}
// 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>
);
}
// 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>
);
}
// Show gallery view if authenticated
if (isAuthenticated && event) {
return <GalleryView slug={slug!} event={event} />;
}
// Show login form
return (
<div className="min-h-screen bg-gradient-to-br from-neutral-50 to-sand-100">
<div className="min-h-screen flex items-center justify-center p-4">
<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>
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-neutral-600 text-sm">
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(galleryInfo!.event_date), 'MMMM d, yyyy')}
</div>
</div>
{/* Expiration Warning */}
{daysUntilExpiration !== null && daysUntilExpiration <= 7 && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start">
<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'}
</p>
<p className="text-xs text-amber-700 mt-1">
Download your photos before they're no longer available.
</p>
</div>
</div>
</div>
)}
{/* Login Card */}
<Card>
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-6">Enter Gallery Password</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label="Password"
placeholder="Enter the gallery password"
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
View Gallery
</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.
</p>
</CardContent>
</Card>
{/* Event Type Badge */}
<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>
</div>
</div>
</div>
);
};