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,76 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import { GalleryAuthProvider, AdminAuthProvider } from './contexts';
|
||||
import { GalleryPage } from './pages/GalleryPage';
|
||||
|
||||
// Page imports (to be created)
|
||||
// import { AdminLoginPage } from './pages/admin/AdminLoginPage';
|
||||
// import { AdminDashboard } from './pages/admin/AdminDashboard';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public gallery routes */}
|
||||
<Route path="/gallery/:slug" element={
|
||||
<GalleryAuthProvider>
|
||||
<GalleryPage />
|
||||
</GalleryAuthProvider>
|
||||
} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin/*" element={
|
||||
<AdminAuthProvider>
|
||||
<Routes>
|
||||
<Route path="login" element={
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<h1 className="text-2xl font-bold text-center py-8">Admin Login (To be implemented)</h1>
|
||||
</div>
|
||||
} />
|
||||
<Route path="dashboard" element={
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<h1 className="text-2xl font-bold text-center py-8">Admin Dashboard (To be implemented)</h1>
|
||||
</div>
|
||||
} />
|
||||
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AdminAuthProvider>
|
||||
} />
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer
|
||||
position="bottom-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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';
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Download } from 'lucide-react';
|
||||
import Countdown from 'react-countdown';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
interface ExpirationBannerProps {
|
||||
daysRemaining: number;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
|
||||
daysRemaining,
|
||||
expiresAt
|
||||
}) => {
|
||||
const expirationDate = parseISO(expiresAt);
|
||||
|
||||
const countdownRenderer = ({ days, hours, minutes, completed }: any) => {
|
||||
if (completed) {
|
||||
return <span>Gallery has expired</span>;
|
||||
} else {
|
||||
return (
|
||||
<span className="font-mono">
|
||||
{days}d {hours}h {minutes}m
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getBannerColor = () => {
|
||||
if (daysRemaining <= 1) return 'bg-red-600';
|
||||
if (daysRemaining <= 3) return 'bg-amber-600';
|
||||
return 'bg-amber-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${getBannerColor()} text-white sticky top-0 z-50`}>
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 mr-2 animate-pulse" />
|
||||
<span className="font-medium">
|
||||
Gallery expires in <Countdown date={expirationDate} renderer={countdownRenderer} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
<span>Download your photos now!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
photos,
|
||||
initialIndex,
|
||||
onClose,
|
||||
slug,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goToPrevious();
|
||||
if (e.key === 'ArrowRight') goToNext();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [currentIndex]);
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoom(1);
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setZoom((prev) => Math.min(prev + 0.5, 3));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setZoom((prev) => Math.max(prev - 0.5, 1));
|
||||
if (zoom - 0.5 <= 1) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadPhotoMutation.mutate({
|
||||
slug,
|
||||
photoId: currentPhoto.id,
|
||||
filename: currentPhoto.filename,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging && zoom > 1) {
|
||||
setDragOffset({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleImageClick = (e: React.MouseEvent) => {
|
||||
// Only close if clicking the background, not the image
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-50 flex items-center justify-center">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div className="text-white">
|
||||
<p className="text-sm opacity-75">
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</p>
|
||||
<p className="font-medium">{currentPhoto.filename}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<span className="text-white text-sm w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
onClick={handleImageClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
>
|
||||
<img
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Touch/swipe indicators for mobile */}
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden">
|
||||
Swipe to navigate
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { GalleryView } from './GalleryView';
|
||||
export { PhotoGrid } from './PhotoGrid';
|
||||
export { PhotoLightbox } from './PhotoLightbox';
|
||||
export { ExpirationBanner } from './ExpirationBanner';
|
||||
@@ -0,0 +1,79 @@
|
||||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
// Cookie keys
|
||||
export const ADMIN_TOKEN_KEY = 'admin_token';
|
||||
export const GALLERY_TOKEN_KEY = 'gallery_token';
|
||||
|
||||
// Create axios instance
|
||||
export const api = axios.create({
|
||||
baseURL: '',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// Check if it's an admin route or gallery route
|
||||
const isAdminRoute = config.url?.includes('/admin');
|
||||
const token = isAdminRoute
|
||||
? Cookies.get(ADMIN_TOKEN_KEY)
|
||||
: Cookies.get(GALLERY_TOKEN_KEY);
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear tokens on unauthorized
|
||||
Cookies.remove(ADMIN_TOKEN_KEY);
|
||||
Cookies.remove(GALLERY_TOKEN_KEY);
|
||||
|
||||
// Redirect to appropriate login
|
||||
const isAdminRoute = error.config?.url?.includes('/admin');
|
||||
if (isAdminRoute) {
|
||||
window.location.href = '/admin/login';
|
||||
} else {
|
||||
// For gallery routes, redirect to the gallery password page
|
||||
const currentPath = window.location.pathname;
|
||||
const gallerySlug = currentPath.split('/')[2];
|
||||
if (gallerySlug) {
|
||||
window.location.href = `/gallery/${gallerySlug}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to set auth tokens
|
||||
export const setAuthToken = (token: string, isAdmin: boolean = false) => {
|
||||
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
|
||||
Cookies.set(key, token, { expires: 1 }); // 1 day expiry
|
||||
};
|
||||
|
||||
// Helper to clear auth tokens
|
||||
export const clearAuthToken = (isAdmin: boolean = false) => {
|
||||
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
|
||||
Cookies.remove(key);
|
||||
};
|
||||
|
||||
// Helper to get auth tokens
|
||||
export const getAuthToken = (isAdmin: boolean = false) => {
|
||||
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
|
||||
return Cookies.get(key);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { getAuthToken } from '../config/api';
|
||||
import { authService } from '../services';
|
||||
import type { AdminUser } from '../types';
|
||||
|
||||
interface AdminAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
user: AdminUser | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAdminAuth = () => {
|
||||
const context = useContext(AdminAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAdminAuth must be used within an AdminAuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AdminAuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has a valid token on mount
|
||||
const token = getAuthToken(true);
|
||||
if (token) {
|
||||
// TODO: Validate token with backend and get user info
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.adminLogin(username, password);
|
||||
setUser(response.user);
|
||||
setIsAuthenticated(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid credentials');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authService.adminLogout();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminAuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AdminAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { getAuthToken } from '../config/api';
|
||||
import { authService } from '../services';
|
||||
|
||||
interface GalleryEvent {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface GalleryAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
event: GalleryEvent | null;
|
||||
login: (slug: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const GalleryAuthContext = createContext<GalleryAuthContextType | undefined>(undefined);
|
||||
|
||||
export const useGalleryAuth = () => {
|
||||
const context = useContext(GalleryAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useGalleryAuth must be used within a GalleryAuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GalleryAuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has a valid token on mount
|
||||
const token = getAuthToken(false);
|
||||
if (token) {
|
||||
// TODO: Validate token with backend
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (slug: string, password: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password);
|
||||
setEvent(response.event);
|
||||
setIsAuthenticated(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid password');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authService.galleryLogout();
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<GalleryAuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
event,
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</GalleryAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
|
||||
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { galleryService } from '../services';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useGalleryInfo = (slug: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['gallery-info', slug],
|
||||
queryFn: () => galleryService.getGalleryInfo(slug),
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['gallery-photos', slug],
|
||||
queryFn: () => galleryService.getGalleryPhotos(slug),
|
||||
enabled,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
export const useGalleryStats = (slug: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['gallery-stats', slug],
|
||||
queryFn: () => galleryService.getGalleryStats(slug),
|
||||
enabled,
|
||||
retry: 1,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
export const useDownloadPhoto = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
slug,
|
||||
photoId,
|
||||
filename,
|
||||
}: {
|
||||
slug: string;
|
||||
photoId: number;
|
||||
filename: string;
|
||||
}) => galleryService.downloadPhoto(slug, photoId, filename),
|
||||
onSuccess: () => {
|
||||
toast.success('Photo downloaded successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to download photo');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDownloadAllPhotos = () => {
|
||||
return useMutation({
|
||||
mutationFn: (slug: string) => galleryService.downloadAllPhotos(slug),
|
||||
onSuccess: () => {
|
||||
toast.success('Download started');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to download photos');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-primary: 92 135 98;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
@apply bg-neutral-50 text-neutral-900 antialiased;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-neutral-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-neutral-300 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-neutral-400;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Button styles */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-neutral-200 text-neutral-900 hover:bg-neutral-300 focus-visible:ring-neutral-400;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-neutral-300 bg-transparent text-neutral-700 hover:bg-neutral-100 focus-visible:ring-neutral-400;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply h-9 px-3 text-sm;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
@apply h-10 px-4 py-2;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply h-11 px-8 text-lg;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
@apply flex h-10 w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply rounded-xl border border-neutral-200 bg-white shadow-soft;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card transition-all duration-200 hover:shadow-medium hover:translate-y-[-2px];
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
@apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl;
|
||||
}
|
||||
|
||||
/* Image loading skeleton */
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-neutral-200 rounded-lg;
|
||||
}
|
||||
|
||||
/* Gallery grid */
|
||||
.gallery-grid {
|
||||
@apply grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 bg-black/50 backdrop-blur-sm animate-fade-in;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-amber-100 text-amber-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Text gradient */
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-primary-800;
|
||||
}
|
||||
|
||||
/* Smooth scroll */
|
||||
.smooth-scroll {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { api, setAuthToken, clearAuthToken } from '../config/api';
|
||||
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
||||
|
||||
export const authService = {
|
||||
// Admin authentication
|
||||
async adminLogin(username: string, password: string): Promise<LoginResponse> {
|
||||
const response = await api.post<LoginResponse>('/api/auth/admin/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
setAuthToken(response.data.token, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
adminLogout() {
|
||||
clearAuthToken(true);
|
||||
window.location.href = '/admin/login';
|
||||
},
|
||||
|
||||
// Gallery authentication
|
||||
async verifyGalleryPassword(slug: string, password: string): Promise<GalleryAuthResponse> {
|
||||
const response = await api.post<GalleryAuthResponse>('/api/auth/gallery/verify', {
|
||||
slug,
|
||||
password,
|
||||
});
|
||||
|
||||
setAuthToken(response.data.token, false);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
galleryLogout() {
|
||||
clearAuthToken(false);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { api } from '../config/api';
|
||||
import type { Event } from '../types';
|
||||
|
||||
interface CreateEventData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
admin_email: string;
|
||||
password: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface UpdateEventData {
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface EventsListResponse {
|
||||
events: Event[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export const eventsService = {
|
||||
// Get all events (admin)
|
||||
async getEvents(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: 'active' | 'inactive' | 'archived'
|
||||
): Promise<EventsListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
if (status) {
|
||||
params.append('status', status);
|
||||
}
|
||||
|
||||
const response = await api.get<EventsListResponse>(`/api/admin/events?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single event details (admin)
|
||||
async getEvent(id: number): Promise<Event> {
|
||||
const response = await api.get<Event>(`/api/admin/events/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create new event (admin)
|
||||
async createEvent(data: CreateEventData): Promise<Event> {
|
||||
const response = await api.post<Event>('/api/admin/events', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update event (admin)
|
||||
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
|
||||
const response = await api.patch<Event>(`/api/admin/events/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete/deactivate event (admin)
|
||||
async deleteEvent(id: number): Promise<void> {
|
||||
await api.delete(`/api/admin/events/${id}`);
|
||||
},
|
||||
|
||||
// Force archive event (admin)
|
||||
async archiveEvent(id: number): Promise<void> {
|
||||
await api.post(`/api/admin/events/${id}/archive`);
|
||||
},
|
||||
|
||||
// Extend event expiration (admin)
|
||||
async extendExpiration(id: number, newExpiryDate: string): Promise<Event> {
|
||||
const response = await api.patch<Event>(`/api/admin/events/${id}`, {
|
||||
expires_at: newExpiryDate,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||
|
||||
export const galleryService = {
|
||||
// Get basic gallery info (no auth required)
|
||||
async getGalleryInfo(slug: string): Promise<GalleryInfo> {
|
||||
const response = await api.get<GalleryInfo>(`/api/gallery/${slug}/info`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get gallery photos (requires auth)
|
||||
async getGalleryPhotos(slug: string): Promise<GalleryData> {
|
||||
const response = await api.get<GalleryData>(`/api/gallery/${slug}/photos`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Download single photo
|
||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
const response = await api.get(`/api/gallery/${slug}/download/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// Download all photos as ZIP
|
||||
async downloadAllPhotos(slug: string): Promise<void> {
|
||||
const response = await api.get(`/api/gallery/${slug}/download-all`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `${slug}.zip`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// Get gallery statistics
|
||||
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
||||
const response = await api.get<GalleryStats>(`/api/gallery/${slug}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { authService } from './auth.service';
|
||||
export { galleryService } from './gallery.service';
|
||||
export { eventsService } from './events.service';
|
||||
@@ -0,0 +1,94 @@
|
||||
// Event/Gallery types
|
||||
export interface Event {
|
||||
id: number;
|
||||
slug: string;
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
admin_email: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
share_link: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
is_active: boolean;
|
||||
is_archived: boolean;
|
||||
archive_path?: string;
|
||||
archived_at?: string;
|
||||
}
|
||||
|
||||
export interface GalleryInfo {
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
expires_at: string;
|
||||
is_active: boolean;
|
||||
is_expired: boolean;
|
||||
}
|
||||
|
||||
export interface Photo {
|
||||
id: number;
|
||||
filename: string;
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
type: 'collage' | 'individual';
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface GalleryData {
|
||||
event: {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
};
|
||||
photos: Photo[];
|
||||
}
|
||||
|
||||
export interface GalleryStats {
|
||||
total_photos: number;
|
||||
total_views: number;
|
||||
total_downloads: number;
|
||||
unique_visitors: number;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: AdminUser;
|
||||
}
|
||||
|
||||
export interface GalleryAuthResponse {
|
||||
token: string;
|
||||
event: {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
// API Error type
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
errors?: Array<{
|
||||
type: string;
|
||||
msg: string;
|
||||
path: string;
|
||||
location: string;
|
||||
}>;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user