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 <[email protected]>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Download, Grid, Square, LogOut, Calendar, Clock } from 'lucide-react';
|
||||
import { format, differenceInDays, parseISO } from 'date-fns';
|
||||
|
||||
import { Button, Loading } from '../common';
|
||||
import { useGalleryAuth } from '../../contexts';
|
||||
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { ExpirationBanner } from './ExpirationBanner';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
event: {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { logout } = useGalleryAuth();
|
||||
const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all');
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
const showUrgentWarning = daysUntilExpiration <= 7;
|
||||
|
||||
// Filter photos based on view mode
|
||||
const filteredPhotos = data?.photos.filter(photo => {
|
||||
if (viewMode === 'all') return true;
|
||||
if (viewMode === 'collages') return photo.type === 'collage';
|
||||
if (viewMode === 'individual') return photo.type === 'individual';
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
downloadAllMutation.mutate(slug);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<Loading size="lg" text="Loading photos..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-neutral-600">Failed to load photos</p>
|
||||
<Button onClick={() => window.location.reload()} className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
||||
<div className="container py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-neutral-600">
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Expires {format(parseISO(event.expires_at), 'MMM d')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={handleDownloadAll}
|
||||
isLoading={downloadAllMutation.isPending}
|
||||
className={showUrgentWarning ? 'animate-pulse' : ''}
|
||||
>
|
||||
Download All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Welcome Message */}
|
||||
{event.welcome_message && (
|
||||
<div className="container mt-6">
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<p className="text-primary-900">{event.welcome_message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="container mt-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'all' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('all')}
|
||||
leftIcon={<Grid className="w-4 h-4" />}
|
||||
>
|
||||
All Photos ({data.photos.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'collages' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('collages')}
|
||||
leftIcon={<Square className="w-4 h-4" />}
|
||||
>
|
||||
Collages ({data.photos.filter(p => p.type === 'collage').length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'individual' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('individual')}
|
||||
>
|
||||
Individual ({data.photos.filter(p => p.type === 'individual').length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600">
|
||||
{filteredPhotos.length} {filteredPhotos.length === 1 ? 'photo' : 'photos'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Photo Grid */}
|
||||
<PhotoGrid photos={filteredPhotos} slug={slug} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 py-8 border-t border-neutral-200">
|
||||
<div className="container text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Need help? Contact the event organizer at{' '}
|
||||
<a
|
||||
href={`mailto:${data.event.event_name}`}
|
||||
className="text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
support email
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user