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
@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import { Download, Maximize2, Check } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { Button } from '../common';
interface PhotoGridProps {
photos: Photo[];
slug: string;
}
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
const handlePhotoClick = (index: number) => {
if (isSelectionMode) {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photos[index].id)) {
newSelected.delete(photos[index].id);
} else {
newSelected.add(photos[index].id);
}
setSelectedPhotos(newSelected);
} else {
setSelectedPhotoIndex(index);
}
};
const handleDownload = (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
downloadPhotoMutation.mutate({
slug,
photoId: photo.id,
filename: photo.filename,
});
};
const toggleSelectionMode = () => {
setIsSelectionMode(!isSelectionMode);
setSelectedPhotos(new Set());
};
const selectAll = () => {
setSelectedPhotos(new Set(photos.map(p => p.id)));
};
const deselectAll = () => {
setSelectedPhotos(new Set());
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">No photos found</p>
</div>
);
}
return (
<>
{/* Selection Mode Controls */}
{photos.length > 1 && (
<div className="mb-4 flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button>
{isSelectionMode && (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
</span>
<Button variant="ghost" size="sm" onClick={selectAll}>
Select All
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll}>
Deselect All
</Button>
{selectedPhotos.size > 0 && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
>
Download Selected
</Button>
)}
</div>
)}
</div>
)}
{/* Photo Grid */}
<div className="gallery-grid">
{photos.map((photo, index) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index)}
onDownload={(e) => handleDownload(photo, e)}
/>
))}
</div>
{/* Lightbox */}
{selectedPhotoIndex !== null && (
<PhotoLightbox
photos={photos}
initialIndex={selectedPhotoIndex}
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
/>
)}
</>
);
};
interface PhotoThumbnailProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: () => void;
onDownload: (e: React.MouseEvent) => void;
}
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
photo,
isSelected,
isSelectionMode,
onClick,
onDownload,
}) => {
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.1,
});
return (
<div
ref={ref}
className="relative group cursor-pointer"
onClick={onClick}
>
{inView ? (
<>
<img
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{/* Selection checkbox */}
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
{/* Photo type badge */}
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
</>
) : (
<div className="skeleton aspect-square w-full" />
)}
</div>
);
};