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
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { clsx } from 'clsx';
import { Loader2 } from 'lucide-react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading = false,
disabled,
leftIcon,
rightIcon,
children,
...props
},
ref
) => {
const baseStyles = 'btn';
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
outline: 'btn-outline',
ghost: 'bg-transparent hover:bg-neutral-100 text-neutral-700',
};
const sizes = {
sm: 'btn-sm',
md: 'btn-md',
lg: 'btn-lg',
};
return (
<button
ref={ref}
className={clsx(
baseStyles,
variants[variant],
sizes[size],
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
leftIcon && <span className="mr-2">{leftIcon}</span>
)}
{children}
{!isLoading && rightIcon && <span className="ml-2">{rightIcon}</span>}
</button>
);
}
);
Button.displayName = 'Button';
+106
View File
@@ -0,0 +1,106 @@
import React from 'react';
import { clsx } from 'clsx';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'hover';
padding?: 'none' | 'sm' | 'md' | 'lg';
children: React.ReactNode;
}
export const Card: React.FC<CardProps> = ({
className,
variant = 'default',
padding = 'md',
children,
...props
}) => {
const paddingStyles = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
return (
<div
className={clsx(
variant === 'hover' ? 'card-hover' : 'card',
paddingStyles[padding],
className
)}
{...props}
>
{children}
</div>
);
};
interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
subtitle?: string;
action?: React.ReactNode;
}
export const CardHeader: React.FC<CardHeaderProps> = ({
title,
subtitle,
action,
className,
...props
}) => {
return (
<div
className={clsx(
'flex items-start justify-between mb-4',
className
)}
{...props}
>
<div>
<h3 className="text-lg font-semibold text-neutral-900">{title}</h3>
{subtitle && (
<p className="mt-1 text-sm text-neutral-500">{subtitle}</p>
)}
</div>
{action && <div className="ml-4">{action}</div>}
</div>
);
};
interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export const CardContent: React.FC<CardContentProps> = ({
className,
children,
...props
}) => {
return (
<div className={clsx('', className)} {...props}>
{children}
</div>
);
};
interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export const CardFooter: React.FC<CardFooterProps> = ({
className,
children,
...props
}) => {
return (
<div
className={clsx(
'mt-6 pt-6 border-t border-neutral-200',
className
)}
{...props}
>
{children}
</div>
);
};
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import { clsx } from 'clsx';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
className,
label,
error,
helperText,
leftIcon,
rightIcon,
id,
...props
},
ref
) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-neutral-700 mb-1.5"
>
{label}
</label>
)}
<div className="relative">
{leftIcon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-neutral-500">{leftIcon}</span>
</div>
)}
<input
ref={ref}
id={inputId}
className={clsx(
'input',
leftIcon && 'pl-10',
rightIcon && 'pr-10',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
}
{...props}
/>
{rightIcon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<span className="text-neutral-500">{rightIcon}</span>
</div>
)}
</div>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600">
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500">
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
@@ -0,0 +1,77 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import { clsx } from 'clsx';
interface LoadingProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
fullScreen?: boolean;
className?: string;
}
export const Loading: React.FC<LoadingProps> = ({
size = 'md',
text,
fullScreen = false,
className,
}) => {
const sizeStyles = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
};
const content = (
<div className={clsx('flex flex-col items-center justify-center', className)}>
<Loader2 className={clsx('animate-spin text-primary-600', sizeStyles[size])} />
{text && (
<p className="mt-4 text-sm text-neutral-600">{text}</p>
)}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-50">
{content}
</div>
);
}
return content;
};
interface LoadingSkeletonProps {
className?: string;
count?: number;
type?: 'text' | 'card' | 'image';
}
export const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
className,
count = 1,
type = 'text',
}) => {
const baseStyles = 'skeleton';
const typeStyles = {
text: 'h-4 w-full rounded',
card: 'h-32 w-full rounded-xl',
image: 'aspect-square w-full rounded-lg',
};
return (
<>
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className={clsx(
baseStyles,
typeStyles[type],
className
)}
/>
))}
</>
);
};
+4
View File
@@ -0,0 +1,4 @@
export { Button } from './Button';
export { Input } from './Input';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading';