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,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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||
export { Loading, LoadingSkeleton } from './Loading';
|
||||
Reference in New Issue
Block a user