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
+311
View File
@@ -0,0 +1,311 @@
kind: pipeline
type: docker
name: default
trigger:
branch:
- main
- develop
- feature/*
event:
- push
- pull_request
- tag
volumes:
- name: docker
host:
path: /var/run/docker.sock
steps:
# Frontend Tests
- name: frontend-test
image: node:18-alpine
commands:
- cd frontend
- npm ci --legacy-peer-deps
- npm run lint
- npm run build
when:
event:
- push
- pull_request
# Backend Tests
- name: backend-test
image: node:18-alpine
commands:
- cd backend
- npm ci
- npm run lint
- npm test
environment:
NODE_ENV: test
JWT_SECRET: test-secret
when:
event:
- push
- pull_request
# Build Frontend Docker Image
- name: build-frontend
image: plugins/docker
settings:
repo: ${DRONE_REPO_NAMESPACE}/photo-sharing-frontend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_TAG}
dockerfile: frontend/Dockerfile
context: frontend
username:
from_secret: docker_username
password:
from_secret: docker_password
registry:
from_secret: docker_registry
when:
branch:
- main
event:
- push
- tag
# Build Backend Docker Image
- name: build-backend
image: plugins/docker
settings:
repo: ${DRONE_REPO_NAMESPACE}/photo-sharing-backend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_TAG}
dockerfile: backend/Dockerfile
context: backend
username:
from_secret: docker_username
password:
from_secret: docker_password
registry:
from_secret: docker_registry
when:
branch:
- main
event:
- push
- tag
# Security Scan
- name: security-scan
image: aquasec/trivy:latest
commands:
- trivy image --exit-code 0 --no-progress ${DRONE_REPO_NAMESPACE}/photo-sharing-frontend:${DRONE_COMMIT_SHA:0:8}
- trivy image --exit-code 0 --no-progress ${DRONE_REPO_NAMESPACE}/photo-sharing-backend:${DRONE_COMMIT_SHA:0:8}
environment:
DOCKER_HOST: tcp://docker:2375
volumes:
- name: docker
path: /var/run/docker.sock
when:
branch:
- main
event:
- push
# Deploy to Staging
- name: deploy-staging
image: alpine:latest
environment:
SWARM_HOST:
from_secret: staging_swarm_host
SWARM_USER:
from_secret: staging_swarm_user
SWARM_KEY:
from_secret: staging_swarm_key
REGISTRY_URL:
from_secret: docker_registry
VERSION: ${DRONE_COMMIT_SHA:0:8}
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
- |
ssh $SWARM_USER@$SWARM_HOST << EOF
cd /opt/photo-sharing
export REGISTRY_URL=$REGISTRY_URL
export VERSION=$VERSION
docker stack deploy -c deploy/docker-stack.yml photo-sharing
EOF
when:
branch:
- develop
event:
- push
# Deploy to Production
- name: deploy-production
image: alpine:latest
environment:
SWARM_HOST:
from_secret: prod_swarm_host
SWARM_USER:
from_secret: prod_swarm_user
SWARM_KEY:
from_secret: prod_swarm_key
REGISTRY_URL:
from_secret: docker_registry
VERSION: ${DRONE_TAG:-latest}
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
- |
ssh $SWARM_USER@$SWARM_HOST << EOF
cd /opt/photo-sharing
export REGISTRY_URL=$REGISTRY_URL
export VERSION=$VERSION
# Backup database before deployment
docker exec \$(docker ps -q -f name=photo-sharing_db) pg_dump -U postgres photo_sharing > /backup/db-backup-\$(date +%Y%m%d-%H%M%S).sql
# Deploy stack
docker stack deploy -c deploy/docker-stack.yml photo-sharing --with-registry-auth
# Wait for services to be ready
sleep 30
# Run migrations if needed
docker exec \$(docker ps -q -f name=photo-sharing_backend) npm run migrate
EOF
when:
event:
- tag
# Health Check
- name: health-check
image: alpine:latest
commands:
- apk add --no-cache curl
- sleep 30
- curl -f https://${FRONTEND_HOST}/health || exit 1
- curl -f https://${BACKEND_HOST}/api/health || exit 1
when:
branch:
- main
event:
- push
- tag
# Notification - Success
- name: notify-success
image: plugins/slack
settings:
webhook:
from_secret: slack_webhook
channel: deployments
template: |
✅ *Build {{build.number}} succeeded* for {{repo.name}}
Branch: {{build.branch}}
Commit: {{build.commit}}
Author: {{build.author}}
{{#if build.tag}}
🏷️ Tag: {{build.tag}}
🚀 Deployed to *PRODUCTION*
{{else}}
📦 Deployed to *{{build.branch}}*
{{/if}}
🔗 {{build.link}}
when:
status:
- success
# Notification - Failure
- name: notify-failure
image: plugins/slack
settings:
webhook:
from_secret: slack_webhook
channel: deployments
template: |
❌ *Build {{build.number}} failed* for {{repo.name}}
Branch: {{build.branch}}
Commit: {{build.commit}}
Author: {{build.author}}
🔗 {{build.link}}
when:
status:
- failure
---
kind: pipeline
type: docker
name: rollback
trigger:
event:
- rollback
steps:
- name: rollback-production
image: alpine:latest
environment:
SWARM_HOST:
from_secret: prod_swarm_host
SWARM_USER:
from_secret: prod_swarm_user
SWARM_KEY:
from_secret: prod_swarm_key
REGISTRY_URL:
from_secret: docker_registry
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
- |
ssh $SWARM_USER@$SWARM_HOST << EOF
cd /opt/photo-sharing
export REGISTRY_URL=$REGISTRY_URL
export VERSION=${DRONE_ROLLBACK_TO}
# Deploy previous version
docker stack deploy -c deploy/docker-stack.yml photo-sharing --with-registry-auth
EOF
---
kind: secret
name: docker_username
get:
path: drone/docker
name: username
---
kind: secret
name: docker_password
get:
path: drone/docker
name: password
---
kind: secret
name: docker_registry
get:
path: drone/docker
name: registry
---
kind: secret
name: slack_webhook
get:
path: drone/slack
name: webhook
+60
View File
@@ -0,0 +1,60 @@
# Application URLs
FRONTEND_HOST=photos.yourdomain.com
BACKEND_HOST=api.photos.yourdomain.com
ADMIN_URL=https://admin.photos.yourdomain.com
FRONTEND_URL=https://photos.yourdomain.com
# Database Configuration
DB_NAME=photo_sharing
DB_USER=photoapp
DB_PASSWORD=your-secure-password-here
# JWT Configuration
JWT_SECRET=your-jwt-secret-here
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
# Umami Analytics
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_HOST=analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=your-random-salt
UMAMI_DB_PASSWORD=umami-db-password
# Traefik Configuration
TRAEFIK_HOST=traefik.yourdomain.com
ACME_EMAIL=admin@yourdomain.com
TRAEFIK_DASHBOARD_AUTH=admin:$2y$10$... # Use htpasswd to generate
# Docker Registry (optional)
REGISTRY_URL=registry.yourdomain.com
VERSION=latest
# Monitoring
DOMAIN=yourdomain.com
GRAFANA_USER=admin
GRAFANA_PASSWORD=your-grafana-password
# OAuth Configuration (optional)
OAUTH_AUTH_URL=https://auth.yourdomain.com/oauth2/auth
OAUTH_TOKEN_URL=https://auth.yourdomain.com/oauth2/token
OAUTH_USER_URL=https://auth.yourdomain.com/oauth2/userinfo
OAUTH_CLIENT_ID=photo-sharing
OAUTH_CLIENT_SECRET=your-oauth-secret
OAUTH_SECRET=your-random-secret
COOKIE_DOMAIN=.yourdomain.com
OAUTH_WHITELIST=admin@yourdomain.com
# Backup Configuration (optional)
S3_BACKUP_BUCKET=your-backup-bucket
# Drone CI Configuration
DRONE_RPC_SECRET=your-drone-secret
DRONE_GITHUB_CLIENT_ID=your-github-client-id
DRONE_GITHUB_CLIENT_SECRET=your-github-client-secret
+149
View File
@@ -0,0 +1,149 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Product Overview
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
## Architecture Overview
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
- **Storage**: File-based with active/archived separation
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
- **Analytics**: Umami integration for engagement tracking
## Essential Commands
### Backend Development
```bash
cd backend
npm install # Install dependencies
npm run migrate # Initialize database schema
npm run dev # Start with hot-reload (port 3001)
npm test # Run Jest tests
npm run lint # ESLint checks
```
### Running a Single Test
```bash
cd backend
npm test -- path/to/test.test.js
npm test -- --testNamePattern="test name"
```
### Production
```bash
docker-compose -f docker-compose.prod.yml up -d # Production deployment
pm2 start ecosystem.config.js # Alternative: PM2 deployment
```
## Key Product Requirements (from PRD)
### Core Features
1. **File-Based System**: Drop photos in folders → automatic gallery creation
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
3. **Password Protection**: Secure access with customizable passwords
4. **Automatic Archiving**: ZIP compression and storage after expiration
5. **Email Notifications**: Creation, warning, and expiration notifications
6. **Analytics**: Umami tracking for views, downloads, and engagement
### Folder Structure
```
/events/
├── active/
│ ├── wedding-smith-jones-2024-06-15/
│ │ ├── collages/
│ │ └── individual/
│ └── birthday-emma-2024-07-20/
└── archived/
└── wedding-smith-jones-2024-06-15.zip
```
## Frontend Implementation Requirements
### Design Style (scrappbook.de-inspired)
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
- **Layout**: Minimalist, modular sections with grid-based photo displays
- **Aesthetic**: Professional yet approachable, photographer-focused
### Key Frontend Components to Build
1. **Landing Page**: Password entry with event preview
2. **Gallery View**:
- Responsive photo grid with lazy loading
- Toggle between collages/individual photos
- Prominent expiration banner
- Download urgency indicators
3. **Photo Lightbox**: Full-screen viewing with zoom
4. **Mobile-First**: Responsive design with touch gestures
5. **Personalization**: Dynamic theming per event type
### User Experience Priorities
- Clear expiration warnings (sticky banner)
- One-click "Download All" for urgent galleries
- Smooth image loading with skeleton screens
- Intuitive navigation between photo categories
- Professional presentation matching photographer branding
## Key Architecture Patterns
### Authentication Flow
- JWT-based with separate tokens for admin and gallery access
- Gallery tokens include event-specific claims
- Auth middleware: `backend/src/middleware/auth.js`
- `adminAuth` - Admin panel protection
- `photoAuth` - Protected photo access
- `verifyGalleryAccess` - Gallery-specific validation
### Database Schema (Knex/SQLite)
Main tables:
- `events` - Gallery metadata with expiration, custom messages, themes
- `photos` - Photo records linked to events
- `access_logs` - IP-based usage tracking
- `email_queue` - Async email processing
- `admin_users` - Admin authentication
### Service Architecture
Background services run as separate processes:
- **emailService**: Processes email queue with retry logic
- **archiveService**: Creates ZIP archives of expired events
- **expirationChecker**: Cron job for expiration warnings
- **fileWatcher**: Monitors for new photo uploads
### API Structure
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
- `/api/gallery/*` - Public gallery endpoints
- `/api/auth/*` - Authentication endpoints
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
## Critical Implementation Notes
1. **Security**: All gallery access requires valid JWT with event-specific claims
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
4. **File Processing**: Sharp library for thumbnail generation (300x300)
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Environment Variables
Required in `.env`:
- `JWT_SECRET` - Token signing
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
- `SMTP_*` - Email configuration
- `DB_*` - PostgreSQL credentials (production)
- `UMAMI_*` - Analytics configuration
## Testing Approach
- Jest with Supertest for API testing
- Test files in `__tests__` directories
- Database migrations run before tests
- Mock email sending in tests
## Success Metrics (from PRD)
- Time to generate gallery: <2 minutes
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
+483
View File
@@ -0,0 +1,483 @@
# Photo Sharing Platform - Production Deployment Guide
This guide covers deploying the photo sharing platform using Docker Swarm, Traefik, and Drone CI/CD.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Infrastructure Setup](#infrastructure-setup)
- [Docker Swarm Setup](#docker-swarm-setup)
- [Traefik Setup](#traefik-setup)
- [Application Deployment](#application-deployment)
- [CI/CD with Drone](#cicd-with-drone)
- [Monitoring](#monitoring)
- [Backup and Recovery](#backup-and-recovery)
- [Troubleshooting](#troubleshooting)
## Prerequisites
### Hardware Requirements
- **Manager Node**: 2 CPU cores, 4GB RAM, 50GB storage
- **Worker Nodes**: 2 CPU cores, 2GB RAM, 20GB storage
- **Storage**: SSD recommended for database and photo storage
### Software Requirements
- Ubuntu 20.04+ or similar Linux distribution
- Docker Engine 20.10+
- Docker Compose 2.0+
- Git
- SSL certificates (automated with Let's Encrypt)
### Network Requirements
- Ports 80, 443 open for web traffic
- Port 2377 for Swarm management
- Ports 7946, 4789 for Swarm networking
- Static IP or reliable dynamic DNS
## Infrastructure Setup
### 1. Install Docker
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
# Add user to docker group
sudo usermod -aG docker $USER
# Enable Docker service
sudo systemctl enable docker
sudo systemctl start docker
```
### 2. Configure Firewall
```bash
# Allow Docker Swarm ports
sudo ufw allow 2377/tcp
sudo ufw allow 7946/tcp
sudo ufw allow 7946/udp
sudo ufw allow 4789/udp
# Allow web traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
```
## Docker Swarm Setup
### 1. Initialize Swarm
On the manager node:
```bash
cd deploy/scripts
sudo ./init-swarm.sh
```
This script will:
- Initialize Docker Swarm
- Create overlay networks
- Label nodes for service placement
- Create required directories
### 2. Join Worker Nodes
On each worker node, run the join command displayed by the init script:
```bash
docker swarm join --token SWMTKN-1-xxx... manager-ip:2377
```
### 3. Verify Swarm
```bash
docker node ls
```
## Application Configuration
### 1. Environment Setup
```bash
# Copy environment template
cp .env.production.example .env.production
# Edit with your values
nano .env.production
```
Required configurations:
- Domain names for frontend, backend, and services
- SMTP credentials for email
- Database passwords
- JWT secrets
### 2. Create Docker Secrets
```bash
cd deploy/scripts
./create-secrets.sh
```
This will create all required secrets in Docker Swarm. Save the generated passwords!
## Traefik Setup
### 1. Deploy Traefik
```bash
cd deploy/traefik
# Create traefik network
docker network create --driver overlay traefik-public
# Deploy Traefik stack
docker stack deploy -c docker-compose.traefik.yml traefik
```
### 2. Verify Traefik
```bash
# Check service status
docker service ls | grep traefik
# View logs
docker service logs traefik_traefik
```
Access Traefik dashboard at: `https://traefik.yourdomain.com/dashboard/`
## Application Deployment
### 1. Build Images (if using local registry)
```bash
# Build frontend
cd frontend
docker build -t photo-sharing-frontend:latest .
# Build backend
cd ../backend
docker build -t photo-sharing-backend:latest .
```
### 2. Deploy Application Stack
```bash
cd deploy/scripts
./deploy.sh
```
Options:
- `--env FILE`: Specify environment file
- `--registry URL`: Docker registry URL
- `--version VERSION`: Image version to deploy
### 3. Verify Deployment
```bash
# Check all services
docker service ls
# Check specific service
docker service ps photo-sharing_backend
# View logs
docker service logs photo-sharing_backend -f
```
### 4. Run Database Migrations
The deploy script automatically runs migrations, but you can run manually:
```bash
docker exec $(docker ps -q -f name=photo-sharing_backend) npm run migrate
```
## CI/CD with Drone
### 1. Drone Server Setup
Deploy Drone server on your CI infrastructure:
```bash
docker run \
--volume=/var/lib/drone:/data \
--env=DRONE_GITHUB_CLIENT_ID=your-id \
--env=DRONE_GITHUB_CLIENT_SECRET=your-secret \
--env=DRONE_RPC_SECRET=your-rpc-secret \
--env=DRONE_SERVER_HOST=drone.yourdomain.com \
--env=DRONE_SERVER_PROTO=https \
--publish=80:80 \
--publish=443:443 \
--restart=always \
--detach=true \
--name=drone \
drone/drone:2
```
### 2. Drone Runner Setup
On build servers:
```bash
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-e DRONE_RPC_PROTO=https \
-e DRONE_RPC_HOST=drone.yourdomain.com \
-e DRONE_RPC_SECRET=your-rpc-secret \
-e DRONE_RUNNER_CAPACITY=2 \
-e DRONE_RUNNER_NAME=runner-1 \
-p 3000:3000 \
--restart always \
--name runner \
drone/drone-runner-docker:1
```
### 3. Repository Setup
1. Enable repository in Drone UI
2. Add secrets in Drone:
- `docker_username`
- `docker_password`
- `docker_registry`
- `staging_swarm_host`
- `staging_swarm_user`
- `staging_swarm_key`
- `prod_swarm_host`
- `prod_swarm_user`
- `prod_swarm_key`
- `slack_webhook`
### 4. Deployment Workflow
- Push to `develop` → Deploy to staging
- Create tag → Deploy to production
- Automatic rollback on failure
## Monitoring
### 1. Deploy Monitoring Stack
```bash
cd deploy/monitoring
# Deploy monitoring services
docker stack deploy -c docker-compose.monitoring.yml monitoring
```
### 2. Access Services
- Grafana: `https://grafana.yourdomain.com`
- Prometheus: `https://prometheus.yourdomain.com`
- Alertmanager: `https://alerts.yourdomain.com`
### 3. Configure Alerts
Create alert rules in `deploy/monitoring/alerts/`:
```yaml
groups:
- name: photo-sharing
rules:
- alert: ServiceDown
expr: up{job="photo-sharing-backend"} == 0
for: 5m
annotations:
summary: "Photo sharing backend is down"
```
## Backup and Recovery
### 1. Automated Backups
Set up cron job for automated backups:
```bash
# Edit crontab
crontab -e
# Add daily backup at 2 AM
0 2 * * * /opt/photo-sharing/deploy/scripts/backup.sh
```
### 2. Manual Backup
```bash
cd deploy/scripts
./backup.sh
```
### 3. Restore from Backup
```bash
# Extract backup
tar -xzf backup-20240615-020000.tar.gz
# Restore database
docker exec -i $(docker ps -q -f name=photo-sharing_db) \
psql -U postgres photo_sharing < backup-20240615-020000/database.sql
# Restore photos
tar -xzf backup-20240615-020000/photos.tar.gz -C /opt/photo-sharing/
# Restore volumes
docker run --rm \
-v photo-sharing_app-data:/data \
-v $(pwd)/backup-20240615-020000:/backup \
alpine tar -xzf /backup/volume-photo-sharing_app-data.tar.gz -C /data
```
## Maintenance
### 1. Scaling Services
```bash
# Scale backend to 5 replicas
docker service scale photo-sharing_backend=5
# Scale frontend to 3 replicas
docker service scale photo-sharing_frontend=3
```
### 2. Rolling Updates
```bash
# Update backend image
docker service update \
--image registry.yourdomain.com/photo-sharing-backend:v2.0 \
photo-sharing_backend
```
### 3. Drain Node for Maintenance
```bash
# Drain node
docker node update --availability drain worker-1
# Perform maintenance...
# Activate node
docker node update --availability active worker-1
```
## Troubleshooting
### Common Issues
#### 1. Service Won't Start
```bash
# Check service status
docker service ps photo-sharing_backend --no-trunc
# View detailed logs
docker service logs photo-sharing_backend --details
```
#### 2. Database Connection Issues
```bash
# Check database logs
docker service logs photo-sharing_db
# Test connection
docker exec $(docker ps -q -f name=photo-sharing_db) \
pg_isready -U postgres
```
#### 3. Traefik Certificate Issues
```bash
# Check Traefik logs
docker service logs traefik_traefik | grep acme
# Remove and regenerate certificates
rm -rf /opt/traefik/letsencrypt/acme.json
docker service update --force traefik_traefik
```
#### 4. Storage Issues
```bash
# Check disk usage
df -h
# Clean up Docker
docker system prune -a
```
### Debug Mode
Enable debug logging:
```bash
# Update service with debug logging
docker service update \
--env-add LOG_LEVEL=debug \
photo-sharing_backend
```
### Health Checks
```bash
# Check all endpoints
curl -f https://photos.yourdomain.com/health
curl -f https://api.photos.yourdomain.com/api/health
curl -f https://traefik.yourdomain.com/ping
```
## Security Best Practices
1. **Regular Updates**
- Keep Docker and system packages updated
- Update application dependencies regularly
- Monitor security advisories
2. **Access Control**
- Use strong passwords for all services
- Enable 2FA where possible
- Restrict SSH access to specific IPs
- Use Docker secrets for sensitive data
3. **Network Security**
- Use internal networks for service communication
- Enable firewall rules
- Use TLS for all external communication
- Regular security scans with Trivy
4. **Backup Security**
- Encrypt backups at rest
- Test restore procedures regularly
- Store backups in multiple locations
- Rotate old backups
## Performance Tuning
1. **Database Optimization**
```sql
-- Add indexes for common queries
CREATE INDEX idx_photos_event_id ON photos(event_id);
CREATE INDEX idx_access_logs_event_id ON access_logs(event_id);
```
2. **Image Optimization**
- Use CDN for static assets
- Enable aggressive caching
- Optimize image sizes before upload
3. **Service Limits**
```yaml
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
```
## Support
For issues and questions:
- Check logs: `docker service logs <service_name>`
- Review documentation: [README.md](README.md)
- Check monitoring dashboards
- Contact: admin@yourdomain.com
+130
View File
@@ -0,0 +1,130 @@
# 🚀 Quick Local Development Setup
Get the photo sharing platform running locally in under 2 minutes!
## Prerequisites
- Docker Desktop installed and running
- Git
- 4GB RAM available
## Quick Start
```bash
# 1. Clone the repository
git clone <your-repo-url>
cd wedding-photo-sharing
# 2. Start everything
./start-local.sh
```
That's it! 🎉
## What You Get
| Service | URL | Description |
|---------|-----|-------------|
| Frontend (Dev) | http://localhost:3002 | React app with hot reload |
| Frontend (Prod) | http://localhost:3000 | Production build |
| Backend API | http://localhost:3001 | Express API |
| Mailhog | http://localhost:8025 | Email testing UI |
## Default Credentials
- **Admin Login**: admin / admin123
- **Test Gallery**:
- Create via Admin Panel
- Password: test123
## Common Tasks
### View Logs
```bash
docker-compose -f docker-compose.local.yml logs -f
```
### Stop Everything
```bash
./stop-local.sh
```
### Reset Database
```bash
docker-compose -f docker-compose.local.yml exec backend npm run migrate
```
### Add Test Photos
1. Create a gallery in the admin panel
2. Get the gallery slug (e.g., `wedding-smith-2024`)
3. Add photos to: `./storage/events/active/wedding-smith-2024/`
4. Photos appear automatically!
### Access Backend Shell
```bash
docker-compose -f docker-compose.local.yml exec backend sh
```
## Development Workflow
1. **Frontend Development** (Port 3002)
- Hot reload enabled
- Edit files in `./frontend/src`
- Changes appear instantly
2. **Backend Development** (Port 3001)
- Nodemon watches for changes
- Edit files in `./backend/src`
- Server restarts automatically
3. **Email Testing**
- All emails go to Mailhog
- View at http://localhost:8025
- No real emails sent!
## Troubleshooting
### Backend won't start
```bash
# Check logs
docker-compose -f docker-compose.local.yml logs backend
# Rebuild
docker-compose -f docker-compose.local.yml build backend
```
### Frontend build issues
```bash
# Clear cache and rebuild
docker-compose -f docker-compose.local.yml exec frontend-dev npm run build
```
### Port conflicts
Edit `docker-compose.local.yml` and change the port mappings:
- Backend: Change `3001:3000` to `XXXX:3000`
- Frontend: Change `3002:5173` to `YYYY:5173`
### Reset everything
```bash
# Stop and remove all data
docker-compose -f docker-compose.local.yml down -v
rm -rf data storage logs
./start-local.sh
```
## Tips
- 📧 Check Mailhog for all emails
- 🔄 Frontend auto-refreshes on save
- 📁 SQLite DB at `./data/photo_sharing.db`
- 🖼️ Photos in `./storage/events/active/`
- 📝 Logs in `./logs/`
## Next Steps
1. Create your first gallery via Admin Panel
2. Upload some test photos
3. Test the gallery with password
4. Check expiration warnings
5. View emails in Mailhog
Happy coding! 🎨
+31 -2
View File
@@ -1,3 +1,32 @@
# wedding-photo-sharing
# Photo Sharing Platform
Secure photo sharing platform for weddings and events with automatic expiration and email notifications
A secure, self-hosted photo sharing platform designed for weddings and events. Features automatic expiration, email notifications, and simple file-based management.
## Features
- 🔒 Password Protected Galleries
- ⏰ Automatic Expiration
- 📧 Email Notifications
- 📁 Simple File Management
- 📊 Analytics Integration
- 🎨 Customizable Themes
- 📱 Mobile Responsive
- ⚡ Docker Ready
## Quick Start
1. Clone the repository
2. Run `./scripts/install.sh`
3. Configure `.env` file
4. Setup SSL: `./scripts/setup-ssl.sh`
5. Start: `docker-compose -f docker-compose.prod.yml up -d`
Default credentials: admin / admin123 (change immediately!)
## Documentation
See DEPLOYMENT.md for detailed deployment instructions.
## License
MIT License
+37
View File
@@ -0,0 +1,37 @@
const bcrypt = require('bcrypt');
const { db, initializeDatabase } = require('../src/database/db');
async function runMigrations() {
console.log('Running database migrations...');
try {
// Initialize tables
await initializeDatabase();
// Create default admin user if none exists
const adminExists = await db('admin_users').first();
if (!adminExists) {
const defaultPassword = 'admin123'; // Change this!
const passwordHash = await bcrypt.hash(defaultPassword, 10);
await db('admin_users').insert({
username: 'admin',
email: 'admin@example.com',
password_hash: passwordHash
});
console.log('Default admin user created:');
console.log('Username: admin');
console.log('Password: admin123');
console.log('⚠️ Please change this password immediately!');
}
console.log('Migrations completed successfully');
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
runMigrations();
+28
View File
@@ -0,0 +1,28 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const THUMBNAIL_WIDTH = 300;
const THUMBNAIL_PATH = path.join(__dirname, '../../../storage/thumbnails');
async function generateThumbnail(imagePath) {
const filename = path.basename(imagePath);
const thumbnailFilename = `thumb_${filename}`;
const thumbnailPath = path.join(THUMBNAIL_PATH, thumbnailFilename);
// Ensure thumbnail directory exists
await fs.mkdir(THUMBNAIL_PATH, { recursive: true });
// Generate thumbnail
await sharp(imagePath)
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return path.relative(path.join(__dirname, '../../../storage'), thumbnailPath);
}
module.exports = { generateThumbnail };
+31
View File
@@ -0,0 +1,31 @@
const winston = require('winston');
const path = require('path');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({
filename: path.join(__dirname, '../../../logs/error.log'),
level: 'error'
}),
new winston.transports.File({
filename: path.join(__dirname, '../../../logs/combined.log')
})
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;
View File
+265
View File
@@ -0,0 +1,265 @@
version: '3.8'
services:
backend:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
networks:
- photo-sharing
- traefik-public
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- ADMIN_URL=${ADMIN_URL}
- FRONTEND_URL=${FRONTEND_URL}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER_FILE=/run/secrets/smtp_user
- SMTP_PASS_FILE=/run/secrets/smtp_pass
- EMAIL_FROM=${EMAIL_FROM}
- UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=${DB_NAME:-photo_sharing}
- DB_USER_FILE=/run/secrets/db_user
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
- jwt_secret
- smtp_user
- smtp_pass
- db_user
- db_password
volumes:
- photo-storage:/app/storage
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
failure_action: rollback
max_failure_ratio: 0.3
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
- "traefik.http.routers.backend.rule=Host(`${BACKEND_HOST}`) && PathPrefix(`/api`)"
- "traefik.http.routers.backend.entrypoints=https"
- "traefik.http.routers.backend.tls=true"
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
- "traefik.http.services.backend.loadbalancer.server.port=3000"
- "traefik.http.services.backend.loadbalancer.healthcheck.path=/api/health"
- "traefik.http.services.backend.loadbalancer.healthcheck.interval=10s"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
image: ${REGISTRY_URL}/photo-sharing-frontend:${VERSION:-latest}
networks:
- photo-sharing
- traefik-public
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
failure_action: rollback
restart_policy:
condition: on-failure
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.1'
memory: 64M
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
- "traefik.http.routers.frontend.rule=Host(`${FRONTEND_HOST}`)"
- "traefik.http.routers.frontend.entrypoints=https"
- "traefik.http.routers.frontend.tls=true"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
- "traefik.http.middlewares.frontend-compress.compress=true"
- "traefik.http.routers.frontend.middlewares=frontend-compress"
db:
image: postgres:14-alpine
networks:
- photo-sharing
environment:
- POSTGRES_USER_FILE=/run/secrets/db_user
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- POSTGRES_DB=${DB_NAME:-photo_sharing}
secrets:
- db_user
- db_password
volumes:
- postgres-data:/var/lib/postgresql/data
deploy:
placement:
constraints:
- node.labels.db == true
restart_policy:
condition: on-failure
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Background workers as separate services for better control
email-worker:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
command: ["node", "src/services/emailService.js"]
networks:
- photo-sharing
environment:
- NODE_ENV=production
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER_FILE=/run/secrets/smtp_user
- SMTP_PASS_FILE=/run/secrets/smtp_pass
- EMAIL_FROM=${EMAIL_FROM}
secrets:
- jwt_secret
- smtp_user
- smtp_pass
volumes:
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
resources:
limits:
cpus: '0.5'
memory: 256M
expiration-checker:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
command: ["node", "src/services/expirationChecker.js"]
networks:
- photo-sharing
environment:
- NODE_ENV=production
volumes:
- photo-storage:/app/storage
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
resources:
limits:
cpus: '0.5'
memory: 256M
archive-worker:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
command: ["node", "src/services/archiveService.js"]
networks:
- photo-sharing
environment:
- NODE_ENV=production
volumes:
- photo-storage:/app/storage
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
resources:
limits:
cpus: '1'
memory: 512M
# Umami Analytics
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
networks:
- photo-sharing
- traefik-public
environment:
DATABASE_URL: postgresql://umami:${UMAMI_DB_PASSWORD}@db:5432/umami
DATABASE_TYPE: postgresql
HASH_SALT: ${UMAMI_HASH_SALT}
depends_on:
- db
deploy:
replicas: 1
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
- "traefik.http.routers.umami.rule=Host(`${UMAMI_HOST}`)"
- "traefik.http.routers.umami.entrypoints=https"
- "traefik.http.routers.umami.tls=true"
- "traefik.http.routers.umami.tls.certresolver=letsencrypt"
- "traefik.http.services.umami.loadbalancer.server.port=3000"
networks:
photo-sharing:
driver: overlay
attachable: true
traefik-public:
external: true
volumes:
postgres-data:
driver: local
photo-storage:
driver: local
app-data:
driver: local
app-logs:
driver: local
secrets:
jwt_secret:
external: true
smtp_user:
external: true
smtp_pass:
external: true
db_user:
external: true
db_password:
external: true
@@ -0,0 +1,189 @@
version: '3.8'
services:
prometheus:
image: prom/prometheus:latest
networks:
- monitoring
- traefik-public
volumes:
- prometheus-data:/prometheus
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
- '--storage.tsdb.retention.time=30d'
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 1G
cpus: '1'
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.prometheus.rule=Host(`prometheus.${DOMAIN}`)"
- "traefik.http.routers.prometheus.entrypoints=https"
- "traefik.http.routers.prometheus.tls=true"
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
- "traefik.http.routers.prometheus.middlewares=admin-auth"
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
grafana:
image: grafana/grafana:latest
networks:
- monitoring
- traefik-public
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=https://grafana.${DOMAIN}
- GF_SMTP_ENABLED=true
- GF_SMTP_HOST=${SMTP_HOST}:${SMTP_PORT}
- GF_SMTP_USER=${SMTP_USER}
- GF_SMTP_PASSWORD=${SMTP_PASS}
- GF_SMTP_FROM_ADDRESS=${EMAIL_FROM}
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 512M
cpus: '0.5'
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.grafana.rule=Host(`grafana.${DOMAIN}`)"
- "traefik.http.routers.grafana.entrypoints=https"
- "traefik.http.routers.grafana.tls=true"
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
loki:
image: grafana/loki:latest
networks:
- monitoring
volumes:
- loki-data:/loki
- ./loki-config.yml:/etc/loki/config.yml:ro
command: -config.file=/etc/loki/config.yml
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 1G
cpus: '1'
promtail:
image: grafana/promtail:latest
networks:
- monitoring
volumes:
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- ./promtail-config.yml:/etc/promtail/config.yml:ro
command: -config.file=/etc/promtail/config.yml
deploy:
mode: global
resources:
limits:
memory: 256M
cpus: '0.25'
node-exporter:
image: prom/node-exporter:latest
networks:
- monitoring
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
deploy:
mode: global
resources:
limits:
memory: 128M
cpus: '0.1'
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
networks:
- monitoring
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
privileged: true
deploy:
mode: global
resources:
limits:
memory: 256M
cpus: '0.25'
alertmanager:
image: prom/alertmanager:latest
networks:
- monitoring
- traefik-public
volumes:
- alertmanager-data:/alertmanager
- ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 256M
cpus: '0.25'
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.alertmanager.rule=Host(`alerts.${DOMAIN}`)"
- "traefik.http.routers.alertmanager.entrypoints=https"
- "traefik.http.routers.alertmanager.tls=true"
- "traefik.http.routers.alertmanager.tls.certresolver=letsencrypt"
- "traefik.http.routers.alertmanager.middlewares=admin-auth"
- "traefik.http.services.alertmanager.loadbalancer.server.port=9093"
networks:
monitoring:
external: true
traefik-public:
external: true
volumes:
prometheus-data:
driver: local
grafana-data:
driver: local
loki-data:
driver: local
alertmanager-data:
driver: local
+65
View File
@@ -0,0 +1,65 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'photo-sharing'
environment: 'production'
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
rule_files:
- '/etc/prometheus/alerts/*.yml'
scrape_configs:
# Prometheus itself
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Node Exporter
- job_name: 'node-exporter'
dns_sd_configs:
- names:
- 'tasks.node-exporter'
type: 'A'
port: 9100
# Docker containers
- job_name: 'cadvisor'
dns_sd_configs:
- names:
- 'tasks.cadvisor'
type: 'A'
port: 8080
# Traefik
- job_name: 'traefik'
static_configs:
- targets: ['traefik:8082']
# Photo Sharing Backend
- job_name: 'photo-sharing-backend'
dns_sd_configs:
- names:
- 'tasks.photo-sharing_backend'
type: 'A'
port: 3000
metrics_path: '/api/metrics'
# PostgreSQL
- job_name: 'postgres'
static_configs:
- targets: ['photo-sharing_db:9187']
# Loki
- job_name: 'loki'
static_configs:
- targets: ['loki:3100']
# Grafana
- job_name: 'grafana'
static_configs:
- targets: ['grafana:3000']
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Photo Sharing Platform Backup Script${NC}"
echo "===================================="
# Configuration
BACKUP_DIR="/opt/photo-sharing/backup"
STACK_NAME="photo-sharing"
RETENTION_DAYS=30
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_NAME="backup-${TIMESTAMP}"
# Create backup directory
mkdir -p $BACKUP_DIR/$BACKUP_NAME
# Function to check if service is running
check_service() {
local service=$1
if docker service ps ${STACK_NAME}_${service} --format "{{.CurrentState}}" | grep -q "Running"; then
return 0
else
return 1
fi
}
# Backup database
echo -e "${GREEN}Backing up database...${NC}"
if check_service "db"; then
DB_CONTAINER=$(docker ps -q -f name=${STACK_NAME}_db -f status=running | head -1)
if [ ! -z "$DB_CONTAINER" ]; then
docker exec $DB_CONTAINER pg_dumpall -U postgres > $BACKUP_DIR/$BACKUP_NAME/database.sql
echo -e "${GREEN}Database backup completed${NC}"
else
echo -e "${RED}Database container not found${NC}"
fi
else
echo -e "${YELLOW}Database service not running, skipping...${NC}"
fi
# Backup photos
echo -e "${GREEN}Backing up photos...${NC}"
if [ -d "/opt/photo-sharing/storage" ]; then
tar -czf $BACKUP_DIR/$BACKUP_NAME/photos.tar.gz -C /opt/photo-sharing storage/
echo -e "${GREEN}Photos backup completed${NC}"
else
echo -e "${YELLOW}Photos directory not found, skipping...${NC}"
fi
# Backup application data
echo -e "${GREEN}Backing up application data...${NC}"
if [ -d "/opt/photo-sharing/data" ]; then
tar -czf $BACKUP_DIR/$BACKUP_NAME/app-data.tar.gz -C /opt/photo-sharing data/
echo -e "${GREEN}Application data backup completed${NC}"
else
echo -e "${YELLOW}Application data directory not found, skipping...${NC}"
fi
# Backup Docker volumes
echo -e "${GREEN}Backing up Docker volumes...${NC}"
for volume in $(docker volume ls -q | grep ${STACK_NAME}); do
echo "Backing up volume: $volume"
docker run --rm \
-v $volume:/data \
-v $BACKUP_DIR/$BACKUP_NAME:/backup \
alpine tar -czf /backup/volume-${volume}.tar.gz -C /data .
done
# Backup configurations
echo -e "${GREEN}Backing up configurations...${NC}"
if [ -f "../../.env.production" ]; then
cp ../../.env.production $BACKUP_DIR/$BACKUP_NAME/
fi
# Export Docker secrets (encrypted)
echo -e "${GREEN}Exporting Docker secrets info...${NC}"
docker secret ls --filter "label=com.docker.stack.namespace=$STACK_NAME" > $BACKUP_DIR/$BACKUP_NAME/secrets-list.txt
# Create backup manifest
echo -e "${GREEN}Creating backup manifest...${NC}"
cat > $BACKUP_DIR/$BACKUP_NAME/manifest.json << EOF
{
"timestamp": "$TIMESTAMP",
"stack_name": "$STACK_NAME",
"hostname": "$(hostname)",
"docker_version": "$(docker version --format '{{.Server.Version}}')",
"services": $(docker service ls --filter "label=com.docker.stack.namespace=$STACK_NAME" --format '{{json .}}' | jq -s .),
"backup_contents": [
"database.sql",
"photos.tar.gz",
"app-data.tar.gz",
"volume-*.tar.gz",
".env.production",
"secrets-list.txt"
]
}
EOF
# Compress entire backup
echo -e "${GREEN}Compressing backup...${NC}"
cd $BACKUP_DIR
tar -czf ${BACKUP_NAME}.tar.gz $BACKUP_NAME/
rm -rf $BACKUP_NAME/
# Upload to S3 (optional)
if [ ! -z "$S3_BACKUP_BUCKET" ] && command -v aws &> /dev/null; then
echo -e "${GREEN}Uploading to S3...${NC}"
aws s3 cp ${BACKUP_NAME}.tar.gz s3://${S3_BACKUP_BUCKET}/photo-sharing/
fi
# Clean up old backups
echo -e "${GREEN}Cleaning up old backups...${NC}"
find $BACKUP_DIR -name "backup-*.tar.gz" -mtime +$RETENTION_DAYS -delete
# Show backup summary
BACKUP_SIZE=$(du -h $BACKUP_DIR/${BACKUP_NAME}.tar.gz | cut -f1)
echo ""
echo -e "${GREEN}Backup completed successfully!${NC}"
echo -e "Backup file: $BACKUP_DIR/${BACKUP_NAME}.tar.gz"
echo -e "Backup size: $BACKUP_SIZE"
echo -e "Retention: $RETENTION_DAYS days"
# Verify backup
echo ""
echo -e "${GREEN}Verifying backup...${NC}"
tar -tzf $BACKUP_DIR/${BACKUP_NAME}.tar.gz | head -10
echo "..."
echo -e "${GREEN}Backup verification complete${NC}"
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Docker Secrets Creation Script${NC}"
echo "==============================="
# Function to create or update a secret
create_secret() {
local secret_name=$1
local secret_value=$2
# Check if secret exists
if docker secret ls | grep -q $secret_name; then
echo -e "${YELLOW}Secret '$secret_name' already exists. Skipping...${NC}"
else
echo "$secret_value" | docker secret create $secret_name -
echo -e "${GREEN}Created secret: $secret_name${NC}"
fi
}
# Function to generate random password
generate_password() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-25
}
# Check if in swarm mode
if ! docker info | grep -q "Swarm: active"; then
echo -e "${RED}Docker is not in swarm mode. Run init-swarm.sh first.${NC}"
exit 1
fi
# Load environment variables if .env.production exists
if [ -f "../../.env.production" ]; then
echo -e "${GREEN}Loading environment variables from .env.production${NC}"
source ../../.env.production
fi
# JWT Secret
if [ -z "$JWT_SECRET" ]; then
JWT_SECRET=$(generate_password)
echo -e "${YELLOW}Generated JWT_SECRET: $JWT_SECRET${NC}"
fi
create_secret "jwt_secret" "$JWT_SECRET"
# Database credentials
if [ -z "$DB_USER" ]; then
DB_USER="photoapp"
fi
if [ -z "$DB_PASSWORD" ]; then
DB_PASSWORD=$(generate_password)
echo -e "${YELLOW}Generated DB_PASSWORD: $DB_PASSWORD${NC}"
fi
create_secret "db_user" "$DB_USER"
create_secret "db_password" "$DB_PASSWORD"
# SMTP credentials
if [ -z "$SMTP_USER" ]; then
read -p "Enter SMTP username: " SMTP_USER
fi
if [ -z "$SMTP_PASS" ]; then
read -sp "Enter SMTP password: " SMTP_PASS
echo
fi
create_secret "smtp_user" "$SMTP_USER"
create_secret "smtp_pass" "$SMTP_PASS"
# Traefik dashboard auth (username:password)
if [ -z "$TRAEFIK_USER" ]; then
TRAEFIK_USER="admin"
fi
if [ -z "$TRAEFIK_PASSWORD" ]; then
TRAEFIK_PASSWORD=$(generate_password)
echo -e "${YELLOW}Generated TRAEFIK_PASSWORD: $TRAEFIK_PASSWORD${NC}"
fi
# Generate htpasswd format
TRAEFIK_AUTH=$(docker run --rm httpd:alpine htpasswd -nb $TRAEFIK_USER $TRAEFIK_PASSWORD)
create_secret "traefik_dashboard_auth" "$TRAEFIK_AUTH"
# OAuth secrets (optional)
if [ ! -z "$OAUTH_CLIENT_SECRET" ]; then
create_secret "oauth_client_secret" "$OAUTH_CLIENT_SECRET"
fi
if [ ! -z "$OAUTH_SECRET" ]; then
create_secret "oauth_secret" "$OAUTH_SECRET"
fi
# Drone CI secrets
if [ ! -z "$DRONE_RPC_SECRET" ]; then
create_secret "drone_rpc_secret" "$DRONE_RPC_SECRET"
fi
echo ""
echo -e "${GREEN}Secrets creation complete!${NC}"
echo ""
echo -e "${YELLOW}Important: Save these generated values in a secure location:${NC}"
echo "JWT_SECRET=$JWT_SECRET"
echo "DB_PASSWORD=$DB_PASSWORD"
echo "TRAEFIK_USER=$TRAEFIK_USER"
echo "TRAEFIK_PASSWORD=$TRAEFIK_PASSWORD"
echo ""
echo -e "${GREEN}Next steps:${NC}"
echo "1. Update .env.production with the generated values"
echo "2. Deploy Traefik: ./deploy-traefik.sh"
echo "3. Deploy the application: ./deploy.sh"
+196
View File
@@ -0,0 +1,196 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${GREEN}Photo Sharing Platform Deployment Script${NC}"
echo "========================================"
# Default values
STACK_NAME="photo-sharing"
ENV_FILE="../../.env.production"
REGISTRY_URL="${REGISTRY_URL:-}"
VERSION="${VERSION:-latest}"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--env)
ENV_FILE="$2"
shift 2
;;
--registry)
REGISTRY_URL="$2"
shift 2
;;
--version)
VERSION="$2"
shift 2
;;
--stack-name)
STACK_NAME="$2"
shift 2
;;
--help)
echo "Usage: $0 [options]"
echo "Options:"
echo " --env FILE Path to environment file (default: ../../.env.production)"
echo " --registry URL Docker registry URL"
echo " --version VERSION Image version to deploy (default: latest)"
echo " --stack-name NAME Stack name (default: photo-sharing)"
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
# Check if Docker is in swarm mode
if ! docker info | grep -q "Swarm: active"; then
echo -e "${RED}Docker is not in swarm mode. Run init-swarm.sh first.${NC}"
exit 1
fi
# Check if environment file exists
if [ ! -f "$ENV_FILE" ]; then
echo -e "${RED}Environment file not found: $ENV_FILE${NC}"
echo "Please create it from .env.production.example"
exit 1
fi
# Load environment variables
echo -e "${GREEN}Loading environment variables...${NC}"
set -a
source "$ENV_FILE"
set +a
# Export deployment variables
export REGISTRY_URL
export VERSION
# Validate required environment variables
required_vars=(
"FRONTEND_HOST"
"BACKEND_HOST"
"ADMIN_URL"
"FRONTEND_URL"
"ACME_EMAIL"
"DB_NAME"
"EMAIL_FROM"
)
echo -e "${GREEN}Validating configuration...${NC}"
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo -e "${RED}Missing required environment variable: $var${NC}"
exit 1
fi
done
# Check if Traefik is running
if ! docker service ls | grep -q "traefik_traefik"; then
echo -e "${YELLOW}Traefik is not running. Deploy it first with:${NC}"
echo "cd ../traefik && docker stack deploy -c docker-compose.traefik.yml traefik"
exit 1
fi
# Check if secrets exist
echo -e "${GREEN}Checking Docker secrets...${NC}"
required_secrets=(
"jwt_secret"
"db_user"
"db_password"
"smtp_user"
"smtp_pass"
)
for secret in "${required_secrets[@]}"; do
if ! docker secret ls | grep -q $secret; then
echo -e "${RED}Missing required secret: $secret${NC}"
echo "Run create-secrets.sh first"
exit 1
fi
done
# Pull latest images if registry is specified
if [ ! -z "$REGISTRY_URL" ]; then
echo -e "${GREEN}Pulling latest images...${NC}"
docker pull ${REGISTRY_URL}/photo-sharing-backend:${VERSION} || true
docker pull ${REGISTRY_URL}/photo-sharing-frontend:${VERSION} || true
fi
# Deploy the stack
echo -e "${GREEN}Deploying stack: $STACK_NAME${NC}"
echo -e "${BLUE}Version: $VERSION${NC}"
echo -e "${BLUE}Registry: ${REGISTRY_URL:-local}${NC}"
cd ..
docker stack deploy \
-c docker-stack.yml \
--with-registry-auth \
$STACK_NAME
# Wait for services to start
echo -e "${GREEN}Waiting for services to start...${NC}"
sleep 10
# Check service status
echo -e "${GREEN}Service status:${NC}"
docker service ls --filter "label=com.docker.stack.namespace=$STACK_NAME"
# Wait for database to be ready
echo -e "${GREEN}Waiting for database to be ready...${NC}"
max_attempts=30
attempt=1
while [ $attempt -le $max_attempts ]; do
if docker exec $(docker ps -q -f name=${STACK_NAME}_db) pg_isready -U postgres > /dev/null 2>&1; then
echo -e "${GREEN}Database is ready!${NC}"
break
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo -e "${RED}Database failed to start in time${NC}"
exit 1
fi
# Run database migrations
echo -e "${GREEN}Running database migrations...${NC}"
sleep 5
docker exec $(docker ps -q -f name=${STACK_NAME}_backend -f status=running | head -1) npm run migrate || {
echo -e "${YELLOW}Migration failed. This might be normal if migrations already ran.${NC}"
}
# Show deployment information
echo ""
echo -e "${GREEN}Deployment complete!${NC}"
echo ""
echo -e "${BLUE}Access URLs:${NC}"
echo "Frontend: https://${FRONTEND_HOST}"
echo "Backend API: https://${BACKEND_HOST}/api"
if [ ! -z "$UMAMI_HOST" ]; then
echo "Analytics: https://${UMAMI_HOST}"
fi
if [ ! -z "$TRAEFIK_HOST" ]; then
echo "Traefik Dashboard: https://${TRAEFIK_HOST}/dashboard/"
fi
echo ""
echo -e "${BLUE}Useful commands:${NC}"
echo "View logs: docker service logs ${STACK_NAME}_backend"
echo "Scale service: docker service scale ${STACK_NAME}_backend=5"
echo "Update service: docker service update ${STACK_NAME}_backend"
echo "Remove stack: docker stack rm $STACK_NAME"
echo ""
echo -e "${GREEN}Health check:${NC}"
curl -s -o /dev/null -w "Frontend: %{http_code}\n" https://${FRONTEND_HOST}/health || true
curl -s -o /dev/null -w "Backend: %{http_code}\n" https://${BACKEND_HOST}/api/health || true
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Docker Swarm Initialization Script${NC}"
echo "======================================"
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}This script must be run as root${NC}"
exit 1
fi
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}Docker is not installed. Please install Docker first.${NC}"
exit 1
fi
# Check if already in swarm mode
if docker info | grep -q "Swarm: active"; then
echo -e "${YELLOW}This node is already part of a swarm.${NC}"
docker node ls
exit 0
fi
# Initialize swarm
echo -e "${GREEN}Initializing Docker Swarm...${NC}"
ADVERTISE_ADDR=${1:-$(hostname -I | awk '{print $1}')}
docker swarm init --advertise-addr $ADVERTISE_ADDR
# Create overlay networks
echo -e "${GREEN}Creating overlay networks...${NC}"
docker network create --driver overlay --attachable traefik-public || true
docker network create --driver overlay --attachable monitoring || true
# Label the node
echo -e "${GREEN}Labeling manager node...${NC}"
NODE_ID=$(docker info -f '{{.Swarm.NodeID}}')
docker node update --label-add db=true $NODE_ID
docker node update --label-add monitoring=true $NODE_ID
# Create required directories
echo -e "${GREEN}Creating required directories...${NC}"
mkdir -p /opt/photo-sharing/{storage,data,logs,backup}
mkdir -p /opt/traefik/letsencrypt
mkdir -p /opt/monitoring/{prometheus,grafana,loki}
# Set permissions
chown -R 1000:1000 /opt/photo-sharing
chmod -R 755 /opt/photo-sharing
echo -e "${GREEN}Swarm initialization complete!${NC}"
echo ""
echo "Manager join token:"
docker swarm join-token manager
echo ""
echo "Worker join token:"
docker swarm join-token worker
echo ""
echo -e "${GREEN}Next steps:${NC}"
echo "1. Join worker nodes using the token above"
echo "2. Create secrets using create-secrets.sh"
echo "3. Deploy Traefik using deploy-traefik.sh"
echo "4. Deploy the application stack using deploy.sh"
+124
View File
@@ -0,0 +1,124 @@
version: '3.8'
services:
traefik:
image: traefik:v2.10
ports:
- target: 80
published: 80
mode: host
- target: 443
published: 443
mode: host
networks:
- traefik-public
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certificates:/letsencrypt
environment:
- TRAEFIK_API=true
- TRAEFIK_API_DASHBOARD=true
- TRAEFIK_API_DEBUG=false
- TRAEFIK_LOG_LEVEL=INFO
- TRAEFIK_PROVIDERS_DOCKER=true
- TRAEFIK_PROVIDERS_DOCKER_SWARMMODE=true
- TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT=false
- TRAEFIK_PROVIDERS_DOCKER_NETWORK=traefik-public
- TRAEFIK_ENTRYPOINTS_HTTP_ADDRESS=:80
- TRAEFIK_ENTRYPOINTS_HTTPS_ADDRESS=:443
# Redirect HTTP to HTTPS
- TRAEFIK_ENTRYPOINTS_HTTP_HTTP_REDIRECTIONS_ENTRYPOINT_TO=https
- TRAEFIK_ENTRYPOINTS_HTTP_HTTP_REDIRECTIONS_ENTRYPOINT_SCHEME=https
# Let's Encrypt
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${ACME_EMAIL}
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_STORAGE=/letsencrypt/acme.json
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE=true
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT=http
# Enable metrics
- TRAEFIK_METRICS_PROMETHEUS=true
- TRAEFIK_METRICS_PROMETHEUS_ENTRYPOINT=metrics
- TRAEFIK_ENTRYPOINTS_METRICS_ADDRESS=:8082
deploy:
mode: global
placement:
constraints:
- node.role == manager
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
# Dashboard
- "traefik.http.routers.traefik-dashboard.rule=Host(`${TRAEFIK_HOST}`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
- "traefik.http.routers.traefik-dashboard.entrypoints=https"
- "traefik.http.routers.traefik-dashboard.tls=true"
- "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik-dashboard.service=api@internal"
- "traefik.http.routers.traefik-dashboard.middlewares=admin-auth"
# Basic auth for dashboard
- "traefik.http.middlewares.admin-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
# Global redirect to https
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
# Security headers
- "traefik.http.middlewares.security-headers.headers.frameDeny=true"
- "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.security-headers.headers.browserXssFilter=true"
- "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.security-headers.headers.stsPreload=true"
# Rate limiting
- "traefik.http.middlewares.rate-limit.ratelimit.average=100"
- "traefik.http.middlewares.rate-limit.ratelimit.burst=50"
# API service
- "traefik.http.services.traefik.loadbalancer.server.port=8080"
healthcheck:
test: ["CMD", "traefik", "healthcheck"]
interval: 30s
timeout: 3s
retries: 3
# Traefik Forward Auth for advanced authentication (optional)
traefik-forward-auth:
image: thomseddon/traefik-forward-auth:latest
networks:
- traefik-public
environment:
- DEFAULT_PROVIDER=generic-oauth
- PROVIDERS_GENERIC_OAUTH_AUTH_URL=${OAUTH_AUTH_URL}
- PROVIDERS_GENERIC_OAUTH_TOKEN_URL=${OAUTH_TOKEN_URL}
- PROVIDERS_GENERIC_OAUTH_USER_URL=${OAUTH_USER_URL}
- PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}
- PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}
- SECRET=${OAUTH_SECRET}
- COOKIE_DOMAIN=${COOKIE_DOMAIN}
- INSECURE_COOKIE=false
- LOG_LEVEL=info
- URL_PATH=/_oauth
- WHITELIST=${OAUTH_WHITELIST}
deploy:
replicas: 2
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.traefik-forward-auth.rule=Host(`${FRONTEND_HOST}`) && PathPrefix(`/_oauth`)"
- "traefik.http.routers.traefik-forward-auth.entrypoints=https"
- "traefik.http.routers.traefik-forward-auth.tls=true"
- "traefik.http.routers.traefik-forward-auth.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik-forward-auth.middlewares=auth-verify"
- "traefik.http.services.traefik-forward-auth.loadbalancer.server.port=4181"
- "traefik.http.middlewares.auth-verify.forwardauth.address=http://traefik-forward-auth:4181"
- "traefik.http.middlewares.auth-verify.forwardauth.authResponseHeaders=X-Forwarded-User"
networks:
traefik-public:
external: true
volumes:
traefik-certificates:
driver: local
+93
View File
@@ -0,0 +1,93 @@
# Static configuration
global:
checkNewVersion: true
sendAnonymousUsage: false
api:
dashboard: true
debug: false
# Entry Points
entryPoints:
http:
address: ":80"
http:
redirections:
entryPoint:
to: https
scheme: https
priority: 1000
https:
address: ":443"
http:
tls:
certResolver: letsencrypt
domains:
- main: "${FRONTEND_HOST}"
- main: "${BACKEND_HOST}"
- main: "${UMAMI_HOST}"
forwardedHeaders:
trustedIPs:
- "127.0.0.1/32"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
metrics:
address: ":8082"
# Providers
providers:
docker:
swarmMode: true
exposedByDefault: false
network: traefik-public
watch: true
file:
directory: /etc/traefik/dynamic
watch: true
# Certificate Resolvers
certificatesResolvers:
letsencrypt:
acme:
email: ${ACME_EMAIL}
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: http
# Staging server for testing
# caServer: https://acme-staging-v02.api.letsencrypt.org/directory
# Logs
log:
level: INFO
format: json
accessLog:
format: json
filters:
statusCodes:
- "200-299"
- "400-499"
- "500-599"
retryAttempts: true
minDuration: "10ms"
# Metrics
metrics:
prometheus:
entryPoint: metrics
addEntryPointsLabels: true
addServicesLabels: true
buckets:
- 0.1
- 0.3
- 1.2
- 5.0
# Ping
ping:
entryPoint: traefik
# Pilot
pilot:
enabled: false
+99
View File
@@ -0,0 +1,99 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3000"
environment:
- NODE_ENV=development
- PORT=3000
- JWT_SECRET=local-dev-secret-key-123
- ADMIN_URL=http://localhost:3001
- FRONTEND_URL=http://localhost:3000
# Email - uses Mailhog
- SMTP_HOST=mailhog
- SMTP_PORT=1025
- SMTP_SECURE=false
- SMTP_USER=
- SMTP_PASS=
- EMAIL_FROM=noreply@photo-sharing.local
# Umami Analytics (optional)
- UMAMI_URL=
- UMAMI_WEBSITE_ID=
volumes:
- ./backend:/app
- /app/node_modules
- ./storage:/app/storage
- ./data:/app/data
- ./logs:/app/logs
depends_on:
- mailhog
command: sh -c "npm install && npm run migrate && npm run dev"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:80"
environment:
- NODE_ENV=development
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./frontend/nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# Development frontend with hot reload
frontend-dev:
image: node:18-alpine
working_dir: /app
ports:
- "3002:5173"
environment:
- NODE_ENV=development
volumes:
- ./frontend:/app
- /app/node_modules
command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host"
depends_on:
- backend
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
# Optional: File watcher service
file-watcher:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- NODE_ENV=development
volumes:
- ./backend:/app
- /app/node_modules
- ./storage:/app/storage
- ./data:/app/data
command: node src/services/fileWatcher.js
depends_on:
- backend
volumes:
node_modules_backend:
node_modules_frontend:
+18
View File
@@ -0,0 +1,18 @@
node_modules
dist
.git
.gitignore
.env*
.DS_Store
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vscode
.idea
*.swp
*.swo
README.md
.eslintcache
coverage
.nyc_output
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+54
View File
@@ -0,0 +1,54 @@
# Build stage
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --legacy-peer-deps
# Copy source files
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Install runtime dependencies
RUN apk add --no-cache curl
# Remove default nginx config
RUN rm -rf /etc/nginx/conf.d/*
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Create non-root user
RUN addgroup -g 101 -S nginx && \
adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx && \
chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
# Switch to non-root user
USER nginx
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+69
View File
@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always;
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache index.html with revalidation
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# API proxy
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
# Photo serving proxy
location /photos {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache photos
proxy_cache_valid 200 302 1d;
proxy_cache_valid 404 1m;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}
+34
View File
@@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
# API proxy to backend
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Photos proxy to backend
location /photos {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
+4876
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "photo-sharing-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.8.0",
"axios": "^1.3.2",
"@tanstack/react-query": "^5.0.0",
"date-fns": "^2.29.3",
"react-toastify": "^9.1.1",
"react-countdown": "^2.3.5",
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
"clsx": "^2.0.0",
"lucide-react": "^0.292.0",
"js-cookie": "^3.0.5"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/js-cookie": "^3.0.6",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^7.0.0",
"tailwindcss": "^3.3.0",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+76
View File
@@ -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;
+1
View File
@@ -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

+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';
@@ -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>
);
};
+4
View File
@@ -0,0 +1,4 @@
export { GalleryView } from './GalleryView';
export { PhotoGrid } from './PhotoGrid';
export { PhotoLightbox } from './PhotoLightbox';
export { ExpirationBanner } from './ExpirationBanner';
+79
View File
@@ -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>
);
};
+2
View File
@@ -0,0 +1,2 @@
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
+64
View File
@@ -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');
},
});
};
+140
View File
@@ -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;
}
}
+10
View File
@@ -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>,
)
+176
View File
@@ -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>
);
};
+35
View File
@@ -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);
},
};
+85
View File
@@ -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;
},
};
+56
View File
@@ -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;
},
};
+3
View File
@@ -0,0 +1,3 @@
export { authService } from './auth.service';
export { galleryService } from './gallery.service';
export { eventsService } from './events.service';
+94
View File
@@ -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;
}>;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+83
View File
@@ -0,0 +1,83 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#5C8762', // Main brand color from scrappbook.de
700: '#4a6f4f',
800: '#3f5d42',
900: '#365238',
},
sand: {
50: '#fdfcfb',
100: '#f7f5f2',
200: '#f0ebe5',
300: '#e6ddd4',
400: '#d4c2b0',
500: '#c2a68c',
600: '#b18b68',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
}
},
fontFamily: {
sans: ['Inter', 'Noto Sans', 'system-ui', '-apple-system', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
scaleIn: {
'0%': { transform: 'scale(0.95)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
},
spacing: {
'18': '4.5rem',
'88': '22rem',
},
borderRadius: {
'xl': '1rem',
'2xl': '1.25rem',
},
boxShadow: {
'soft': '0 2px 8px rgba(0, 0, 0, 0.04)',
'medium': '0 4px 16px rgba(0, 0, 0, 0.08)',
'large': '0 8px 32px rgba(0, 0, 0, 0.12)',
},
},
},
plugins: [],
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true,
},
'/photos': {
target: 'http://backend:3000',
changeOrigin: true,
},
},
},
})
View File
+40
View File
@@ -0,0 +1,40 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
include /etc/nginx/sites-enabled/*.conf;
}
+372
View File
@@ -0,0 +1,372 @@
# Product Requirements Document: Event Photo Sharing Platform
## 1. Executive Summary
### 1.1 Product Overview
A secure, customizable photo sharing platform designed primarily for wedding photo booths but adaptable for any event type. The platform enables event organizers to easily share photos with guests through password-protected, time-limited links while maintaining a simple file-based backend system with automatic archiving.
### 1.2 Key Value Propositions
- **Simple Backend Management**: Drop photos in folders, generate links instantly
- **Secure Sharing**: Password-protected access with expiration dates
- **Automated Lifecycle**: Automatic archiving and storage optimization
- **Proactive Communication**: Email notifications for key events
- **Personalized Experience**: Custom branding for each event
- **Analytics Integration**: Track engagement through Umami
- **Versatile Use Cases**: Optimized for weddings but suitable for any event
## 2. Product Goals & Objectives
### 2.1 Primary Goals
- Provide a seamless, time-limited photo sharing experience for event guests
- Minimize technical complexity for administrators
- Ensure photo privacy through password protection and link expiration
- Automate storage management through intelligent archiving
- Enable detailed analytics on photo access and engagement
- Keep stakeholders informed through automated notifications
### 2.2 Success Metrics
- Time to generate new event gallery (<2 minutes)
- Guest satisfaction score (>90%)
- Photo view/download rates
- System uptime (99.9%)
- Successful automatic archiving rate (100%)
- Email delivery rate (>98%)
## 3. User Personas
### 3.1 Administrator (Event Organizer/Photographer)
- **Background**: Professional photographer or event organizer
- **Technical Skills**: Basic to intermediate
- **Needs**: Quick photo upload, easy link generation, access analytics, automated cleanup
- **Pain Points**: Complex upload processes, managing multiple events, storage management
### 3.2 End User (Event Guest)
- **Background**: Wedding guest or event attendee
- **Technical Skills**: Varies widely
- **Needs**: Easy photo viewing, downloading, sharing within timeframe
- **Pain Points**: Complicated interfaces, slow loading, expired links
### 3.3 Event Host (Bride/Groom/Celebrant)
- **Background**: Person celebrating the event
- **Technical Skills**: Basic
- **Needs**: Notification of gallery availability, awareness of expiration
- **Pain Points**: Missing the opportunity to save photos, not knowing when gallery is ready
## 4. Functional Requirements
### 4.1 Backend Administration
#### 4.1.1 File Management System
- **Photo Upload**: Direct file system access via designated folders
- **Folder Structure**:
```
/events/
├── active/
│ ├── wedding-smith-jones-2024-06-15/
│ │ ├── collages/
│ │ │ ├── collage_001.jpg
│ │ │ └── collage_002.jpg
│ │ └── individual/
│ │ ├── photo_001.jpg
│ │ └── photo_002.jpg
│ └── birthday-emma-2024-07-20/
│ └── photos/
└── archived/
└── wedding-smith-jones-2024-06-15.zip
```
- **Supported Formats**: JPEG, PNG, WebP
- **Auto-detection**: System monitors folders for new photos
- **Automatic Archiving**: Upon expiration, compress folder to ZIP and move to archive
#### 4.1.2 Link Generation
- **Unique URL Generation**: Automatic creation of shareable links
- **Password Setting**: Admin sets password during link creation
- **Expiration Date**: Mandatory expiration date selection (default: 30 days)
- **Event Metadata**:
- Event type (wedding, birthday, corporate, etc.)
- Names (couple names for weddings, celebrant for others)
- Event date
- Host email address (for notifications)
- Admin notification email
- Custom welcome message
- Color theme selection
- Link validity period
#### 4.1.3 Email Notification System
- **Trigger Events**:
- Link creation: Notify host with access details
- Link expiration warning: 7 days before expiration
- Link expiration: Notify both host and admin
- Archive completion: Confirm to admin
- **Email Templates**: Customizable, branded email templates
- **Configuration**: SMTP settings, from address, reply-to address
#### 4.1.4 Admin Dashboard
- **Event Management**: List all events, active/inactive/archived status
- **Expiration Overview**: Timeline view of upcoming expirations
- **Analytics Overview**: Quick stats per event
- **Link Management**: Copy links, reset passwords, extend expiration, deactivate events
- **Bulk Operations**: Archive old events, batch photo operations
- **Email Configuration**: Template management, SMTP settings
- **Archive Management**: View and download archived ZIPs
### 4.2 Frontend Guest Experience
#### 4.2.1 Landing Page
- **Password Entry**: Clean, intuitive password input
- **Event Preview**: Show event name, date, and expiration notice
- **Expiration Warning**: Prominent display if <7 days remaining
- **Expired State**: Clear message with contact information if expired
- **Responsive Design**: Mobile-first approach
#### 4.2.2 Gallery View
- **Expiration Banner**: Sticky banner showing days remaining
- **Grid Layout**: Responsive photo grid with lazy loading
- **View Toggle**: Switch between collages and individual photos
- **Sorting Options**: By date, name, or custom order
- **Search**: Basic filename or date search
- **Download Urgency**: Prominent "Download All" for soon-to-expire galleries
#### 4.2.3 Photo Interactions
- **Lightbox View**: Full-screen photo viewing with navigation
- **Zoom**: Pinch-to-zoom on mobile, mouse wheel on desktop
- **Download Options**:
- Single photo download
- Bulk download (selected photos)
- Download all (ZIP file)
- **Sharing**: Direct link to specific photos (respects expiration)
#### 4.2.4 Personalization
- **Dynamic Theming**: Based on event type and admin preferences
- **Custom Headers**: Event names, dates, and messages
- **Branded Elements**: Optional logo upload
- **Expiration Messaging**: Customizable expiration notices
### 4.3 Analytics Integration
#### 4.3.1 Umami Analytics
- **Page Views**: Track gallery visits
- **User Actions**: Photo views, downloads, time spent
- **Device/Browser Stats**: Understand user base
- **Geographic Data**: Guest locations
- **Custom Events**:
- Password entries (successful/failed)
- Photo downloads
- Share button clicks
- Expiration warning views
- Last-minute download spikes
### 4.4 Archiving System
#### 4.4.1 Automatic Archiving Process
- **Trigger**: Activated upon link expiration
- **Process**:
1. Create ZIP file with folder structure preserved
2. Verify ZIP integrity
3. Move ZIP to archive location
4. Delete original files
5. Update database with archive location
6. Send confirmation emails
#### 4.4.2 Archive Management
- **Storage Optimization**: Compression settings for long-term storage
- **Retrieval System**: Admin can restore archives if needed
- **Retention Policy**: Configurable long-term retention rules
## 5. Technical Requirements
### 5.1 Architecture
#### 5.1.1 Infrastructure
- **Backend Access**: Dedicated FQDN (e.g., admin.photos.domain.com)
- **Frontend Access**: Public FQDN (e.g., photos.domain.com)
- **File Storage**: Local file system or network-attached storage
- **Archive Storage**: Separate location for long-term ZIP storage
- **Database**: Lightweight database for metadata (SQLite or PostgreSQL)
- **Email Service**: SMTP integration or email service provider
#### 5.1.2 Security
- **HTTPS**: Required for both frontend and backend
- **Password Hashing**: Bcrypt or similar for stored passwords
- **Rate Limiting**: Prevent brute force attacks
- **Access Logs**: Track all access attempts
- **Expiration Enforcement**: Server-side validation of link validity
### 5.2 Performance Requirements
- **Page Load Time**: <3 seconds on 4G connection
- **Image Optimization**: Automatic thumbnail generation
- **Caching**: CDN integration for static assets
- **Concurrent Users**: Support 100+ simultaneous users per event
- **Archive Generation**: Complete within 10 minutes for 1000 photos
### 5.3 Technology Stack (Recommended)
- **Backend**: Node.js with Express or Python with FastAPI
- **Frontend**: React or Vue.js for dynamic interactions
- **Image Processing**: Sharp (Node.js) or Pillow (Python)
- **File Monitoring**: Chokidar or Watchdog
- **Analytics**: Umami self-hosted or cloud
- **Email Service**: Nodemailer or SendGrid
- **Job Queue**: Bull (Node.js) or Celery (Python) for archiving tasks
- **Scheduler**: Node-cron or APScheduler for expiration checks
## 6. User Interface Requirements
### 6.1 Design Principles
- **Modern Aesthetic**: Clean, minimalist design
- **Wedding-Optimized**: Elegant typography, romantic color options
- **Urgency Communication**: Clear expiration indicators
- **Accessibility**: WCAG 2.1 AA compliant
- **Responsive**: Mobile, tablet, and desktop optimized
### 6.2 UI Components
- **Photo Grid**: Masonry or uniform grid layout
- **Navigation**: Sticky header with view toggles
- **Expiration Timer**: Countdown display for urgent galleries
- **Loading States**: Skeleton screens for better UX
- **Error Handling**: Friendly error messages
- **Email Status**: Indicators for sent notifications
### 6.3 Branding Options
- **Color Schemes**: Pre-defined themes plus custom colors
- **Font Selection**: Google Fonts integration
- **Layout Templates**: Multiple gallery layout options
- **Email Templates**: Matching email designs
## 7. Non-Functional Requirements
### 7.1 Scalability
- Horizontal scaling capability
- Support for 10,000+ photos per event
- Efficient handling of high-resolution images
- Queue system for archiving operations
### 7.2 Reliability
- 99.9% uptime SLA
- Automated backups (including archives)
- Graceful error handling
- Failed job retry mechanisms
### 7.3 Maintainability
- Clear code documentation
- Modular architecture
- Automated testing suite
- Monitoring for failed archiving jobs
### 7.4 Compliance
- GDPR compliance for EU users
- Copyright considerations
- Privacy policy and terms of service
- Data retention policies
## 8. Email Templates
### 8.1 Link Creation Email (to Host)
- Subject: "Your [Event Name] Photos Are Ready!"
- Content: Access details, password, expiration date
- Call-to-action: View gallery button
### 8.2 Expiration Warning Email
- Subject: "Your [Event Name] Photos Expire in 7 Days"
- Content: Urgency message, download instructions
- Call-to-action: Download all photos button
### 8.3 Expiration Notification Email
- To Host: "Your [Event Name] Photo Gallery Has Expired"
- To Admin: "[Event Name] Gallery Archived Successfully"
- Content: Confirmation of archiving, contact for retrieval
## 9. Future Enhancements
### 9.1 Phase 2 Features
- **Flexible Expiration**: Extend expiration for individual users
- **Partial Downloads**: Resume interrupted downloads
- **AI-Powered Features**: Face recognition for automatic grouping
- **Social Integration**: Direct sharing to social media
- **Guest Uploads**: Allow guests to add their photos
- **Video Support**: Basic video playback
### 9.2 Phase 3 Features
- **Mobile Apps**: Native iOS/Android applications
- **Print Integration**: Direct ordering of prints
- **Event Packages**: Bundled services with photographers
- **Multi-language Support**: Internationalization
- **Cloud Archive**: Optional cloud storage for archives
## 10. Success Criteria
### 10.1 Launch Criteria
- Successfully handle 10 concurrent events
- Process 1,000 photos in <5 minutes
- 100% successful archiving rate
- Achieve 95% positive user feedback in beta
- Email delivery rate >98%
### 10.2 Post-Launch Metrics
- Monthly active events: 100+
- Average photos per event: 200+
- Guest engagement rate: 70%+
- Download rate: 50%+ of guests
- On-time archiving: 99%+
## 11. Risks & Mitigation
### 11.1 Technical Risks
- **Storage Limitations**: Implement automated archiving and cloud storage
- **Performance Issues**: Progressive loading and CDN usage
- **Security Breaches**: Regular security audits
- **Archive Failures**: Redundant archiving with verification
- **Email Delivery**: Multiple SMTP providers, delivery monitoring
### 11.2 Business Risks
- **Low Adoption**: Marketing partnerships with photographers
- **Feature Creep**: Strict MVP scope adherence
- **Support Burden**: Comprehensive documentation and FAQs
- **Expired Link Complaints**: Clear communication, grace period
## 12. Timeline & Milestones
### 12.1 Development Phases
- **Phase 1 (MVP)**: 10-12 weeks
- Core functionality
- Basic UI
- Expiration system
- Email notifications
- Archiving system
- Umami integration
- **Phase 2 (Enhancement)**: 4-6 weeks
- Advanced features
- Performance optimization
- **Phase 3 (Polish)**: 2-4 weeks
- UI refinements
- Beta testing
### 12.2 Key Milestones
- Week 2: Technical architecture finalized
- Week 4: Backend functionality complete
- Week 6: Frontend gallery functional
- Week 7: Email system integrated
- Week 8: Archiving system complete
- Week 9: Analytics integrated
- Week 12: Beta launch
## 13. Appendices
### 13.1 Technical Specifications
- Detailed API documentation
- Database schema (including expiration tracking)
- File naming conventions
- Archive format specifications
### 13.2 Design Mockups
- UI wireframes
- Email template designs
- Expiration state displays
- Style guide
- Component library
### 13.3 Testing Plan
- Unit test coverage
- Integration testing
- Archiving system testing
- Email delivery testing
- User acceptance criteria
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
set -e
echo "Photo Sharing Platform - Docker Installation"
echo "==========================================="
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root"
exit 1
fi
# Function to check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Check prerequisites
echo "Checking prerequisites..."
# Install Docker if not present
if ! command_exists docker; then
echo "Installing Docker..."
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
rm get-docker.sh
fi
# Install Docker Compose if not present
if ! command_exists docker-compose; then
echo "Installing Docker Compose..."
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
fi
# Create necessary directories
echo "Creating directory structure..."
mkdir -p storage/events/{active,archived}
mkdir -p storage/thumbnails
mkdir -p data
mkdir -p logs
mkdir -p nginx/sites-enabled
mkdir -p certbot/{conf,www}
# Set permissions
chmod -R 755 storage
chmod -R 755 data
chmod -R 755 logs
# Copy environment file
if [ ! -f .env ]; then
cp .env.example .env
echo "Created .env file. Please edit it with your configuration."
fi
# Generate secure passwords
echo "Generating secure passwords..."
JWT_SECRET=$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -base64 32)
UMAMI_HASH_SALT=$(openssl rand -base64 32)
# Update .env file with generated values
sed -i "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=$DB_PASSWORD/" .env
sed -i "s/UMAMI_HASH_SALT=.*/UMAMI_HASH_SALT=$UMAMI_HASH_SALT/" .env
echo ""
echo "Installation complete!"
echo "Next steps:"
echo "1. Edit .env file with your domain names and SMTP settings"
echo "2. Run: ./scripts/setup-ssl.sh to configure SSL certificates"
echo "3. Run: docker-compose -f docker-compose.prod.yml up -d"
echo "4. Run: docker-compose -f docker-compose.prod.yml exec backend npm run migrate"
Regular → Executable
View File
Executable
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}🚀 Photo Sharing Platform - Local Development Setup${NC}"
echo "=================================================="
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}❌ Docker is not installed. Please install Docker Desktop first.${NC}"
echo " Visit: https://www.docker.com/products/docker-desktop"
exit 1
fi
# Check if Docker is running
if ! docker info &> /dev/null; then
echo -e "${RED}❌ Docker is not running. Please start Docker Desktop.${NC}"
exit 1
fi
# Create necessary directories
echo -e "${YELLOW}📁 Creating directories...${NC}"
mkdir -p storage/events/{active,archived}
mkdir -p storage/thumbnails
mkdir -p data
mkdir -p logs
mkdir -p backend/node_modules
mkdir -p frontend/node_modules
# Copy local environment file if it doesn't exist
if [ ! -f .env ]; then
echo -e "${YELLOW}📋 Setting up environment...${NC}"
cp .env.local .env
fi
# Stop any existing containers
echo -e "${YELLOW}🛑 Stopping existing containers...${NC}"
docker-compose -f docker-compose.local.yml down 2>/dev/null || true
# Build images
echo -e "${YELLOW}🔨 Building Docker images...${NC}"
docker-compose -f docker-compose.local.yml build
# Start services
echo -e "${YELLOW}🚀 Starting services...${NC}"
docker-compose -f docker-compose.local.yml up -d
# Wait for backend to be ready
echo -e "${YELLOW}⏳ Waiting for backend to start...${NC}"
max_attempts=30
attempt=1
while [ $attempt -le $max_attempts ]; do
if curl -s http://localhost:3001/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Backend is ready!${NC}"
break
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo -e "${RED}❌ Backend failed to start. Check logs with: docker-compose -f docker-compose.local.yml logs backend${NC}"
exit 1
fi
# Build frontend for production-like testing
echo -e "${YELLOW}📦 Building frontend...${NC}"
docker-compose -f docker-compose.local.yml exec frontend-dev npm run build
# Show status
echo ""
echo -e "${GREEN}✅ Local development environment is ready!${NC}"
echo ""
echo -e "${GREEN}🌐 Access Points:${NC}"
echo " Frontend (Production Build): http://localhost:3000"
echo " Frontend (Dev with Hot Reload): http://localhost:3002"
echo " Backend API: http://localhost:3001/api"
echo " Mailhog (Email Testing): http://localhost:8025"
echo ""
echo -e "${GREEN}🔑 Default Admin Credentials:${NC}"
echo " Username: admin"
echo " Password: admin123"
echo ""
echo -e "${GREEN}📝 Useful Commands:${NC}"
echo " View logs: docker-compose -f docker-compose.local.yml logs -f"
echo " Stop all: ./stop-local.sh"
echo " Backend shell: docker-compose -f docker-compose.local.yml exec backend sh"
echo " Reset database: docker-compose -f docker-compose.local.yml exec backend npm run migrate"
echo ""
echo -e "${GREEN}💡 Tips:${NC}"
echo " - Frontend dev server (port 3002) has hot reload enabled"
echo " - All emails are caught by Mailhog - check http://localhost:8025"
echo " - SQLite database is stored in ./data/photo_sharing.db"
echo " - Upload photos to ./storage/events/active/{event-name}/"
echo ""
# Open browser
if command -v xdg-open &> /dev/null; then
xdg-open http://localhost:3002
elif command -v open &> /dev/null; then
open http://localhost:3002
fi
# Show logs
echo -e "${YELLOW}📋 Showing logs (Ctrl+C to exit)...${NC}"
docker-compose -f docker-compose.local.yml logs -f
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${YELLOW}🛑 Stopping Photo Sharing Platform...${NC}"
# Stop all containers
docker-compose -f docker-compose.local.yml down
# Optional: Remove volumes (uncomment if you want to reset data)
# docker-compose -f docker-compose.local.yml down -v
echo -e "${GREEN}✅ All services stopped${NC}"
echo ""
echo -e "${YELLOW}💡 Tips:${NC}"
echo " - Your data is preserved in ./data and ./storage"
echo " - To completely reset, run: docker-compose -f docker-compose.local.yml down -v"
echo " - To restart, run: ./start-local.sh"
View File
View File
View File