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