Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped

Original: feat: enhance security logging and ensure rate limit blocks are properly tracked

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-18 19:25:15 +02:00
commit 1773ed5f95
354 changed files with 61508 additions and 0 deletions
@@ -0,0 +1,263 @@
# PicPeak Security Scan Report
**Date**: January 12, 2025
**Scan Type**: Comprehensive Security Audit
**Platform**: PicPeak Photo Sharing Platform
**Scanner**: Claude Code Security Scanner
## Executive Summary
A comprehensive security scan of the PicPeak photo sharing platform reveals **critical vulnerabilities** that require immediate attention. While the application implements some security best practices, several high-severity issues could lead to data breaches, unauthorized access, and system compromise.
### Overall Risk Assessment: **HIGH** 🔴
**Critical Issues Found**: 8
**High-Risk Issues**: 7
**Medium-Risk Issues**: 6
**Low-Risk Issues**: 2
## Critical Vulnerabilities Requiring Immediate Action
### 1. Hardcoded Secrets and Credentials 🔴
#### JWT Secret Fallback
- **Location**: `backend/src/routes/protectedImages.js:15,27`
- **Severity**: CRITICAL
- **Impact**: Complete authentication bypass if environment variable not set
```javascript
const secret = process.env.JWT_SECRET || 'your-secret-key'; // VULNERABLE
```
#### Default Admin Password
- **Location**: `backend/migrations/init.js:14`, `setup-remaining-files.sh:121`
- **Severity**: HIGH
- **Impact**: Known default credentials allow unauthorized admin access
- **Current**: Hardcoded `admin123` password
### 2. SQL Injection Vulnerabilities 🔴
#### Direct Template Literal Interpolation
- **Location**: `backend/src/routes/adminDashboard.js:214,221,227,252,269`
- **Severity**: HIGH
- **Impact**: Potential database compromise
```javascript
.whereRaw(`timestamp >= datetime("now", "-${days} days")`) // VULNERABLE
```
#### LIKE Query Injection
- **Locations**:
- `backend/src/routes/adminPhotos.js:476`
- `backend/src/routes/adminEvents.js:156-158`
- **Severity**: MEDIUM
- **Impact**: Query manipulation through special characters
### 3. Authentication & Authorization Flaws 🔴
#### Missing Token Type Validation
- **Location**: Admin middleware
- **Severity**: HIGH
- **Impact**: Gallery tokens could potentially access admin endpoints
#### Weak Password Requirements
- **Current**: Only 6 characters minimum
- **Severity**: MEDIUM
- **Impact**: Vulnerable to brute force attacks
#### Rate Limiting Bypass
- **Location**: `backend/server.js:57-73`
- **Severity**: HIGH
- **Impact**: Invalid JWT tokens bypass rate limiting
### 4. Cross-Site Scripting (XSS) 🔴
#### Stored XSS in CMS
- **Location**: `frontend/src/pages/public/LegalPage.tsx:106`
- **Severity**: CRITICAL
- **Impact**: Malicious scripts execute for all visitors
```tsx
dangerouslySetInnerHTML={{ __html: page.content }} // VULNERABLE
```
### 5. File Upload Vulnerabilities 🟡
#### Path Traversal Risk
- **Location**: `backend/server.js:104-110`
- **Severity**: HIGH
- **Impact**: Access to files outside intended directories
#### Insufficient MIME Type Validation
- **Multiple locations**
- **Severity**: MEDIUM
- **Impact**: Malicious file upload bypass
### 6. Security Headers & Configuration 🟡
#### Missing Critical Headers
- **Missing**: CSP, X-Frame-Options, Strict-Transport-Security
- **Severity**: MEDIUM
- **Impact**: Reduced defense against various attacks
#### Permissive CORS Configuration
- **Location**: `backend/server.js:30-49`
- **Severity**: MEDIUM
- **Impact**: Allows multiple origins including localhost
## Dependency Analysis
### NPM Audit Results ✅
- **Backend**: 0 vulnerabilities found
- **Frontend**: 0 vulnerabilities found
- **Status**: All dependencies are up to date
## Detailed Findings by Category
### Authentication Security
1. **JWT Implementation Issues**:
- No refresh token mechanism
- 24-hour token expiration for all types
- No token revocation capability
- Hardcoded fallback secret
2. **Session Management**:
- In-memory session storage (not scalable)
- No Redis implementation despite comments
- Incomplete session cleanup
3. **Password Security**:
- Weak requirements (6 chars minimum)
- Fixed bcrypt rounds (10)
- No password complexity requirements
- No breach checking
### Data Security
1. **SQL Injection Risks**:
- Template literal interpolation in whereRaw()
- Unescaped LIKE queries
- Missing input validation on some parameters
2. **XSS Vulnerabilities**:
- Stored XSS in CMS content
- No Content Security Policy
- Missing output encoding in some areas
3. **Information Disclosure**:
- Detailed error messages exposed
- Console.error statements with sensitive data
- No audit logging for security events
### Infrastructure Security
1. **File Upload Issues**:
- Path traversal vulnerability
- Weak MIME type validation
- No virus scanning
- Missing content validation
2. **Network Security**:
- Missing security headers
- Permissive CORS policy
- No HTTPS enforcement
- Rate limiting can be bypassed
## Recommended Fixes
### Priority 1: Critical (Implement Immediately)
1. **Remove Hardcoded Secrets**
```javascript
// Replace fallback with error
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET environment variable is required');
}
```
2. **Fix SQL Injection**
```javascript
// Use parameterized queries
.whereRaw('timestamp >= datetime("now", ? || " days")', [`-${days}`])
```
3. **Sanitize CMS Content**
```javascript
import DOMPurify from 'dompurify';
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(page.content) }}
```
### Priority 2: High (Implement Within 1 Week)
1. **Add Token Type Validation**
```javascript
if (decoded.type !== 'admin') {
return res.status(401).json({ error: 'Invalid token type' });
}
```
2. **Implement Security Headers**
```javascript
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}));
```
3. **Fix Rate Limiting Bypass**
```javascript
// Check token validity before skipping rate limit
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return decoded && decoded.type === 'admin';
} catch (err) {
return false; // Apply rate limiting on invalid tokens
}
```
### Priority 3: Medium (Implement Within 1 Month)
1. **Enhance Password Security**
- Minimum 12 characters
- Complexity requirements
- Breach checking integration
2. **Implement File Security**
- Content-based validation
- Path traversal protection
- Virus scanning
3. **Add Security Monitoring**
- Audit logging
- Failed login tracking
- Anomaly detection
## Security Checklist
- [ ] Remove all hardcoded secrets
- [ ] Fix SQL injection vulnerabilities
- [ ] Add XSS protection (DOMPurify)
- [ ] Implement proper token validation
- [ ] Add all security headers
- [ ] Fix rate limiting bypass
- [ ] Enhance password requirements
- [ ] Add file upload security
- [ ] Implement audit logging
- [ ] Set up security monitoring
- [ ] Document security procedures
- [ ] Conduct penetration testing
## Conclusion
The PicPeak platform has significant security vulnerabilities that need immediate attention. The most critical issues are hardcoded secrets, SQL injection risks, and stored XSS vulnerabilities. While the codebase shows some security awareness (bcrypt hashing, JWT usage, input validation), the implementation has serious flaws that could lead to system compromise.
**Recommended Action**: Address all critical vulnerabilities immediately before deploying to production. Consider a professional security audit after implementing these fixes.
---
*Generated by Claude Code Security Scanner*
*Scan completed: 2025-01-12*
+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
+11
View File
@@ -0,0 +1,11 @@
# Backend API URL
VITE_API_URL=http://localhost:3000
# Umami Analytics Configuration
# Get these values from your Umami installation
VITE_UMAMI_URL=https://analytics.yourdomain.com
VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
# Optional: Umami share URL for embedding full dashboard
# This is the public share URL from Umami's share feature
VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
+14
View File
@@ -0,0 +1,14 @@
# Production Environment Configuration
# When running behind a reverse proxy like Traefik, use relative URLs
# Backend API URL
# For production behind reverse proxy, use relative URL:
VITE_API_URL=/api
# For development or if frontend/backend are on different domains:
# VITE_API_URL=https://api.yourdomain.com
# Umami Analytics Configuration (optional)
# VITE_UMAMI_URL=https://analytics.yourdomain.com
# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
+29
View File
@@ -0,0 +1,29 @@
# 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?
# Environment files
.env
.env.local
.env.*.local
+52
View File
@@ -0,0 +1,52 @@
# Build stage
FROM node:20-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
# Set permissions (nginx user already exists in nginx:alpine)
RUN 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;"]
+45
View File
@@ -0,0 +1,45 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Accept build arguments
ARG VITE_API_URL
ARG VITE_UMAMI_URL
ARG VITE_UMAMI_WEBSITE_ID
# Set environment variables for build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_UMAMI_URL=$VITE_UMAMI_URL
ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID
# 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
# Copy custom nginx config
COPY nginx.dev.conf /etc/nginx/conf.d/default.conf
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+65
View File
@@ -0,0 +1,65 @@
# Build stage with dynamic API URL
FROM node:20-alpine AS builder
# Accept build args for API URL
ARG VITE_API_URL=/api
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --legacy-peer-deps
# Copy source files
COPY . .
# Set environment variable for build
ENV VITE_API_URL=$VITE_API_URL
# 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 a script to inject runtime config
RUN cat > /usr/share/nginx/html/config.js << 'EOF'
window.__RUNTIME_CONFIG__ = {
API_URL: '/api'
};
EOF
# Set permissions
RUN 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...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
{
"auditReportVersion": 2,
"vulnerabilities": {},
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 0,
"critical": 0,
"total": 0
},
"dependencies": {
"prod": 136,
"dev": 298,
"optional": 47,
"peer": 0,
"peerOptional": 0,
"total": 433
}
}
}
+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/png" sizes="32x32" href="/favicon-32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PicPeak - Photo Sharing Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
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;
}
# Thumbnail serving proxy
location /thumbnails {
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 thumbnails
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# Uploads serving proxy (logos, favicons, watermarks)
location /uploads {
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 uploads
proxy_cache_valid 200 302 7d;
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;
}
}
+6030
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.61",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^2.26.1",
"@tiptap/extension-hard-break": "^2.26.1",
"@tiptap/extension-link": "^2.25.0",
"@tiptap/extension-placeholder": "^2.26.1",
"@tiptap/extension-text-align": "^2.26.1",
"@tiptap/react": "^2.25.0",
"@tiptap/starter-kit": "^2.25.0",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.3.2",
"clsx": "^2.0.0",
"date-fns": "^2.29.3",
"dompurify": "^3.2.6",
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lowlight": "^2.9.0",
"lucide-react": "^0.292.0",
"react": "^18.3.1",
"react-countdown": "^2.3.5",
"react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-i18next": "^15.6.0",
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
"react-router-dom": "^6.8.0",
"react-toastify": "^9.1.1",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.5.2",
"autoprefixer": "^10.4.13",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"postcss": "^8.4.21",
"tailwindcss": "^3.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^7.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+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

+153
View File
@@ -0,0 +1,153 @@
import { useEffect } from 'react';
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 { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
import { LegalPage } from './pages/public/LegalPage';
import {
AdminLoginPage,
AdminDashboard,
EventsListPage,
CreateEventPageEnhanced as CreateEventPage,
EventDetailsPage,
EmailConfigPage,
ArchivesPage,
AnalyticsPage,
BrandingPage,
SettingsPage,
CMSPage
} from './pages/admin';
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { getApiBaseUrl } from './utils/url';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
function App() {
// Initialize Umami Analytics based on settings
useEffect(() => {
const initializeAnalytics = async () => {
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
try {
// Fetch public settings to check if analytics is enabled
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
// Only initialize if analytics is enabled in settings
if (settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Initialize analytics anyway if settings fetch fails
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
}
};
initializeAnalytics();
}, []);
return (
<PageErrorBoundary>
<QueryClientProvider client={queryClient}>
<MaintenanceProvider>
<ThemeProvider>
<GlobalThemeProvider>
<DynamicFavicon />
<Router>
<MaintenanceWrapper>
<SkipLink />
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
</GalleryAuthProvider>
} />
{/* Admin routes - wrap with AdminAuthProvider */}
<Route path="/admin" element={<AdminAuthWrapper />}>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="events" element={<EventsListPage />} />
<Route path="events/new" element={<CreateEventPage />} />
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="email" element={<EmailConfigPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPageEnhanced />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
<Route path="/:slug" element={<LegalPage />} />
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
</Routes>
</MaintenanceWrapper>
</Router>
{/* Offline indicator */}
<OfflineIndicator />
{/* Toast notifications */}
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</GlobalThemeProvider>
</ThemeProvider>
</MaintenanceProvider>
</QueryClientProvider>
</PageErrorBoundary>
);
}
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

@@ -0,0 +1,36 @@
import React, { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTheme } from '../contexts/ThemeContext';
import { api } from '../config/api';
interface GlobalThemeProviderProps {
children: React.ReactNode;
}
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
const { setTheme } = useTheme();
const themeAppliedRef = useRef(false);
// Fetch public settings including theme config
const { data: settingsData } = useQuery({
queryKey: ['global-theme-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Apply global theme when settings are loaded (but not on gallery pages)
useEffect(() => {
// Skip if we're on a gallery page - gallery pages handle their own themes
const isGalleryPage = window.location.pathname.includes('/gallery/');
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
themeAppliedRef.current = true;
setTheme(settingsData.theme_config);
}
}, [settingsData, setTheme]);
return <>{children}</>;
};
+113
View File
@@ -0,0 +1,113 @@
import React, { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../config/api';
import { buildResourceUrl } from '../utils/url';
interface BrandingSettings {
branding_company_name?: string;
branding_company_tagline?: string;
branding_support_email?: string;
branding_footer_text?: string;
branding_favicon_url?: string;
branding_logo_url?: string;
default_language?: string;
}
export const MaintenanceMode: React.FC = () => {
const { t, i18n } = useTranslation();
// Fetch branding settings
const { data: settings } = useQuery<BrandingSettings>({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await api.get('/public/settings');
return response.data;
} catch (error) {
// Return empty object if settings can't be fetched
return {};
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false, // Don't retry on failure
});
// Set language based on system settings
useEffect(() => {
if (settings?.default_language && settings.default_language !== i18n.language) {
i18n.changeLanguage(settings.default_language);
}
}, [settings?.default_language, i18n]);
return (
<div className="min-h-screen bg-neutral-50 flex flex-col">
{/* Header with branding - Always show, with PicPeak logo as fallback */}
<div className="bg-white border-b border-neutral-200 py-4">
<div className="container">
<div className="flex items-center justify-center">
<img
src={settings?.branding_logo_url ?
(settings.branding_logo_url.startsWith('http')
? settings.branding_logo_url
: buildResourceUrl(settings.branding_logo_url))
: '/picpeak-logo-transparent.png'
}
alt={settings?.branding_company_name || 'PicPeak'}
className="h-12 w-auto object-contain"
/>
{settings?.branding_company_name && settings.branding_company_name !== 'PicPeak' && (
<div className="ml-4 text-center">
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
{settings.branding_company_tagline && (
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
)}
</div>
)}
</div>
</div>
</div>
{/* Main content */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md w-full text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-amber-100 rounded-full mb-6">
<AlertTriangle className="w-10 h-10 text-amber-600" />
</div>
<h1 className="text-3xl font-bold text-neutral-900 mb-4">
{t('maintenance.title')}
</h1>
<p className="text-lg text-neutral-600 mb-8">
{t('maintenance.message')}
</p>
{settings?.branding_support_email && (
<p className="text-sm text-neutral-500 mt-8">
{t('maintenance.urgentMatters')}{' '}
<a
href={`mailto:${settings.branding_support_email}`}
className="text-primary-600 hover:text-primary-700"
>
{settings.branding_support_email}
</a>
</p>
)}
</div>
</div>
{/* Footer */}
{settings?.branding_footer_text && (
<footer className="py-4 border-t border-neutral-200">
<div className="container text-center">
<p className="text-sm text-neutral-500">
{settings.branding_footer_text}
</p>
</div>
</footer>
)}
</div>
);
};
@@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api';
interface MaintenanceWrapperProps {
children: React.ReactNode;
}
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
// Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin');
const hasAdminAuth = !!getAuthToken(true);
// Register the maintenance mode callback
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
// Check maintenance mode on mount and when location changes
useQuery({
queryKey: ['maintenance-check', location.pathname],
queryFn: async () => {
try {
// Make a lightweight request to check maintenance status
await api.get('/public/settings');
// If successful, maintenance mode is off
setMaintenanceMode(false);
return { maintenance: false };
} catch (error: any) {
if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
setMaintenanceMode(true);
return { maintenance: true };
}
}
return { maintenance: false };
}
},
staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance
});
// Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) {
return <MaintenanceMode />;
}
return <>{children}</>;
};
@@ -0,0 +1,13 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { AdminAuthProvider } from '../../contexts';
export const AdminAuthWrapper: React.FC = () => {
return (
<AdminAuthProvider>
<Outlet />
</AdminAuthProvider>
);
};
AdminAuthWrapper.displayName = 'AdminAuthWrapper';
@@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = ({
src,
fallback,
alt,
...props
}) => {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
const loadImage = async () => {
try {
setLoading(true);
setError(false);
// Make authenticated request to get the image
const response = await api.get(src, {
responseType: 'blob',
});
if (!cancelled) {
// Create object URL from blob
const imageUrl = URL.createObjectURL(response.data);
setImageSrc(imageUrl);
setLoading(false);
}
} catch (err: any) {
// Image loading failed - handled by error state
if (!cancelled) {
setError(true);
setLoading(false);
}
}
};
if (src) {
loadImage();
}
// Cleanup function
return () => {
cancelled = true;
if (imageSrc) {
URL.revokeObjectURL(imageSrc);
}
};
}, [src]);
if (loading) {
return (
<div className="w-full h-full bg-neutral-200 animate-pulse" />
);
}
if (error) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return <img src={imageSrc || ''} alt={alt} {...props} />;
};
@@ -0,0 +1,250 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
interface AdminHeaderProps {
onMenuClick: () => void;
}
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { formatTimeAgo } = useLocalizedTimeAgo();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
useOnClickOutside(userMenuRef, () => setShowUserMenu(false));
useOnClickOutside(notificationRef, () => setShowNotifications(false));
const handleLogout = () => {
logout();
navigate('/admin/login');
};
// Fetch notifications
const { data: notificationsData } = useQuery({
queryKey: ['notifications', showNotifications],
queryFn: () => notificationsService.getNotifications(showNotifications, 20),
refetchInterval: 60000, // Refetch every minute
});
// Mark all as read mutation
const markAllAsReadMutation = useMutation({
mutationFn: notificationsService.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(t('admin.notificationToasts.markedAllRead'));
},
});
// Clear old notifications mutation
const clearOldMutation = useMutation({
mutationFn: notificationsService.clearOldNotifications,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount }));
},
});
const notifications = notificationsData?.notifications || [];
const unreadCount = notificationsData?.unreadCount || 0;
return (
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Left side - Menu button and Date */}
<div className="flex items-center gap-3">
<button
onClick={onMenuClick}
className="lg:hidden text-neutral-500 hover:text-neutral-700"
>
<Menu className="w-6 h-6" />
</button>
{/* Date display */}
<div className="hidden lg:block">
<p className="text-base text-neutral-700">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* Center - Logo and PicPeak text */}
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Language Selector */}
<LanguageSelector />
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
{/* Notifications dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-neutral-200">
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
title={t('admin.markAllRead')}
>
<CheckCircle className="w-3 h-3" />
{t('admin.markAllRead')}
</button>
)}
<button
onClick={() => clearOldMutation.mutate()}
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
title={t('admin.clearOld')}
>
<Trash2 className="w-3 h-3" />
{t('admin.clearOld')}
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-neutral-500">
{t('admin.noNotificationsMessage')}
</div>
) : (
notifications.map((notification) => {
const style = notificationsService.getNotificationStyle(notification.type);
return (
<div
key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${style.color}`}>
<Bell className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900">
{notificationsService.formatNotificationMessage(notification)}
</p>
<p className="text-xs text-neutral-500 mt-1">
{formatTimeAgo(notification.createdAt)}
</p>
</div>
</div>
</div>
);
})
)}
</div>
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 text-center">
<button
onClick={() => setShowNotifications(false)}
className="text-sm text-primary-600 hover:text-primary-700"
>
{t('admin.close')}
</button>
</div>
)}
</div>
)}
</div>
{/* User menu */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<div className="text-right hidden sm:block">
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
<p className="text-xs text-neutral-500">{user?.email}</p>
</div>
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
</button>
{/* User dropdown */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-neutral-200 py-1">
<div className="px-4 py-2 border-b border-neutral-100 sm:hidden">
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
<p className="text-xs text-neutral-500">{user?.email}</p>
</div>
<button
onClick={() => {
setShowUserMenu(false);
navigate('/admin/settings');
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Settings className="w-4 h-4" />
{t('navigation.settings')}
</button>
<button
onClick={() => {
setShowUserMenu(false);
setShowPasswordModal(true);
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Lock className="w-4 h-4" />
{t('admin.changePassword')}
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
{t('common.logout')}
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Password Change Modal */}
<PasswordChangeModal
isOpen={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
/>
</header>
);
};
@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAdminAuth } from '../../contexts';
import { useSessionTimeout } from '../../hooks/useSessionTimeout';
import { AdminSidebar } from './AdminSidebar';
import { AdminHeader } from './AdminHeader';
import { MaintenanceBanner } from './MaintenanceBanner';
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading } = useAdminAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
// Handle session timeout
useSessionTimeout();
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-neutral-600">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />;
}
return (
<div className="h-screen bg-neutral-50 flex overflow-hidden">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Header */}
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* Page content */}
<main id="main-content" className="flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
);
};
AdminLayout.displayName = 'AdminLayout';
@@ -0,0 +1,251 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package } from 'lucide-react';
import { toast } from 'react-toastify';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
interface AdminPhotoGridProps {
photos: AdminPhoto[];
eventId: number;
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
photos,
eventId,
onPhotoClick,
onPhotosDeleted
}) => {
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingPhotoId, setDeletingPhotoId] = useState<number | null>(null);
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleSelectAll = () => {
if (selectedPhotos.size === photos.length) {
setSelectedPhotos(new Set());
} else {
setSelectedPhotos(new Set(photos.map(p => p.id)));
}
};
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete "${photo.filename}"?`)) {
return;
}
setDeletingPhotoId(photo.id);
try {
await photosService.deletePhoto(eventId, photo.id);
toast.success('Photo deleted successfully');
onPhotosDeleted();
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setDeletingPhotoId(null);
}
};
const handleDeleteSelected = async () => {
if (selectedPhotos.size === 0) return;
const count = selectedPhotos.size;
if (!confirm(`Are you sure you want to delete ${count} photo${count > 1 ? 's' : ''}?`)) {
return;
}
setIsDeleting(true);
try {
await photosService.deletePhotos(eventId, Array.from(selectedPhotos));
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onPhotosDeleted();
} catch (error) {
toast.error('Failed to delete photos');
} finally {
setIsDeleting(false);
}
};
const handleDownload = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
try {
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download photo');
}
};
const toggleSelectionMode = () => {
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedPhotos(new Set());
}
};
return (
<div>
{/* Action Bar */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant={isSelectionMode ? "primary" : "outline"}
size="sm"
onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button>
{isSelectionMode && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAll}
>
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
</Button>
{selectedPhotos.size > 0 && (
<>
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
</span>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete Selected
</button>
</>
)}
</>
)}
</div>
<div className="text-sm text-neutral-600">
{photos.length} photo{photos.length !== 1 ? 's' : ''}
</div>
</div>
{/* Photo Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo, index) => (
<div
key={photo.id}
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 ${
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
}`}
onClick={() => isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)}
>
{/* Selection Checkbox */}
{isSelectionMode && (
<div className="absolute top-2 left-2 z-10">
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id)
? 'bg-primary-500 border-primary-500'
: 'bg-white/80 border-neutral-300'
}`}>
{selectedPhotos.has(photo.id) && (
<Check className="w-4 h-4 text-white" />
)}
</div>
</div>
)}
{/* Thumbnail */}
<div className="aspect-square">
{photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
fallback={
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
)}
</div>
{/* Overlay with actions */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="text-white text-xs font-medium truncate mb-1">
{photo.filename}
</p>
<p className="text-white/80 text-xs mb-2">
{photosService.formatBytes(photo.size)}
</p>
{!isSelectionMode && (
<div className="flex gap-1">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
>
<Download className="w-3 h-3" />
</button>
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
disabled={deletingPhotoId === photo.id}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
{/* Category Badge */}
{photo.category_name && (
<div className="absolute top-2 right-2">
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded">
{photo.category_name}
</span>
</div>
)}
</div>
))}
</div>
{photos.length === 0 && (
<div className="text-center py-12">
<p className="text-neutral-500">No photos uploaded yet</p>
</div>
)}
</div>
);
};
@@ -0,0 +1,269 @@
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
interface AdminPhotoViewerProps {
photos: AdminPhoto[];
initialIndex: number;
eventId: number;
onClose: () => void;
onPhotoDeleted: () => void;
categories: Array<{ id: number; name: string; slug: string }>;
}
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
photos,
initialIndex,
eventId,
onClose,
onPhotoDeleted,
categories
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false);
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
const currentPhoto = photos[currentIndex];
const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
};
const handleDelete = async () => {
if (!confirm(`Are you sure you want to delete "${currentPhoto.filename}"?`)) {
return;
}
setIsDeleting(true);
try {
await photosService.deletePhoto(eventId, currentPhoto.id);
toast.success('Photo deleted successfully');
// Close viewer if this was the last photo
if (photos.length === 1) {
onClose();
} else {
// Move to next photo if available, otherwise previous
if (currentIndex === photos.length - 1) {
setCurrentIndex(currentIndex - 1);
}
}
onPhotoDeleted();
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setIsDeleting(false);
}
};
const handleDownload = async () => {
try {
await photosService.downloadPhoto(eventId, currentPhoto.id, currentPhoto.filename);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download photo');
}
};
const handleCategoryChange = async (categoryId: number | null) => {
try {
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
toast.success('Category updated');
setShowCategoryMenu(false);
// Trigger refresh to update the photo data
onPhotoDeleted(); // This will refresh the photos list
} catch (error) {
toast.error('Failed to update category');
}
};
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
goToPrevious();
break;
case 'ArrowRight':
goToNext();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentIndex]);
return (
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<X className="w-6 h-6" />
</button>
{/* Navigation */}
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
{/* Main content */}
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
</div>
{/* Sidebar */}
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
<h3 className="text-white font-medium text-lg mb-4">{currentPhoto.filename}</h3>
{/* Actions */}
<div className="flex gap-2 mb-6">
<Button
variant="primary"
size="sm"
onClick={handleDownload}
leftIcon={<Download className="w-4 h-4" />}
className="flex-1"
>
Download
</Button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex-1 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
{/* Category */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-neutral-400 text-sm flex items-center gap-1">
<Tag className="w-4 h-4" />
Category
</span>
<button
onClick={() => setShowCategoryMenu(!showCategoryMenu)}
className="text-xs text-primary-400 hover:text-primary-300"
>
Change
</button>
</div>
<p className="text-white">
{currentPhoto.category_name || 'Uncategorized'}
</p>
{showCategoryMenu && (
<div className="mt-2 bg-neutral-800 rounded-lg p-2">
<button
onClick={() => handleCategoryChange(null)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
Uncategorized
</button>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => handleCategoryChange(cat.id)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
{cat.name}
</button>
))}
</div>
)}
</div>
{/* Metadata */}
<div className="space-y-4 text-sm">
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<HardDrive className="w-4 h-4" />
File Size
</span>
<p className="text-white">{photosService.formatBytes(currentPhoto.size)}</p>
</div>
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Calendar className="w-4 h-4" />
Uploaded
</span>
<p className="text-white">
{format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
{currentPhoto.view_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Eye className="w-4 h-4" />
Views
</span>
<p className="text-white">{currentPhoto.view_count}</p>
</div>
)}
{currentPhoto.download_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<MousePointer className="w-4 h-4" />
Downloads
</span>
<p className="text-white">{currentPhoto.download_count}</p>
</div>
)}
</div>
{/* Navigation info */}
<div className="mt-6 pt-6 border-t border-neutral-700">
<p className="text-neutral-400 text-sm text-center">
{currentIndex + 1} of {photos.length}
</p>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,145 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Mail,
Archive,
BarChart3,
Settings,
X,
Palette,
FileText
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
interface AdminSidebarProps {
isOpen: boolean;
onClose: () => void;
}
interface NavItem {
nameKey: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
const navigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
const location = useLocation();
const { t } = useTranslation();
return (
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-neutral-200 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-screen lg:h-full">
{/* Brand */}
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
<div className="flex items-center">
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
</div>
<button
onClick={onClose}
className="lg:hidden text-neutral-400 hover:text-neutral-600"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
{navigation.map((item) => {
const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
return (
<NavLink
key={item.nameKey}
to={item.href}
onClick={() => onClose()}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900'
}`}
>
<item.icon className={`w-5 h-5 mr-3 ${
isActive ? 'text-primary-600' : 'text-neutral-400'
}`} />
{t(item.nameKey)}
</NavLink>
);
})}
</nav>
{/* Bottom section - sticky to bottom */}
<div className="flex-shrink-0">
{/* Version Info */}
<VersionInfo />
{/* Storage Info */}
<StorageInfo />
</div>
</div>
</div>
);
};
const StorageInfo: React.FC = () => {
const { t } = useTranslation();
const { data: storageInfo } = useQuery({
queryKey: ['storage-info'],
queryFn: () => settingsService.getStorageInfo(),
refetchInterval: 60000 // Refresh every minute
});
if (!storageInfo) {
return (
<div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3">
<div className="h-12 animate-pulse bg-neutral-200 rounded" />
</div>
</div>
);
}
const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100);
return (
<div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
<span className="font-medium text-neutral-900">
{settingsService.formatBytes(storageInfo.total_used)}
</span>
</div>
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
<p className="text-xs text-neutral-600 mt-1">
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
</p>
</div>
</div>
);
};
@@ -0,0 +1,92 @@
import React from 'react';
import { Archive, AlertTriangle, X } from 'lucide-react';
import { Button, Card } from '../common';
import type { Event } from '../../types';
interface BulkArchiveModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
selectedEvents: Event[];
isLoading?: boolean;
}
export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
isOpen,
onClose,
onConfirm,
selectedEvents,
isLoading = false,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Confirm Bulk Archive</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<div className="mb-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-neutral-700">
<p className="mb-2">
You are about to archive <strong>{selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}</strong>.
This action will:
</p>
<ul className="list-disc list-inside space-y-1 text-neutral-600">
<li>Create a ZIP archive of all photos for each event</li>
<li>Make the galleries inaccessible to guests</li>
<li>Remove the events from active listings</li>
<li>Free up storage space by compressing photos</li>
</ul>
</div>
</div>
<div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto">
<div className="p-3">
<h3 className="text-sm font-medium text-neutral-700 mb-2">Events to be archived:</h3>
<ul className="space-y-1">
{selectedEvents.map((event) => (
<li key={event.id} className="text-sm text-neutral-600">
{event.event_name} ({event.event_type})
</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
leftIcon={<Archive className="w-4 h-4" />}
>
Archive {selectedEvents.length} Event{selectedEvents.length > 1 ? 's' : ''}
</Button>
</div>
</div>
</Card>
</div>
);
};
BulkArchiveModal.displayName = 'BulkArchiveModal';
+571
View File
@@ -0,0 +1,571 @@
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import TextAlign from '@tiptap/extension-text-align';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { lowlight } from 'lowlight';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Quote,
Code,
Code2,
Minus,
Undo,
Redo,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Eye,
Edit3,
Columns,
Maximize2,
HelpCircle,
Save
} from 'lucide-react';
import { Button } from '../common';
import DOMPurify from 'dompurify';
import '../../styles/prose-overrides.css';
interface CMSEditorProps {
content: string;
onChange: (content: string) => void;
onSave?: () => void;
isSaving?: boolean;
}
type ViewMode = 'edit' | 'preview' | 'split';
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('edit');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [wordCount, setWordCount] = useState(0);
const [charCount, setCharCount] = useState(0);
const editor = useEditor({
extensions: [
StarterKit.configure({
hardBreak: false, // We'll use the separate HardBreak extension
codeBlock: false, // We'll use CodeBlockLowlight instead
}),
HardBreak.configure({
keepMarks: true,
HTMLAttributes: {
class: 'hard-break',
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
defaultAlignment: 'left',
}),
CodeBlockLowlight.configure({
lowlight,
HTMLAttributes: {
class: 'hljs',
},
}),
Placeholder.configure({
placeholder: 'Start typing your content here...',
}),
CharacterCount.configure({
limit: null,
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
updateCounts(editor);
},
onCreate: ({ editor }) => {
updateCounts(editor);
},
});
const updateCounts = useCallback((editor: any) => {
const text = editor.state.doc.textContent;
setCharCount(editor.storage.characterCount.characters());
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
}, []);
// Update editor content when prop changes
React.useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
const addLink = () => {
if (linkUrl) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
};
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
const getPreviewContent = () => {
return DOMPurify.sanitize(editor?.getHTML() || '', {
ALLOWED_TAGS: [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
'code', 'pre', 'hr', 'div', 'span'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
ADD_TAGS: ['br'], // Explicitly allow br tags
ADD_ATTR: ['style'], // Allow style for text alignment
});
};
return (
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}>
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col">
{/* Top Toolbar */}
<div className="border-b border-neutral-200 bg-neutral-50">
{/* View Mode Controls */}
<div className="flex items-center justify-between p-2 border-b border-neutral-200">
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('edit')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'edit'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Edit3 className="w-4 h-4 inline-block mr-1" />
Edit
</button>
<button
onClick={() => setViewMode('preview')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'preview'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Eye className="w-4 h-4 inline-block mr-1" />
Preview
</button>
<button
onClick={() => setViewMode('split')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'split'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Columns className="w-4 h-4 inline-block mr-1" />
Split
</button>
</div>
<div className="flex items-center gap-2">
{onSave && (
<Button
size="sm"
onClick={onSave}
isLoading={isSaving}
leftIcon={<Save className="w-4 h-4" />}
>
Save
</Button>
)}
<MenuButton
onClick={() => setShowHelp(true)}
title="Help & Keyboard Shortcuts"
>
<HelpCircle className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={toggleFullscreen}
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
active={isFullscreen}
>
<Maximize2 className="w-4 h-4" />
</MenuButton>
</div>
</div>
{/* Formatting Toolbar */}
{viewMode !== 'preview' && (
<div className="flex items-center gap-1 p-2 flex-wrap">
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Heading 1 (Ctrl+Alt+1)"
>
<Heading1 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2 (Ctrl+Alt+2)"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3 (Ctrl+Alt+3)"
>
<Heading3 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
active={editor.isActive('heading', { level: 4 })}
title="Heading 4 (Ctrl+Alt+4)"
>
<Heading4 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
active={editor.isActive('heading', { level: 5 })}
title="Heading 5 (Ctrl+Alt+5)"
>
<Heading5 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
active={editor.isActive('heading', { level: 6 })}
title="Heading 6 (Ctrl+Alt+6)"
>
<Heading6 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<Italic className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
title="Inline Code (Ctrl+E)"
>
<Code className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')}
title="Code Block (Ctrl+Alt+C)"
>
<Code2 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet List (Ctrl+Shift+8)"
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Numbered List (Ctrl+Shift+9)"
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title="Blockquote (Ctrl+Shift+B)"
>
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title="Add Link (Ctrl+K)"
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
active={editor.isActive({ textAlign: 'justify' })}
title="Justify"
>
<AlignJustify className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Clear Formatting"
>
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
>
<Redo className="w-4 h-4" />
</MenuButton>
</div>
)}
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>Add Link</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
Cancel
</Button>
</div>
)}
{/* Editor Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Editor */}
{viewMode !== 'preview' && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200' : 'w-full'} overflow-auto`}>
<EditorContent
editor={editor}
className="min-h-[400px] p-4 prose prose-neutral max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
/>
</div>
)}
{/* Preview */}
{viewMode !== 'edit' && (
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}>
<div
className="prose prose-neutral max-w-none"
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
/>
</div>
)}
</div>
{/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 border-t border-neutral-200 text-sm text-neutral-600">
<div className="flex items-center gap-4">
<span>{wordCount} words</span>
<span>{charCount} characters</span>
</div>
<div className="text-xs text-neutral-500">
Press Shift+Enter for line break, Enter for new paragraph
</div>
</div>
</div>
{/* Help Modal */}
{showHelp && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Text Formatting</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+B</kbd> - Bold</div>
<div><kbd>Ctrl+I</kbd> - Italic</div>
<div><kbd>Ctrl+E</kbd> - Inline code</div>
<div><kbd>Ctrl+K</kbd> - Add link</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Headings</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div>
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div>
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div>
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div>
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div>
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Lists & Blocks</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div>
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div>
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div>
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Text Alignment</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Click alignment buttons in toolbar</div>
<div>Works on paragraphs and headings</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Line Breaks</h3>
<div className="space-y-1 text-sm">
<div><kbd>Enter</kbd> - New paragraph</div>
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Navigation</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Z</kbd> - Undo</div>
<div><kbd>Ctrl+Y</kbd> - Redo</div>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button onClick={() => setShowHelp(false)}>Close</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
CMSEditor.displayName = 'CMSEditor';
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
export const CategoryManager: React.FC = () => {
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState('');
// Fetch global categories
const { data: categories = [], isLoading } = useQuery({
queryKey: ['global-categories'],
queryFn: categoriesService.getGlobalCategories,
});
// Create category mutation
const createMutation = useMutation({
mutationFn: (name: string) =>
categoriesService.createCategory({ name, is_global: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category created successfully');
setNewCategoryName('');
setIsAdding(false);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to create category');
},
});
// Update category mutation
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) =>
categoriesService.updateCategory(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category updated successfully');
setEditingId(null);
setEditingName('');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to update category');
},
});
// Delete category mutation
const deleteMutation = useMutation({
mutationFn: categoriesService.deleteCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category deleted successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to delete category');
},
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleUpdate = (id: number) => {
if (editingName.trim()) {
updateMutation.mutate({ id, name: editingName.trim() });
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
deleteMutation.mutate(category.id);
}
};
const startEdit = (category: PhotoCategory) => {
setEditingId(category.id);
setEditingName(category.name);
};
const cancelEdit = () => {
setEditingId(null);
setEditingName('');
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900">Photo Categories</h3>
{!isAdding && (
<Button
variant="primary"
size="sm"
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-4 h-4" />}
>
Add Category
</Button>
)}
</div>
{/* Add new category form */}
{isAdding && (
<div className="flex gap-2 p-3 bg-neutral-50 rounded-lg">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Category name"
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Create'
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
setIsAdding(false);
setNewCategoryName('');
}}
>
Cancel
</Button>
</div>
)}
{/* Categories list */}
<div className="space-y-2">
{categories.length === 0 ? (
<p className="text-neutral-500 text-center py-8">
No categories yet. Create your first category to organize photos.
</p>
) : (
categories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between p-3 bg-white rounded-lg border border-neutral-200 hover:border-neutral-300 transition-colors"
>
{editingId === category.id ? (
<div className="flex gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') handleUpdate(category.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={() => handleUpdate(category.id)}
disabled={!editingName.trim() || updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Save'
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={cancelEdit}
>
Cancel
</Button>
</div>
) : (
<>
<div>
<p className="font-medium text-neutral-900">{category.name}</p>
<p className="text-sm text-neutral-500">/{category.slug}</p>
</div>
<div className="flex gap-1">
<button
onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
title="Edit category"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(category)}
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete category"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</>
)}
</div>
))
)}
</div>
</div>
);
};
CategoryManager.displayName = 'CategoryManager';
@@ -0,0 +1,99 @@
import React from 'react';
import { X, Mail, FileText } from 'lucide-react';
import { Button, Card } from '../common';
interface EmailPreviewModalProps {
isOpen: boolean;
onClose: () => void;
subject: string;
htmlContent: string;
textContent?: string;
}
export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
isOpen,
onClose,
subject,
htmlContent,
textContent
}) => {
const [viewMode, setViewMode] = React.useState<'html' | 'text'>('html');
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-primary-600" />
<h2 className="text-xl font-semibold text-neutral-900">Email Preview</h2>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Subject */}
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
<p className="text-sm font-medium text-neutral-600">Subject:</p>
<p className="text-lg font-semibold text-neutral-900 mt-1">{subject}</p>
</div>
{/* View mode toggle */}
<div className="px-6 py-3 border-b border-neutral-200">
<div className="flex gap-2">
<Button
variant={viewMode === 'html' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('html')}
leftIcon={<Mail className="w-4 h-4" />}
>
HTML View
</Button>
{textContent && (
<Button
variant={viewMode === 'text' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('text')}
leftIcon={<FileText className="w-4 h-4" />}
>
Text View
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{viewMode === 'html' ? (
<div className="bg-white border border-neutral-200 rounded-lg shadow-sm">
<iframe
srcDoc={htmlContent}
className="w-full h-[600px] border-0"
title="Email Preview"
/>
</div>
) : (
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-6">
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700">
{textContent}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
import { useTranslation } from 'react-i18next';
interface EventCategoryManagerProps {
eventId: number;
}
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
// Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation
const createMutation = useMutation({
mutationFn: (name: string) =>
categoriesService.createCategory({
name,
is_global: false,
event_id: eventId
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success(t('categories.categoryCreatedSuccess'));
setNewCategoryName('');
setIsAdding(false);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
},
});
// Delete category mutation
const deleteMutation = useMutation({
mutationFn: categoriesService.deleteCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success(t('categories.categoryDeletedSuccess'));
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
},
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) {
deleteMutation.mutate(category.id);
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
</div>
);
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-neutral-700">{t('categories.eventSpecificCategories')}</h3>
{!isAdding && (
<Button
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
</Button>
)}
</div>
{/* Add new category form */}
{isAdding && (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder={t('categories.categoryName')}
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
t('common.add')
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsAdding(false);
setNewCategoryName('');
}}
>
{t('common.cancel')}
</Button>
</div>
)}
{/* Event categories list */}
{eventCategories.length === 0 ? (
<p className="text-sm text-neutral-500 italic">
{t('categories.noEventSpecificCategories')}
</p>
) : (
<div className="space-y-1">
{eventCategories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
>
<span className="text-sm text-neutral-700">{category.name}</span>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
))}
</div>
)}
{/* Show available global categories */}
<div className="mt-4 pt-3 border-t border-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
<div className="flex flex-wrap gap-1">
{categories
.filter(cat => cat.is_global)
.map(cat => (
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
{cat.name}
</span>
))}
</div>
</div>
</div>
);
};
EventCategoryManager.displayName = 'EventCategoryManager';
@@ -0,0 +1,184 @@
import React, { useMemo } from 'react';
import { Camera } from 'lucide-react';
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
interface GalleryPreviewProps {
theme: ThemeConfig;
layoutType?: GalleryLayoutType;
className?: string;
}
// Mock photo data for preview
const generateMockPhotos = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
filename: `photo-${i + 1}.jpg`,
url: '',
thumbnail_url: '',
type: i % 3 === 0 ? 'collage' : 'individual',
category_id: (i % 4) + 1,
category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4],
category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4],
size: Math.floor(Math.random() * 5000000) + 1000000,
uploaded_at: new Date().toISOString(),
}));
};
// Preview photo component
const PreviewPhoto: React.FC<{
photo: any;
className?: string;
aspectRatio?: string;
}> = ({
photo,
className = '',
aspectRatio = 'aspect-square'
}) => (
<div className={`relative overflow-hidden rounded-lg bg-gradient-to-br from-neutral-200 to-neutral-300 ${aspectRatio} ${className}`}>
<div className="absolute inset-0 flex items-center justify-center">
<Camera className="w-8 h-8 text-neutral-400" />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
{photo.category_name && (
<p className="text-white/70 text-[10px]">{photo.category_name}</p>
)}
</div>
{photo.type === 'collage' && (
<div className="absolute top-1 right-1">
<span className="px-1.5 py-0.5 bg-black/60 text-white text-[10px] rounded">
Collage
</span>
</div>
)}
</div>
);
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
theme,
layoutType,
className = ''
}) => {
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
// Use the provided layoutType or fallback to theme's gallery layout
const activeLayout = layoutType || theme.galleryLayout || 'grid';
const renderLayout = () => {
const spacing = theme.gallerySettings?.spacing || 'normal';
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
switch (activeLayout) {
case 'grid': {
return (
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(0, 8).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
);
}
case 'masonry':
return (
<div className={`columns-3 md:columns-4 ${gapClass}`}>
{mockPhotos.slice(0, 10).map((photo, idx) => (
<div key={photo.id} className={`break-inside-avoid mb-${spacing === 'tight' ? '1' : spacing === 'relaxed' ? '4' : '2'}`}>
<PreviewPhoto
photo={photo}
aspectRatio={idx % 3 === 0 ? 'aspect-[4/5]' : idx % 3 === 1 ? 'aspect-[4/3]' : 'aspect-square'}
/>
</div>
))}
</div>
);
case 'carousel':
return (
<div className="relative">
<div className="flex items-center gap-2 overflow-hidden">
<PreviewPhoto photo={mockPhotos[0]} className="w-full max-w-md mx-auto" aspectRatio="aspect-[4/3]" />
</div>
<div className="flex justify-center gap-1 mt-3">
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-primary-600' : 'bg-neutral-300'}`} />
))}
</div>
</div>
);
case 'timeline':
return (
<div className="space-y-6">
{['Today', 'Yesterday'].map((date, dateIdx) => (
<div key={date}>
<h4 className="text-sm font-medium text-neutral-700 mb-2">{date}</h4>
<div className={`grid grid-cols-3 ${gapClass}`}>
{mockPhotos.slice(dateIdx * 3, (dateIdx * 3) + 3).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
))}
</div>
);
case 'hero':
return (
<div className="space-y-3">
<PreviewPhoto photo={mockPhotos[0]} aspectRatio="aspect-[16/9]" className="w-full" />
<div className={`grid grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(1, 5).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
);
case 'mosaic':
return (
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
<PreviewPhoto photo={mockPhotos[0]} className="col-span-2 row-span-2" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[1]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[2]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[3]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[4]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[5]} className="col-span-2 row-span-1" aspectRatio="aspect-auto h-full" />
</div>
);
default:
return null;
}
};
return (
<div
className={`bg-white rounded-lg shadow-sm overflow-hidden ${className}`}
style={{
backgroundColor: theme.backgroundColor || '#ffffff',
color: theme.textColor || '#171717',
fontFamily: theme.fontFamily || 'Inter, sans-serif',
}}
>
{/* Preview Header */}
<div
className="px-4 py-3 border-b"
style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}}
>
<h3 className="text-sm font-medium">
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout
</h3>
</div>
{/* Preview Content */}
<div className="p-4" style={{ maxHeight: '400px', overflowY: 'auto' }}>
{renderLayout()}
</div>
</div>
);
};
GalleryPreview.displayName = 'GalleryPreview';
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { X, Image as ImageIcon, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, AuthenticatedImage } from '../common';
import { AdminPhoto } from '../../services/photos.service';
interface HeroPhotoSelectorProps {
photos: AdminPhoto[];
currentHeroPhotoId?: number | null;
onSelect: (photoId: number | null) => void;
isEditing: boolean;
}
export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
photos,
currentHeroPhotoId,
onSelect,
isEditing
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [selectedPhotoId, setSelectedPhotoId] = useState<number | null>(currentHeroPhotoId || null);
const currentHeroPhoto = photos.find(p => p.id === currentHeroPhotoId);
const handleSelect = (photoId: number) => {
setSelectedPhotoId(photoId);
onSelect(photoId);
setIsOpen(false);
};
const handleRemove = () => {
setSelectedPhotoId(null);
onSelect(null);
};
if (!isEditing) {
return (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.heroPhoto')}
</label>
{currentHeroPhoto ? (
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100">
<AuthenticatedImage
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
alt={currentHeroPhoto.filename}
className="w-full h-full object-cover"
/>
</div>
) : (
<p className="text-sm text-neutral-500">{t('events.noHeroPhotoSelected')}</p>
)}
</div>
);
}
return (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.heroPhoto')}
</label>
<p className="text-xs text-neutral-500 mb-2">
{t('events.heroPhotoHelp')}
</p>
{currentHeroPhoto ? (
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100 mb-2">
<AuthenticatedImage
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
alt={currentHeroPhoto.filename}
className="w-full h-full object-cover"
/>
<div className="absolute top-2 right-2 flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setIsOpen(true)}
className="bg-white/90 hover:bg-white"
>
{t('common.change')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRemove}
leftIcon={<X className="w-4 h-4" />}
className="bg-white/90 hover:bg-white"
>
{t('common.remove')}
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
leftIcon={<ImageIcon className="w-4 h-4" />}
onClick={() => setIsOpen(true)}
className="w-full"
>
{t('events.selectHeroPhoto')}
</Button>
)}
{/* Photo Selection Modal */}
{isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-neutral-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('events.selectHeroPhoto')}</h2>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{photos.length === 0 ? (
<p className="text-center text-neutral-500 py-8">
{t('events.noPhotosAvailable')}
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => (
<div
key={photo.id}
onClick={() => handleSelect(photo.id)}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
photo.id === selectedPhotoId
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
: 'border-transparent hover:border-neutral-300'
}`}
>
<div className="aspect-square bg-neutral-100">
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
/>
</div>
{photo.id === selectedPhotoId && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="p-6 border-t border-neutral-200 flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setIsOpen(false)}
>
{t('common.cancel')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
export const MaintenanceBanner: React.FC = () => {
const [dismissed, setDismissed] = React.useState(false);
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
refetchInterval: 60000 // Check every minute
});
const isMaintenanceMode = settings?.general_maintenance_mode === true ||
settings?.general_maintenance_mode === 'true';
if (!isMaintenanceMode || dismissed) {
return null;
}
return (
<div className="bg-amber-50 border-b border-amber-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<p className="text-sm font-medium text-amber-900">
Maintenance mode is currently enabled. Public access to galleries is restricted.
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="text-amber-600 hover:text-amber-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
interface PasswordChangeModalProps {
isOpen: boolean;
onClose: () => void;
}
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success('Password changed successfully');
onClose();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
},
onError: (error: any) => {
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error('Failed to change password');
}
}
});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = 'Current password is required';
}
if (!formData.newPassword) {
newErrors.newPassword = 'New password is required';
} else if (formData.newPassword.length < 6) {
newErrors.newPassword = 'Password must be at least 6 characters';
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Please confirm your new password';
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = 'New password must be different from current password';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
changePasswordMutation.mutate({
currentPassword: formData.currentPassword,
newPassword: formData.newPassword
});
};
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user types
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Change Password</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Current Password
</label>
<div className="relative">
<Input
id="currentPassword"
type={showPasswords.current ? 'text' : 'password'}
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder="Enter current password"
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
New Password
</label>
<div className="relative">
<Input
id="newPassword"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder="Enter new password"
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Confirm New Password
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder="Confirm new password"
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Password Requirements */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">Password Requirements:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>At least 6 characters long</li>
<li>Must be different from current password</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
isLoading={changePasswordMutation.isPending}
>
Change Password
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { X, Key, Copy, CheckCircle, Mail } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card } from '../common';
interface PasswordResetModalProps {
eventName: string;
onConfirm: (sendEmail: boolean) => Promise<{ newPassword: string; emailSent: boolean }>;
onClose: () => void;
}
export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
eventName,
onConfirm,
onClose
}) => {
const [isResetting, setIsResetting] = useState(false);
const [sendEmail, setSendEmail] = useState(true);
const [newPassword, setNewPassword] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleReset = async () => {
setIsResetting(true);
try {
const result = await onConfirm(sendEmail);
setNewPassword(result.newPassword);
toast.success('Password reset successfully');
} catch (error) {
toast.error('Failed to reset password');
onClose();
} finally {
setIsResetting(false);
}
};
const handleCopy = async () => {
if (newPassword) {
await navigator.clipboard.writeText(newPassword);
setCopied(true);
toast.success('Password copied to clipboard');
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{newPassword ? 'New Password' : 'Reset Gallery Password'}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600"
>
<X className="w-5 h-5" />
</button>
</div>
{!newPassword ? (
<>
<p className="text-neutral-600 mb-6">
Are you sure you want to reset the password for <strong>{eventName}</strong>?
This will generate a new password for gallery access.
</p>
<div className="mb-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">
Send email notification
</span>
</div>
<p className="text-xs text-neutral-500 mt-1">
Notify the host about the password change
</p>
</div>
</label>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
<p className="text-sm text-amber-800">
<strong>Note:</strong> The old password will no longer work.
Make sure to share the new password with the host.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isResetting}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleReset}
disabled={isResetting}
isLoading={isResetting}
leftIcon={<Key className="w-4 h-4" />}
className="flex-1"
>
Reset Password
</Button>
</div>
</>
) : (
<>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<p className="font-medium text-green-900">Password reset successfully!</p>
</div>
{sendEmail && (
<p className="text-sm text-green-700">
An email notification has been sent to the host.
</p>
)}
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-neutral-700 mb-2">
New Gallery Password
</label>
<div className="flex gap-2">
<input
type="text"
value={newPassword}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm"
/>
<Button
variant="outline"
onClick={handleCopy}
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
<p className="text-sm text-blue-800">
<strong>Important:</strong> Save this password securely. It cannot be recovered once you close this window.
</p>
</div>
<Button
variant="primary"
onClick={onClose}
className="w-full"
>
Done
</Button>
</>
)}
</Card>
</div>
);
};
@@ -0,0 +1,89 @@
import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common';
interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>;
selectedCategory: number | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size', order: 'asc' | 'desc') => void;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
categories,
selectedCategory,
searchTerm,
sortBy,
sortOrder,
onCategoryChange,
onSearchChange,
onSortChange
}) => {
const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
};
return (
<div className="bg-white border border-neutral-200 rounded-lg p-4 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<Input
type="text"
placeholder="Search by filename..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Categories</option>
<option value="0">Uncategorized</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{/* Sort Options */}
<div className="flex items-center gap-2">
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="size">Sort by Size</option>
</select>
<button
onClick={handleSortToggle}
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'}
>
{sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600" />
) : (
<SortDesc className="w-5 h-5 text-neutral-600" />
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,280 @@
import React, { useState, useRef } from 'react';
import { Upload, X, Image, Loader2 } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
import { useTranslation } from 'react-i18next';
interface PhotoUploadProps {
eventId: number;
onUploadComplete?: () => void;
}
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch categories for this event
const { data: categories = [] } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > 500) {
const allowedNewFiles = 500 - selectedFiles.length;
if (allowedNewFiles <= 0) {
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
return;
}
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
};
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
if (selectedFiles.length === 0) return;
// Validate file count
if (selectedFiles.length > 500) {
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
return;
}
setIsUploading(true);
setUploadProgress(0);
// For large uploads, chunk the files to prevent memory issues
const CHUNK_SIZE = 50; // Upload 50 files at a time
const chunks = [];
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
}
setTotalChunks(chunks.length);
let totalUploaded = 0;
let failedFiles = [];
try {
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
setCurrentChunk(chunkIndex + 1);
const chunk = chunks[chunkIndex];
const formData = new FormData();
chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
// Calculate overall progress across all chunks
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
}
},
});
totalUploaded += chunk.length;
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
// Continue with next chunk even if one fails
continue;
}
}
// Clear selected files
setSelectedFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// Show appropriate message
if (failedFiles.length === 0) {
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
} else {
toast.warning(
t('upload.someFilesFailed') ||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
);
}
// Call callback
if (onUploadComplete) {
onUploadComplete();
}
} catch (error: any) {
console.error('Upload error:', error);
toast.error(error.response?.data?.error || t('toast.uploadError'));
} finally {
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
return (
<div className="space-y-4">
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('upload.photoCategory')}
</label>
<select
value={selectedCategoryId || ''}
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">{t('upload.noCategory')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name} {!category.is_global && t('upload.eventSpecific')}
</option>
))}
</select>
</div>
{/* File Input Area */}
<div
className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
"hover:border-primary-400 hover:bg-primary-50/50",
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30" : "border-neutral-300"
)}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-12 h-12 mx-auto text-neutral-400 mb-4" />
<p className="text-neutral-700 font-medium mb-1">
{t('upload.clickToUpload')}
</p>
<p className="text-sm text-neutral-500">
{t('upload.fileRequirements')}
</p>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* Selected Files */}
{selectedFiles.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-700">
{t('upload.selectedFiles')} ({selectedFiles.length})
</p>
<div className="max-h-48 overflow-y-auto space-y-2">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Image className="w-5 h-5 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
{file.name}
</p>
<p className="text-xs text-neutral-500">
{formatFileSize(file.size)}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="p-1 hover:bg-neutral-200 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* Upload Button */}
<div className="flex justify-end">
<Button
variant="primary"
onClick={handleUpload}
disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
>
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
</Button>
</div>
{/* Progress Bar */}
{isUploading && (
<div className="mt-4">
<div className="flex justify-between text-sm text-neutral-600 mb-1">
<span>
{t('upload.uploading')}
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 mt-1">
{t('upload.uploadingChunks') || `Uploading ${selectedFiles.length} files in ${totalChunks} batches...`}
</p>
)}
</div>
)}
</div>
);
};
PhotoUpload.displayName = 'PhotoUpload';
@@ -0,0 +1,59 @@
import React from 'react';
import { X } from 'lucide-react';
import { Button } from '../common';
import { PhotoUpload } from './PhotoUpload';
import { useTranslation } from 'react-i18next';
interface PhotoUploadModalProps {
isOpen: boolean;
onClose: () => void;
eventId: number;
onUploadComplete?: () => void;
}
export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
isOpen,
onClose,
eventId,
onUploadComplete
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const handleUploadComplete = () => {
if (onUploadComplete) {
onUploadComplete();
}
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
{/* Fixed Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<h2 className="text-xl font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="!p-1"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6">
<PhotoUpload
eventId={eventId}
onUploadComplete={handleUploadComplete}
/>
</div>
</div>
</div>
);
};
PhotoUploadModal.displayName = 'PhotoUploadModal';
@@ -0,0 +1,342 @@
import React, { useState, useEffect, useRef } from 'react';
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
import { buildResourceUrl } from '../../utils/url';
interface ThemeCustomizerProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
presetName?: string;
onPresetChange?: (presetName: string) => void;
}
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
value,
onChange,
presetName = 'default',
onPresetChange
}) => {
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalTheme(value);
setCustomCss(value.customCss || '');
}, [value]);
useEffect(() => {
setSelectedPreset(presetName);
}, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
// Always propagate changes to parent, not just in preview mode
onChange(updated);
};
const handlePresetSelect = (presetKey: string) => {
const preset = GALLERY_THEME_PRESETS[presetKey];
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
if (onPresetChange) {
onPresetChange(presetKey);
}
// Always propagate preset changes
onChange(preset.config);
}
};
const handleApply = () => {
const themeWithCss = { ...localTheme, customCss };
setLocalTheme(themeWithCss);
onChange(themeWithCss);
};
const handleReset = () => {
const defaultPreset = GALLERY_THEME_PRESETS['default'];
if (defaultPreset) {
setSelectedPreset('default');
setLocalTheme(defaultPreset.config);
setCustomCss('');
onChange(defaultPreset.config);
if (onPresetChange) {
onPresetChange('default');
}
}
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
// Upload to server
const logoUrl = await settingsService.uploadLogo(file);
// Update theme with the server URL
handleChange('logoUrl', logoUrl);
toast.success('Logo uploaded successfully');
} catch (error) {
console.error('Failed to upload logo:', error);
toast.error('Failed to upload logo');
}
}
};
return (
<div className="space-y-6">
{/* Preset Themes */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Preset Themes</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
<button
key={key}
onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all ${
selectedPreset === key
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
{selectedPreset === key && (
<Check className="w-4 h-4 text-primary-600" />
)}
</div>
<div className="flex gap-2">
<div
className="w-6 h-6 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.primaryColor }}
/>
<div
className="w-6 h-6 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.accentColor }}
/>
<div
className="w-6 h-6 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
</div>
</button>
))}
</div>
</Card>
{/* Color Customization */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Colors</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Primary Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
placeholder="#5C8762"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Accent Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
placeholder="#22c55e"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Background Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
placeholder="#fafafa"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Text Color
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300"
/>
<Input
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
placeholder="#171717"
className="flex-1"
/>
</div>
</div>
</div>
</Card>
{/* Typography & Style */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Typography & Style</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Font Family
</label>
<select
value={localTheme.fontFamily || 'Inter, sans-serif'}
onChange={(e) => handleChange('fontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="Inter, sans-serif">Inter (Default)</option>
<option value="Georgia, serif">Georgia (Elegant)</option>
<option value="Helvetica, Arial, sans-serif">Helvetica (Clean)</option>
<option value="'Playfair Display', serif">Playfair Display (Sophisticated)</option>
<option value="'Comic Sans MS', cursive">Comic Sans (Playful)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Border Radius
</label>
<div className="flex gap-2">
{(['none', 'sm', 'md', 'lg'] as const).map((radius) => (
<button
key={radius}
onClick={() => handleChange('borderRadius', radius)}
className={`px-4 py-2 rounded-lg border-2 transition-all ${
localTheme.borderRadius === radius
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
{radius === 'none' ? 'None' : radius.toUpperCase()}
</button>
))}
</div>
</div>
</div>
</Card>
{/* Logo Upload */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Branding</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Custom Logo
</label>
<div className="flex items-center gap-4">
{localTheme.logoUrl && (
<img
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : buildResourceUrl(localTheme.logoUrl)}
alt="Custom logo"
className="h-16 w-auto object-contain"
/>
)}
<input
ref={logoInputRef}
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => logoInputRef.current?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Logo
</Button>
{localTheme.logoUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleChange('logoUrl', '')}
>
Remove
</Button>
)}
</div>
</div>
</div>
</Card>
{/* Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Custom CSS</h3>
<textarea
value={customCss}
onChange={(e) => setCustomCss(e.target.value)}
placeholder="/* Add custom CSS here */"
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<p className="mt-2 text-sm text-neutral-600">
Advanced: Add custom CSS to further customize the appearance
</p>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
Reset to Default
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
Apply Theme
</Button>
</div>
</div>
);
};
@@ -0,0 +1,597 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
interface ThemeCustomizerEnhancedProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
presetName?: string;
onPresetChange?: (presetName: string) => void;
isPreviewMode?: boolean;
showGalleryLayouts?: boolean;
hideActions?: boolean;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-5 h-5" />,
masonry: <Layers className="w-5 h-5" />,
carousel: <Play className="w-5 h-5" />,
timeline: <Clock className="w-5 h-5" />,
hero: <Image className="w-5 h-5" />,
mosaic: <LayoutGrid className="w-5 h-5" />
};
// Layout descriptions will use translation keys
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
value,
onChange,
presetName = 'default',
onPresetChange,
isPreviewMode = false,
showGalleryLayouts = true,
hideActions = false
}) => {
const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
// const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalTheme(value);
setCustomCss(value.customCss || '');
}, [value]);
useEffect(() => {
setSelectedPreset(presetName);
}, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
// When any change is made, mark it as custom
if (selectedPreset !== 'custom' && onPresetChange) {
setSelectedPreset('custom');
onPresetChange('custom');
}
if (isPreviewMode) {
onChange(updated);
}
};
const handlePresetSelect = (presetKey: string) => {
const preset = GALLERY_THEME_PRESETS[presetKey];
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
if (onPresetChange) {
onPresetChange(presetKey);
}
if (isPreviewMode) {
onChange(preset.config);
}
}
};
const handleApply = () => {
onChange({ ...localTheme, customCss });
};
const handleReset = () => {
const defaultPreset = GALLERY_THEME_PRESETS['default'];
if (defaultPreset) {
setSelectedPreset('default');
setLocalTheme(defaultPreset.config);
setCustomCss('');
onChange(defaultPreset.config);
if (onPresetChange) {
onPresetChange('default');
}
}
};
// const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
// const file = e.target.files?.[0];
// if (file) {
// try {
// const logoUrl = await settingsService.uploadLogo(file);
// handleChange('logoUrl', logoUrl);
// toast.success('Logo uploaded successfully');
// } catch (error) {
// console.error('Failed to upload logo:', error);
// toast.error('Failed to upload logo');
// }
// }
// };
const updateGallerySettings = (key: string, value: any) => {
const updatedSettings = {
...localTheme.gallerySettings,
[key]: value
};
handleChange('gallerySettings', updatedSettings);
};
return (
<div className="space-y-6">
{/* Preset Themes */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Sparkles className="w-5 h-5" />
{t('branding.themePresets')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
<button
key={key}
onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
selectedPreset === key
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<span className="font-medium text-sm block">{theme.name}</span>
{theme.description && (
<span className="text-xs text-neutral-600 mt-1 block">{theme.description}</span>
)}
</div>
{selectedPreset === key && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-3">
<div className="flex gap-1">
<div
className="w-5 h-5 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.primaryColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.accentColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
</div>
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
<div className="ml-auto text-neutral-400">
{layoutIcons[theme.config.galleryLayout]}
</div>
)}
</div>
</button>
))}
</div>
</Card>
{/* Gallery Layout */}
{showGalleryLayouts && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Layout className="w-5 h-5" />
{t('branding.galleryLayout')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
<button
key={layout}
onClick={() => handleChange('galleryLayout', layout)}
className={`relative p-4 rounded-lg border-2 transition-all ${
localTheme.galleryLayout === layout
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700">
{layoutIcons[layout]}
</div>
<span className="font-medium text-sm capitalize">{layout}</span>
<span className="text-xs text-neutral-600 mt-1">
{t(`branding.layoutDescriptions.${layout}`)}
</span>
</div>
{localTheme.galleryLayout === layout && (
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
)}
</button>
))}
</div>
{/* Layout-specific settings */}
{localTheme.galleryLayout && (
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200">
<h4 className="font-medium text-sm text-neutral-700">{t('branding.layoutSettings')}</h4>
{/* Common settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.photoSpacing')}
</label>
<select
value={localTheme.gallerySettings?.spacing || 'normal'}
onChange={(e) => updateGallerySettings('spacing', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="tight">{t('branding.spacing.tight')}</option>
<option value="normal">{t('branding.spacing.normal')}</option>
<option value="relaxed">{t('branding.spacing.relaxed')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.photoAnimation')}
</label>
<select
value={localTheme.gallerySettings?.photoAnimation || 'fade'}
onChange={(e) => updateGallerySettings('photoAnimation', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="none">{t('branding.animation.none')}</option>
<option value="fade">{t('branding.animation.fade')}</option>
<option value="scale">{t('branding.animation.scale')}</option>
<option value="slide">{t('branding.animation.slide')}</option>
</select>
</div>
</div>
{/* Grid specific */}
{localTheme.galleryLayout === 'grid' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.columns')}
</label>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-neutral-600">{t('branding.mobile')}</label>
<Input
type="number"
min="1"
max="4"
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
mobile: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600">{t('branding.tablet')}</label>
<Input
type="number"
min="2"
max="6"
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
tablet: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600">{t('branding.desktop')}</label>
<Input
type="number"
min="3"
max="8"
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
desktop: parseInt(e.target.value)
})}
/>
</div>
</div>
</div>
)}
{/* Carousel specific */}
{localTheme.galleryLayout === 'carousel' && (
<>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={localTheme.gallerySettings?.carouselAutoplay || false}
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
className="rounded"
/>
<span className="text-sm font-medium text-neutral-700">{t('branding.enableAutoplay')}</span>
</label>
</div>
{localTheme.gallerySettings?.carouselAutoplay && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.autoplayInterval')}
</label>
<Input
type="number"
min="2"
max="10"
value={(localTheme.gallerySettings?.carouselInterval || 5000) / 1000}
onChange={(e) => updateGallerySettings('carouselInterval', parseInt(e.target.value) * 1000)}
/>
</div>
)}
</>
)}
{/* Timeline specific */}
{localTheme.galleryLayout === 'timeline' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.groupPhotosBy')}
</label>
<select
value={localTheme.gallerySettings?.timelineGrouping || 'day'}
onChange={(e) => updateGallerySettings('timelineGrouping', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="day">{t('branding.grouping.day')}</option>
<option value="week">{t('branding.grouping.week')}</option>
<option value="month">{t('branding.grouping.month')}</option>
</select>
</div>
)}
</div>
)}
</Card>
)}
{/* Color Customization */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('branding.colors')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.primaryColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
/>
<Input
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
placeholder="#5C8762"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.accentColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
/>
<Input
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
placeholder="#22c55e"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.backgroundColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
/>
<Input
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
placeholder="#fafafa"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.textColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
/>
<Input
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
placeholder="#171717"
className="flex-1"
/>
</div>
</div>
</div>
</Card>
{/* Typography & Style */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Type className="w-5 h-5" />
{t('branding.typographyAndStyle')}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.bodyFont')}
</label>
<select
value={localTheme.fontFamily || 'Inter, sans-serif'}
onChange={(e) => handleChange('fontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="Inter, sans-serif">Inter</option>
<option value="system-ui, sans-serif">System UI</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'Montserrat', sans-serif">Montserrat</option>
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
<option value="'Comic Neue', cursive">Comic Neue</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.headingFont')}
</label>
<select
value={localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'}
onChange={(e) => handleChange('headingFontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="">{t('branding.sameAsBody')}</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'Montserrat', sans-serif">Montserrat</option>
<option value="Georgia, serif">Georgia</option>
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.fontSize')}
</label>
<select
value={localTheme.fontSize || 'normal'}
onChange={(e) => handleChange('fontSize', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="small">{t('branding.fontSizes.small')}</option>
<option value="normal">{t('branding.fontSizes.normal')}</option>
<option value="large">{t('branding.fontSizes.large')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.borderRadius')}
</label>
<select
value={localTheme.borderRadius || 'md'}
onChange={(e) => handleChange('borderRadius', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="none">{t('branding.borderRadiusOptions.none')}</option>
<option value="sm">{t('branding.borderRadiusOptions.small')}</option>
<option value="md">{t('branding.borderRadiusOptions.medium')}</option>
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.shadowStyle')}
</label>
<select
value={localTheme.shadowStyle || 'normal'}
onChange={(e) => handleChange('shadowStyle', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="none">{t('branding.shadowOptions.none')}</option>
<option value="subtle">{t('branding.shadowOptions.subtle')}</option>
<option value="normal">{t('branding.shadowOptions.normal')}</option>
<option value="dramatic">{t('branding.shadowOptions.dramatic')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.backgroundPattern')}
</label>
<select
value={localTheme.backgroundPattern || 'none'}
onChange={(e) => handleChange('backgroundPattern', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="none">{t('branding.backgroundOptions.none')}</option>
<option value="dots">{t('branding.backgroundOptions.dots')}</option>
<option value="grid">{t('branding.backgroundOptions.grid')}</option>
<option value="waves">{t('branding.backgroundOptions.waves')}</option>
</select>
</div>
</div>
</div>
</Card>
{/* Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3>
<textarea
value={customCss}
onChange={(e) => {
setCustomCss(e.target.value);
// Mark as custom when CSS is added
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
setSelectedPreset('custom');
onPresetChange('custom');
}
}}
placeholder="/* Add custom CSS here */"
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg"
/>
<p className="mt-2 text-sm text-neutral-600">
{t('branding.customCSSHelp')}
</p>
</Card>
{/* Actions */}
{!hideActions && (
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
{t('branding.resetToDefault')}
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
{t('branding.applyTheme')}
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,161 @@
import React from 'react';
import {
Palette,
Type,
Grid3X3,
Layers,
Play,
Clock,
Image,
LayoutGrid,
Layout
} from 'lucide-react';
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { useTranslation } from 'react-i18next';
interface ThemeDisplayProps {
theme: ThemeConfig | string;
presetName?: string;
className?: string;
showDetails?: boolean;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-4 h-4" />,
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />
};
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
theme,
presetName,
className = '',
showDetails = true
}) => {
const { t } = useTranslation();
// Parse theme if it's a string
let themeConfig: ThemeConfig | null = null;
let themeName = t('branding.theme');
if (typeof theme === 'string') {
try {
if (theme.startsWith('{')) {
themeConfig = JSON.parse(theme);
} else {
// Legacy theme name - find matching preset
const preset = Object.entries(GALLERY_THEME_PRESETS).find(([key]) => key === theme);
if (preset) {
themeConfig = preset[1].config;
themeName = preset[1].name;
}
}
} catch (e) {
console.error('Failed to parse theme:', e);
}
} else {
themeConfig = theme;
}
// If we have a preset name, use its display name
if (presetName && GALLERY_THEME_PRESETS[presetName]) {
themeName = GALLERY_THEME_PRESETS[presetName].name;
}
if (!themeConfig) {
return (
<div className={`text-sm text-neutral-500 ${className}`}>
{t('events.noThemeSet')}
</div>
);
}
const galleryLayout = themeConfig.galleryLayout || 'grid';
return (
<div className={`space-y-3 ${className}`}>
{/* Theme Name & Layout */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layout className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">{themeName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-neutral-600">
{layoutIcons[galleryLayout]}
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
</div>
</div>
{showDetails && (
<>
{/* Color Palette */}
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-neutral-500" />
<span className="text-sm text-neutral-600">{t('branding.colors')}:</span>
<div className="flex gap-1">
{themeConfig.primaryColor && (
<div
className="w-6 h-6 rounded border border-neutral-300"
style={{ backgroundColor: themeConfig.primaryColor }}
title={t('branding.primaryColor')}
/>
)}
{themeConfig.accentColor && (
<div
className="w-6 h-6 rounded border border-neutral-300"
style={{ backgroundColor: themeConfig.accentColor }}
title={t('branding.accentColor')}
/>
)}
{themeConfig.backgroundColor && (
<div
className="w-6 h-6 rounded border border-neutral-300"
style={{ backgroundColor: themeConfig.backgroundColor }}
title={t('branding.backgroundColor')}
/>
)}
</div>
</div>
{/* Typography */}
{themeConfig.fontFamily && (
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-neutral-500" />
<span className="text-sm text-neutral-600">{t('branding.bodyFont')}:</span>
<span className="text-sm font-medium" style={{ fontFamily: themeConfig.fontFamily }}>
{themeConfig.fontFamily}
</span>
</div>
)}
{/* Layout Settings */}
{themeConfig.gallerySettings && (
<div className="text-sm text-neutral-600">
{themeConfig.gallerySettings.spacing && (
<span className="inline-flex items-center gap-1 mr-3">
<span>{t('branding.photoSpacing')}:</span>
<span className="font-medium capitalize">
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
</span>
</span>
)}
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
<span className="inline-flex items-center gap-1">
<span>{t('branding.photoAnimation')}:</span>
<span className="font-medium capitalize">
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
</span>
</span>
)}
</div>
)}
</>
)}
</div>
);
};
ThemeDisplay.displayName = 'ThemeDisplay';
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Check } from 'lucide-react';
import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { useTranslation } from 'react-i18next';
interface ThemeEditorModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (theme: ThemeConfig, presetName: string) => void;
currentTheme: ThemeConfig | string;
eventName: string;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-4 h-4" />,
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />
};
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
isOpen,
onClose,
onSave,
currentTheme,
eventName
}) => {
const { t } = useTranslation();
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
const [presetName, setPresetName] = useState<string>('default');
const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined);
useEffect(() => {
if (currentTheme) {
if (typeof currentTheme === 'string') {
try {
if (currentTheme.startsWith('{')) {
const parsedTheme = JSON.parse(currentTheme);
setTheme(parsedTheme);
// Try to find matching preset
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
);
setPresetName(matchingPreset ? matchingPreset[0] : 'custom');
} else {
// Legacy theme name
const preset = GALLERY_THEME_PRESETS[currentTheme];
if (preset) {
setTheme(preset.config);
setPresetName(currentTheme);
}
}
} catch (e) {
console.error('Failed to parse theme:', e);
setTheme(GALLERY_THEME_PRESETS.default.config);
setPresetName('default');
}
} else {
setTheme(currentTheme);
setPresetName('custom');
}
}
}, [currentTheme]);
const handleThemeChange = (newTheme: ThemeConfig) => {
setTheme(newTheme);
};
const handlePresetChange = (newPresetName: string) => {
setPresetName(newPresetName);
if (newPresetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[newPresetName];
if (preset) {
setTheme(preset.config);
}
}
};
const handleSave = () => {
onSave(theme, presetName);
onClose();
};
const handleReset = () => {
const defaultPreset = GALLERY_THEME_PRESETS.default;
setTheme(defaultPreset.config);
setPresetName('default');
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{t('events.galleryTheme')}
</h2>
<p className="text-sm text-neutral-600 mt-1">
{t('events.customizingThemeFor', { event: eventName })}
</p>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
{/* Left side - Theme Customizer */}
<div className="p-6 overflow-y-auto border-r border-neutral-200">
<ThemeCustomizerEnhanced
value={theme}
onChange={handleThemeChange}
presetName={presetName}
onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
/>
</div>
{/* Right side - Gallery Preview */}
<div className="p-6 bg-neutral-50 overflow-y-auto">
<div className="space-y-4">
{/* Grid Style Selector */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.previewLayout')}
</h3>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
<button
key={layout}
onClick={() => setPreviewLayout(layout)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(previewLayout || theme.galleryLayout || 'grid') === layout
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300 bg-white'
}`}
>
<div className="flex flex-col items-center gap-1">
<div className="text-neutral-700">
{layoutIcons[layout]}
</div>
<span className="text-xs capitalize">{layout}</span>
</div>
{(previewLayout || theme.galleryLayout || 'grid') === layout && (
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
)}
</button>
))}
</div>
</div>
{/* Gallery Preview */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={theme}
layoutType={previewLayout}
className="shadow-lg"
/>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-neutral-200 flex items-center justify-between">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
{t('branding.resetToDefault')}
</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSave}
>
{t('branding.saveTheme')}
</Button>
</div>
</div>
</div>
</div>
);
};
ThemeEditorModal.displayName = 'ThemeEditorModal';
@@ -0,0 +1,45 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
import { api } from '../../config/api';
import packageJson from '../../../package.json';
// Frontend version from package.json
const FRONTEND_VERSION = packageJson.version;
interface SystemVersion {
backend: string;
frontend: string;
node: string;
environment: string;
}
async function fetchSystemVersion(): Promise<SystemVersion> {
const response = await api.get<SystemVersion>('/admin/system/version');
return response.data;
}
export const VersionInfo: React.FC = () => {
const { t } = useTranslation();
const { data: versionInfo } = useQuery({
queryKey: ['system-version'],
queryFn: fetchSystemVersion,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
return (
<div className="px-4 py-3 border-t border-neutral-200">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<Info className="w-3 h-3" />
<span className="font-medium">{t('admin.version')}</span>
</div>
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
<div>Frontend: v{FRONTEND_VERSION}</div>
{versionInfo && (
<div>Backend: v{versionInfo.backend}</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,64 @@
import React from 'react';
import { HelpCircle } from 'lucide-react';
interface WelcomeMessageEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
value,
onChange,
placeholder,
rows = 6
}) => {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};
// Convert newlines to <br> tags for preview
const getPreviewHtml = () => {
return value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('<br />');
};
return (
<div className="space-y-2">
<div className="relative">
<textarea
value={value}
onChange={handleChange}
placeholder={placeholder}
rows={rows}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
/>
<div className="absolute top-2 right-2 text-neutral-400">
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
</div>
</div>
<div className="text-xs text-neutral-500">
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
</div>
{value && (
<div className="mt-4">
<p className="text-sm font-medium text-neutral-700 mb-2">Preview:</p>
<div className="p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<div
className="text-sm text-neutral-700 whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
/>
</div>
</div>
)}
</div>
);
};
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
+25
View File
@@ -0,0 +1,25 @@
export { AdminLayout } from './AdminLayout';
export { AdminSidebar } from './AdminSidebar';
export { AdminHeader } from './AdminHeader';
export { ThemeCustomizer } from './ThemeCustomizer';
export { PasswordChangeModal } from './PasswordChangeModal';
export { AdminAuthWrapper } from './AdminAuthWrapper';
export { PhotoUpload } from './PhotoUpload';
export { CategoryManager } from './CategoryManager';
export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
export { BulkArchiveModal } from './BulkArchiveModal';
export { MaintenanceBanner } from './MaintenanceBanner';
export { EmailPreviewModal } from './EmailPreviewModal';
export { AdminPhotoGrid } from './AdminPhotoGrid';
export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal';
export { HeroPhotoSelector } from './HeroPhotoSelector';
export { PhotoUploadModal } from './PhotoUploadModal';
export { GalleryPreview } from './GalleryPreview';
@@ -0,0 +1,122 @@
import React, { useState, useEffect } from 'react';
import { getAuthToken } from '../../config/api';
import { buildResourceUrl } from '../../utils/url';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
isGallery?: boolean;
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
src,
fallbackSrc,
alt,
useWatermark = false,
isGallery = false,
...props
}) => {
const [imageSrc, setImageSrc] = useState<string>('');
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let objectUrl: string | null = null;
// Determine which token to use based on context
let token: string | undefined;
if (isGallery) {
// For gallery images, get the gallery-specific token
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
token = localStorage.getItem(`gallery_token_${gallerySlug}`) || undefined;
}
} else {
// For admin images, use the admin token
token = getAuthToken(true);
}
if (!src) {
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
}
if (!token) {
// No auth token - use fallback
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
}
setIsLoading(true);
setError(false);
// Create a new URL with auth header
const fetchImage = async () => {
try {
// Use the src as-is since it should already be the correct endpoint
let imageUrl = src;
// Build full URL for the image
// For API paths that start with /admin, we need to prepend /api
const fullImageUrl = imageUrl.startsWith('/admin')
? buildResourceUrl(`/api${imageUrl}`)
: imageUrl.startsWith('/')
? buildResourceUrl(imageUrl)
: imageUrl;
// Fetch authenticated image
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageSrc(objectUrl);
setIsLoading(false);
} catch (err) {
// Image loading failed - use fallback
setError(true);
setImageSrc(fallbackSrc || '');
setIsLoading(false);
}
};
fetchImage();
// Cleanup function
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src, fallbackSrc, useWatermark, isGallery]);
if (isLoading) {
return (
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
{/* Show a placeholder while loading */}
</div>
);
}
if (error && fallbackSrc) {
return <img src={fallbackSrc} alt={alt} {...props} />;
}
if (!imageSrc) {
return null;
}
return <img src={imageSrc} alt={alt} {...props} />;
};
+70
View File
@@ -0,0 +1,70 @@
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}
aria-busy={isLoading}
aria-disabled={disabled || isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-label="Loading" />
) : (
leftIcon && <span className="mr-2" aria-hidden="true">{leftIcon}</span>
)}
{children}
{!isLoading && rightIcon && <span className="ml-2" aria-hidden="true">{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>
);
};
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
useEffect(() => {
if (settings?.branding_favicon_url) {
// Remove existing favicon links
const existingFavicons = document.querySelectorAll("link[rel*='icon']");
existingFavicons.forEach(favicon => favicon.remove());
// Create new favicon link
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
: buildResourceUrl(settings.branding_favicon_url);
document.head.appendChild(link);
}
}, [settings?.branding_favicon_url]);
return null;
};
@@ -0,0 +1,136 @@
import React, { Component } from 'react';
import type { ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from './Button';
import i18n from '../../i18n/config';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
console.error('Component stack:', errorInfo.componentStack);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return <>{this.props.fallback}</>;
}
return (
<div className="min-h-[400px] flex items-center justify-center p-4">
<div className="text-center max-w-md">
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
{i18n.t('errors.somethingWentWrong')}
</h2>
<p className="text-sm text-neutral-600 mb-6">
{this.state.error?.message || i18n.t('errors.tryAgainLater')}
</p>
<Button
onClick={this.handleReset}
leftIcon={<RefreshCw className="w-4 h-4" />}
>
{i18n.t('errors.refreshPage')}
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
// Page-level error boundary with more prominent UI
export class PageErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Page error:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.href = '/';
};
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-6" />
<h1 className="text-2xl font-bold text-neutral-900 mb-4">
{i18n.t('errors.oopsSomethingWentWrong')}
</h1>
<p className="text-neutral-600 mb-8">
{i18n.t('errors.unexpectedError')}
</p>
<div className="space-y-3">
<Button
variant="primary"
onClick={this.handleReset}
leftIcon={<RefreshCw className="w-4 h-4" />}
className="w-full"
>
{i18n.t('errors.goToHomepage')}
</Button>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="w-full"
>
{i18n.t('gallery.tryAgain')}
</Button>
</div>
{import.meta.env.DEV && this.state.error && (
<details className="mt-8 text-left">
<summary className="text-sm text-neutral-500 cursor-pointer hover:text-neutral-700">
{i18n.t('errors.errorDetails')}
</summary>
<pre className="mt-2 text-xs bg-neutral-100 p-3 rounded overflow-auto">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
+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">
<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,54 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
const languages = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
];
export const LanguageSelector: React.FC = () => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = React.useState(false);
const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0];
const handleLanguageChange = (languageCode: string) => {
i18n.changeLanguage(languageCode);
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<Globe className="w-4 h-4" />
<span>{currentLanguage.flag}</span>
<span>{currentLanguage.name}</span>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
{languages.map((language) => (
<button
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
language.code === i18n.language
? 'text-primary-600 bg-primary-50'
: 'text-neutral-700'
}`}
>
<span className="text-lg">{language.flag}</span>
<span>{language.name}</span>
</button>
))}
</div>
)}
</div>
);
};
LanguageSelector.displayName = 'LanguageSelector';
@@ -0,0 +1,77 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import { clsx } from 'clsx';
interface LoadingProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
fullScreen?: boolean;
className?: string;
}
export const Loading: React.FC<LoadingProps> = ({
size = 'md',
text,
fullScreen = false,
className,
}) => {
const sizeStyles = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
};
const content = (
<div className={clsx('flex flex-col items-center justify-center', className)}>
<Loader2 className={clsx('animate-spin text-primary-600', sizeStyles[size])} />
{text && (
<p className="mt-4 text-sm text-neutral-600">{text}</p>
)}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-50">
{content}
</div>
);
}
return content;
};
interface LoadingSkeletonProps {
className?: string;
count?: number;
type?: 'text' | 'card' | 'image';
}
export const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
className,
count = 1,
type = 'text',
}) => {
const baseStyles = 'skeleton';
const typeStyles = {
text: 'h-4 w-full rounded',
card: 'h-32 w-full rounded-xl',
image: 'aspect-square w-full rounded-lg',
};
return (
<>
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className={clsx(
baseStyles,
typeStyles[type],
className
)}
/>
))}
</>
);
};
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { WifiOff, Wifi } from 'lucide-react';
import { cn } from '../../lib/utils';
export const OfflineIndicator: React.FC = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [showIndicator, setShowIndicator] = useState(false);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
// Show "back online" message briefly
setShowIndicator(true);
setTimeout(() => setShowIndicator(false), 3000);
};
const handleOffline = () => {
setIsOnline(false);
setShowIndicator(true);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Check initial state
if (!navigator.onLine) {
setShowIndicator(true);
}
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (!showIndicator) return null;
return (
<div
className={cn(
'fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-auto z-50',
'transition-all duration-300 ease-in-out',
isOnline ? 'translate-y-0' : 'translate-y-0'
)}
role="status"
aria-live="polite"
>
<div
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg',
isOnline
? 'bg-green-50 border border-green-200 text-green-900'
: 'bg-red-50 border border-red-200 text-red-900'
)}
>
{isOnline ? (
<>
<Wifi className="w-5 h-5" />
<span className="text-sm font-medium">Back online</span>
</>
) : (
<>
<WifiOff className="w-5 h-5" />
<span className="text-sm font-medium">No internet connection</span>
</>
)}
</div>
</div>
);
};
// Hook to monitor online status
export const useOnlineStatus = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleStatusChange = () => {
setIsOnline(navigator.onLine);
};
window.addEventListener('online', handleStatusChange);
window.addEventListener('offline', handleStatusChange);
return () => {
window.removeEventListener('online', handleStatusChange);
window.removeEventListener('offline', handleStatusChange);
};
}, []);
return isOnline;
};
@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
interface ReCaptchaProps {
onChange: (token: string | null) => void;
onExpired?: () => void;
size?: 'normal' | 'compact';
}
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
onExpired,
size = 'normal'
}) => {
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
// Fetch public settings to get reCAPTCHA site key
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
return response.json();
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
useEffect(() => {
if (settings?.recaptcha_site_key) {
setSiteKey(settings.recaptcha_site_key);
}
}, [settings]);
// If reCAPTCHA is not enabled or site key is not available, return null
if (!settings?.enable_recaptcha || !siteKey) {
return null;
}
return (
<div className="flex justify-center">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={siteKey}
onChange={onChange}
onExpired={onExpired}
size={size}
theme="light"
/>
</div>
);
};
export default ReCaptcha;
+147
View File
@@ -0,0 +1,147 @@
import React from 'react';
import { cn } from '../../lib/utils';
interface SkeletonProps {
className?: string;
variant?: 'text' | 'circular' | 'rectangular';
width?: string | number;
height?: string | number;
animation?: 'pulse' | 'wave' | 'none';
}
export const Skeleton: React.FC<SkeletonProps> = ({
className,
variant = 'rectangular',
width,
height,
animation = 'pulse'
}) => {
const baseClasses = 'bg-neutral-200';
const animationClasses = {
pulse: 'animate-pulse',
wave: 'animate-shimmer',
none: ''
};
const variantClasses = {
text: 'rounded',
circular: 'rounded-full',
rectangular: 'rounded-lg'
};
const style: React.CSSProperties = {};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
return (
<div
className={cn(
baseClasses,
animationClasses[animation],
variantClasses[variant],
className
)}
style={style}
aria-busy="true"
aria-live="polite"
/>
);
};
// Skeleton group for consistent loading states
interface SkeletonGroupProps {
count?: number;
className?: string;
children?: React.ReactNode;
}
export const SkeletonGroup: React.FC<SkeletonGroupProps> = ({
count = 1,
className,
children
}) => {
if (children) {
return <div className={cn('space-y-3', className)}>{children}</div>;
}
return (
<div className={cn('space-y-3', className)}>
{Array.from({ length: count }).map((_, index) => (
<Skeleton key={index} height={20} />
))}
</div>
);
};
// Common skeleton patterns
export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) => (
<div className={cn('bg-white rounded-lg shadow-sm p-6', className)}>
<Skeleton height={24} width="60%" className="mb-4" />
<SkeletonGroup count={3} />
<div className="flex gap-3 mt-6">
<Skeleton width={100} height={36} />
<Skeleton width={100} height={36} />
</div>
</div>
);
export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
rows = 5,
className
}) => (
<div className={cn('bg-white rounded-lg shadow-sm overflow-hidden', className)}>
<div className="border-b border-neutral-200 p-4">
<div className="flex gap-4">
<Skeleton width="30%" height={20} />
<Skeleton width="25%" height={20} />
<Skeleton width="20%" height={20} />
<Skeleton width="25%" height={20} />
</div>
</div>
<div className="divide-y divide-neutral-100">
{Array.from({ length: rows }).map((_, index) => (
<div key={index} className="p-4">
<div className="flex gap-4">
<Skeleton width="30%" height={16} />
<Skeleton width="25%" height={16} />
<Skeleton width="20%" height={16} />
<Skeleton width="25%" height={16} />
</div>
</div>
))}
</div>
</div>
);
export const SkeletonGalleryGrid: React.FC<{ count?: number; className?: string }> = ({
count = 12,
className
}) => (
<div className={cn('gallery-grid', className)}>
{Array.from({ length: count }).map((_, index) => (
<Skeleton
key={index}
variant="rectangular"
className="aspect-square w-full"
/>
))}
</div>
);
export const SkeletonList: React.FC<{ count?: number; className?: string }> = ({
count = 5,
className
}) => (
<div className={cn('space-y-4', className)}>
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="flex items-center gap-4">
<Skeleton variant="circular" width={48} height={48} />
<div className="flex-1">
<Skeleton height={20} width="70%" className="mb-2" />
<Skeleton height={16} width="40%" />
</div>
</div>
))}
</div>
);
@@ -0,0 +1,12 @@
import React from 'react';
export const SkipLink: React.FC = () => {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-primary-600 text-white px-4 py-2 rounded-lg z-50 focus:outline-none focus:ring-2 focus:ring-primary-700"
>
Skip to main content
</a>
);
};
+19
View File
@@ -0,0 +1,19 @@
export { Button } from './Button';
export { Input } from './Input';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading';
export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary';
export {
Skeleton,
SkeletonGroup,
SkeletonCard,
SkeletonTable,
SkeletonGalleryGrid,
SkeletonList
} from './Skeleton';
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage';
export { ReCaptcha } from './ReCaptcha';
@@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import { Clock, AlertCircle } from 'lucide-react';
import { differenceInSeconds } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface CountdownTimerProps {
expiresAt: string;
className?: string;
}
export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, className = '' }) => {
const { t } = useTranslation();
const [timeLeft, setTimeLeft] = useState<{
hours: number;
minutes: number;
seconds: number;
isExpired: boolean;
}>({ hours: 0, minutes: 0, seconds: 0, isExpired: false });
useEffect(() => {
const calculateTimeLeft = () => {
const expirationDate = new Date(expiresAt);
const now = new Date();
if (expirationDate <= now) {
setTimeLeft({ hours: 0, minutes: 0, seconds: 0, isExpired: true });
return;
}
const totalSeconds = differenceInSeconds(expirationDate, now);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
setTimeLeft({ hours, minutes, seconds, isExpired: false });
};
calculateTimeLeft();
const interval = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
if (timeLeft.isExpired) {
return (
<div className={`flex items-center gap-2 text-red-600 ${className}`}>
<AlertCircle className="w-5 h-5" />
<span className="font-semibold">{t('gallery.expired')}</span>
</div>
);
}
// Only show countdown if less than 24 hours remain
if (timeLeft.hours >= 24) {
return null;
}
return (
<div className={`flex items-center gap-3 ${className}`}>
<Clock className="w-5 h-5 text-orange-600 animate-pulse" />
<div className="flex items-center gap-1 font-mono text-lg">
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
{String(timeLeft.hours).padStart(2, '0')}
</div>
<span className="text-orange-600">:</span>
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
{String(timeLeft.minutes).padStart(2, '0')}
</div>
<span className="text-orange-600">:</span>
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
{String(timeLeft.seconds).padStart(2, '0')}
</div>
</div>
<span className="text-sm text-orange-600 font-medium">{t('gallery.remaining')}</span>
</div>
);
};
@@ -0,0 +1,53 @@
import React from 'react';
import { Download, X } from 'lucide-react';
interface DownloadProgressProps {
isDownloading: boolean;
progress?: number;
fileName?: string;
onCancel?: () => void;
}
export const DownloadProgress: React.FC<DownloadProgressProps> = ({
isDownloading,
progress = 0,
fileName,
onCancel,
}) => {
if (!isDownloading) return null;
return (
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg border border-neutral-200 p-4 min-w-[300px] z-50">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
<div>
<p className="text-sm font-medium text-neutral-900">Downloading...</p>
{fileName && (
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
)}
</div>
</div>
{onCancel && (
<button
onClick={onCancel}
className="p-1 hover:bg-neutral-100 rounded transition-colors"
>
<X className="w-4 h-4 text-neutral-500" />
</button>
)}
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{progress > 0 && (
<p className="text-xs text-neutral-500 mt-1">{Math.round(progress)}% complete</p>
)}
</div>
);
};
@@ -0,0 +1,55 @@
import React from 'react';
import { AlertTriangle, Download } from 'lucide-react';
import Countdown from 'react-countdown';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface ExpirationBannerProps {
daysRemaining: number;
expiresAt: string;
}
export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
daysRemaining,
expiresAt
}) => {
const { t } = useTranslation();
const expirationDate = parseISO(expiresAt);
const countdownRenderer = ({ days, hours, minutes, completed }: any) => {
if (completed) {
return <span>{t('gallery.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">
{t('gallery.expiresIn', { count: daysRemaining })} <Countdown date={expirationDate} renderer={countdownRenderer} />
</span>
</div>
<div className="flex items-center text-sm">
<Download className="w-4 h-4 mr-1" />
<span>{t('gallery.downloadBefore')}</span>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,380 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { buildResourceUrl } from '../../utils/url';
interface GalleryLayoutProps {
event: {
event_name: string;
event_type?: string;
event_date?: string;
expires_at?: string;
};
brandingSettings?: {
company_name?: string;
company_tagline?: string;
support_email?: string;
footer_text?: string;
favicon_url?: string;
logo_url?: string;
};
showLogout?: boolean;
onLogout?: () => void;
showDownloadAll?: boolean;
onDownloadAll?: () => void;
isDownloading?: boolean;
headerExtra?: React.ReactNode;
menuButton?: React.ReactNode;
children: React.ReactNode;
}
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
event,
brandingSettings,
showLogout = false,
onLogout,
showDownloadAll = false,
onDownloadAll,
isDownloading = false,
headerExtra,
menuButton,
children,
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero';
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
const headingFontFamily = theme.headingFontFamily || fontFamily;
return (
<div className="min-h-screen bg-neutral-50">
{/* Dynamic Favicon */}
<DynamicFavicon />
{/* Header structure */}
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts (excluding hero) - keep the current structure */}
{isNonGridLayout && (
<div className="bg-neutral-50 border-b border-neutral-200">
<div className="container py-2">
<div className="flex items-center justify-between">
{/* Left side - Menu button and other header extras */}
<div className="flex items-center gap-3">
{menuButton}
{headerExtra}
</div>
{/* Right side - Download and Logout */}
<div className="flex items-center gap-3">
{/* Download all button */}
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
variant="outline"
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
)}
</div>
</div>
</div>
</div>
)}
{/* For grid layout - everything in one bar */}
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
<div className="container py-3">
<div className="flex items-center justify-between gap-2 sm:gap-4">
{/* Left side - Menu button, Logo */}
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
{/* Menu button */}
{menuButton && (
<div className="flex-shrink-0">
{menuButton}
</div>
)}
{/* Logo - Show custom logo or fallback to PicPeak logo */}
<div className="flex-shrink-0">
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
/>
</div>
</div>
{/* Center - Event info */}
<div className="flex-1 min-w-0 text-center sm:text-left">
<h1
className="text-base sm:text-lg lg:text-xl font-bold text-neutral-900 leading-tight truncate"
style={{ fontFamily: headingFontFamily }}
>
{event.event_name}
</h1>
{(event.event_date || event.expires_at) && (
<div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-neutral-600">
{event.event_date && (
<span className="flex items-center">
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
<span>{format(parseISO(event.event_date), 'PP')}</span>
</span>
)}
{event.expires_at && (
<span className="flex items-center">
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
</span>
)}
</div>
)}
</div>
{/* Right side - Action buttons */}
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* Extra header items (upload button, etc.) */}
{headerExtra && (
<div className="hidden sm:block">
{headerExtra}
</div>
)}
{/* Download all button - hidden on mobile when sidebar is shown */}
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="hidden sm:flex"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
variant="outline"
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
)}
</div>
</div>
{/* Mobile dates row */}
{(event.event_date || event.expires_at) && (
<div className="flex sm:hidden justify-center gap-x-3 mt-2 text-xs text-neutral-600">
{event.event_date && (
<span className="flex items-center">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{format(parseISO(event.event_date), 'PP')}</span>
</span>
)}
{event.expires_at && (
<span className="flex items-center">
<Clock className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
</span>
)}
</div>
)}
</div>
)}
{/* For hero layout - minimal header with just menu and logout */}
{theme.galleryLayout === 'hero' && (
<div className="container py-3">
<div className="flex items-center justify-between">
{/* Left side - Menu button */}
<div className="flex items-center gap-3">
{menuButton}
{headerExtra}
</div>
{/* Right side - Action buttons */}
<div className="flex items-center gap-3 flex-shrink-0">
{/* Download all button */}
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
variant="outline"
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
)}
</div>
</div>
</div>
)}
</header>
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
{isNonGridLayout && (
<div
className="relative text-white overflow-hidden"
style={{
backgroundColor: theme.accentColor || '#22c55e',
backgroundImage: theme.backgroundPattern !== 'none'
? `url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E")`
: undefined
}}
>
<div className="container py-12 sm:py-16 lg:py-20 relative z-10">
<div className="text-center max-w-4xl mx-auto">
{/* Logo - Show custom logo or fallback to PicPeak logo */}
<div className="mb-6">
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto"
style={{
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
/>
</div>
{/* Event Name */}
<h1
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
style={{
fontFamily: headingFontFamily,
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
}}
>
{event.event_name}
</h1>
{/* Event Details */}
{(event.event_date || event.expires_at) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
{event.event_date && (
<span className="flex items-center text-lg">
<Calendar className="w-5 h-5 mr-2" />
{format(parseISO(event.event_date), 'PP')}
</span>
)}
{event.expires_at && (
<span className="flex items-center text-lg">
<Clock className="w-5 h-5 mr-2" />
{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}
</span>
)}
</div>
)}
</div>
</div>
{/* Decorative bottom wave */}
<div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z"
fill="var(--color-background, #fafafa)" />
</svg>
</div>
</div>
)}
{/* Main Content */}
<main className="container">{children}</main>
{/* Footer */}
<footer className="mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
<div className="container text-center px-4">
{brandingSettings?.support_email && (
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
{t('gallery.needHelp')}{' '}
<a
href={`mailto:${brandingSettings.support_email}`}
className="text-primary-600 hover:text-primary-700 break-all"
>
{brandingSettings.support_email}
</a>
</p>
)}
<p className="text-xs sm:text-sm text-neutral-500">
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} | Powered by <span className="font-semibold">PicPeak</span>
</p>
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
<p className="text-xs text-neutral-400 mt-2">
{brandingSettings.company_name} - {brandingSettings.company_tagline}
</p>
)}
{/* Legal Links */}
<div className="mt-4 flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
</div>
</footer>
</div>
);
};
GalleryLayout.displayName = 'GalleryLayout';
@@ -0,0 +1,296 @@
import React, { useEffect, useRef } from 'react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload } from 'lucide-react';
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
interface GallerySidebarProps {
isOpen: boolean;
onClose: () => void;
categories: PhotoCategory[];
selectedCategoryId: number | null;
onCategoryChange: (categoryId: number | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size';
onSortChange: (sort: 'date' | 'name' | 'size') => void;
isSelectionMode: boolean;
onToggleSelectionMode: () => void;
selectedCount: number;
onDownloadAll: () => void;
onDownloadSelected: () => void;
isDownloading: boolean;
photoCounts?: Record<number, number>;
totalPhotos: number;
isMobile: boolean;
galleryLayout?: string;
allowUploads?: boolean;
onUploadClick?: () => void;
}
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
isOpen,
onClose,
categories,
selectedCategoryId,
onCategoryChange,
searchTerm,
onSearchChange,
sortBy,
onSortChange,
isSelectionMode,
onToggleSelectionMode,
selectedCount,
onDownloadAll,
onDownloadSelected,
isDownloading,
photoCounts = {},
totalPhotos,
isMobile,
galleryLayout,
allowUploads,
onUploadClick
}) => {
const { t } = useTranslation();
const sidebarRef = useRef<HTMLDivElement>(null);
// Close sidebar when clicking outside on mobile
useEffect(() => {
if (isMobile && isOpen) {
const handleClickOutside = (event: MouseEvent) => {
if (sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isMobile, isOpen, onClose]);
// Prevent body scroll when sidebar is open on mobile
useEffect(() => {
if (isMobile && isOpen) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}
}, [isMobile, isOpen]);
const sortOptions = [
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }
];
return (
<>
{/* Backdrop for mobile */}
{isMobile && isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
)}
{/* Sidebar */}
<div
ref={sidebarRef}
className={`
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out
${isMobile ? 'w-full max-w-sm' : 'w-80'}
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
aria-label={t('common.close')}
>
<X className="w-5 h-5 text-neutral-600" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Upload Section - Only show on mobile when uploads are allowed */}
{isMobile && allowUploads && onUploadClick && (
<div className="p-4 border-b border-neutral-200">
<Button
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => {
onUploadClick();
onClose();
}}
className="w-full"
>
{t('upload.uploadPhotos')}
</Button>
</div>
)}
{/* Search Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && (
<div className="p-4 border-b border-neutral-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t('gallery.searchPlaceholder')}
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
)}
{/* Download Section */}
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Download className="w-4 h-4" />
{t('gallery.download')}
</h3>
<div className="space-y-2">
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
disabled={isDownloading || totalPhotos === 0}
className="w-full"
>
{t('gallery.downloadAll')} ({totalPhotos})
</Button>
<Button
variant={isSelectionMode ? 'secondary' : 'outline'}
size="sm"
onClick={onToggleSelectionMode}
className="w-full"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{isSelectionMode && selectedCount > 0 && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadSelected}
disabled={isDownloading}
className="w-full"
>
{t('gallery.downloadSelected')} ({selectedCount})
</Button>
)}
</div>
</div>
{/* Categories Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && categories.length > 0 && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('gallery.categories')}
</h3>
<div className="space-y-1">
<button
onClick={() => {
onCategoryChange(null);
if (isMobile) onClose();
}}
className={`
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${selectedCategoryId === null
? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700'
}
`}
>
<span>{t('gallery.allCategories')}</span>
<span className="text-sm text-neutral-500">{totalPhotos}</span>
</button>
{categories.map((category) => {
const count = photoCounts[category.id] || 0;
const isSelected = selectedCategoryId === category.id;
return (
<button
key={category.id}
onClick={() => {
onCategoryChange(category.id);
if (isMobile) onClose();
}}
className={`
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${isSelected
? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700'
}
`}
>
<span className="flex items-center gap-2">
{isSelected && <Check className="w-4 h-4" />}
{category.name}
</span>
<span className="text-sm text-neutral-500">{count}</span>
</button>
);
})}
</div>
</div>
)}
{/* Sort Section - Hidden for carousel and timeline layouts */}
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
<div className="p-4">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<SortAsc className="w-4 h-4" />
{t('gallery.sortBy')}
</h3>
<div className="space-y-1">
{sortOptions.map((option) => {
const Icon = option.icon;
const isSelected = sortBy === option.value;
return (
<button
key={option.value}
onClick={() => {
onSortChange(option.value as 'date' | 'name' | 'size');
if (isMobile) onClose();
}}
className={`
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
${isSelected
? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700'
}
`}
>
<Icon className="w-4 h-4" />
<span>{option.label}</span>
{isSelected && <Check className="w-4 h-4 ml-auto" />}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</>
);
};
@@ -0,0 +1,460 @@
import React, { useState, useMemo, useEffect } from 'react';
import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, SkeletonGalleryGrid, Skeleton } from '../common';
import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
import { ExpirationBanner } from './ExpirationBanner';
import { CountdownTimer } from './CountdownTimer';
import { GalleryLayout } from './GalleryLayout';
import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { analyticsService } from '../../services/analytics.service';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
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;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
};
}
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
const { setTheme, theme } = useTheme();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
// Fetch photos
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
// Data updates are handled by React Query
const downloadAllMutation = useDownloadAllPhotos();
// Handle window resize
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Apply branding settings
useEffect(() => {
if (settingsData) {
setBrandingSettings({
company_name: settingsData.branding_company_name || '',
company_tagline: settingsData.branding_company_tagline || '',
support_email: settingsData.branding_support_email || '',
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
});
}
}, [settingsData]);
// Apply theme when settings are loaded
useEffect(() => {
if (settingsData && data?.event) {
let themeToApply = null;
const fullEvent = data.event; // Use the full event data from API
if (fullEvent.color_theme) {
try {
// Check if it's a valid JSON string
if (fullEvent.color_theme.startsWith('{')) {
const eventTheme = JSON.parse(fullEvent.color_theme);
themeToApply = eventTheme;
} else {
// Handle legacy theme names - check if it's a preset
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
if (preset) {
themeToApply = preset.config;
} else {
// Unknown theme name, fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
}
} catch (e) {
// Invalid theme format - use default
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme with a small delay to ensure it overrides any global theme
if (themeToApply) {
// Use setTimeout to ensure this runs after any global theme application
const timer = setTimeout(() => {
// If there's a hero photo, add it to gallery settings
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
// Apply hero photo ID to existing gallery settings
} else if (fullEvent.hero_photo_id) {
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
// Create gallery settings with hero photo ID
}
setTheme(themeToApply);
}, 0);
return () => clearTimeout(timer);
}
}
}, [settingsData, data, setTheme]); // Use data instead of event prop
// Calculate days until expiration
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
const showUrgentWarning = daysUntilExpiration <= 7;
// Filter and sort photos
const filteredPhotos = useMemo(() => {
if (!data?.photos) return [];
let photos = [...data.photos];
// Apply category filter
if (selectedCategoryId) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
photos = photos.filter(photo =>
photo.filename.toLowerCase().includes(term)
);
}
// Apply sorting
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
}
});
// Transform URLs for watermarks if enabled
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
url: `/gallery/${slug}/photo/${photo.id}`,
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
}));
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug]);
const handleDownloadAll = () => {
downloadAllMutation.mutate(slug);
// Track download all action
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: data?.photos.length || 0,
is_download_all: true
});
};
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: selectedPhotos.size
});
// Download each selected photo
for (const photo of selectedPhotosList) {
await galleryService.downloadPhoto(slug, photo.id, photo.filename);
}
// Clear selection after download
setSelectedPhotos(new Set());
setIsSelectionMode(false);
};
// Calculate photo counts per category
const photoCounts = useMemo(() => {
if (!data?.photos) return {};
const counts: Record<number, number> = {};
data.photos.forEach(photo => {
if (photo.category_id) {
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
}
});
return counts;
}, [data?.photos]);
// Track search usage with debouncing
useEffect(() => {
if (searchTerm.length > 0) {
const timer = setTimeout(() => {
analyticsService.trackSearch(searchTerm, filteredPhotos.length, 'gallery');
}, 1000); // Debounce for 1 second
return () => clearTimeout(timer);
}
}, [searchTerm, filteredPhotos.length]);
// Track expiration warning views
useEffect(() => {
if (showUrgentWarning && daysUntilExpiration > 0) {
analyticsService.trackExpirationWarning(slug, daysUntilExpiration);
}
}, [showUrgentWarning, daysUntilExpiration, slug]);
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50">
{/* Header Skeleton */}
<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>
<Skeleton height={32} width={200} className="mb-2" />
<Skeleton height={20} width={300} />
</div>
<div className="flex items-center gap-2">
<Skeleton height={40} width={120} />
<Skeleton height={40} width={100} />
</div>
</div>
</div>
</header>
{/* Content Skeleton */}
<div className="container mt-6">
<Skeleton height={80} className="mb-6" />
<SkeletonGalleryGrid count={12} />
</div>
</div>
);
}
if (error || !data) {
// Check if it's an authentication error (401)
const is401Error = (error as any)?.response?.status === 401;
if (is401Error) {
// Authentication failed - logout and let the parent component handle re-authentication
logout();
return null;
}
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">{t('gallery.failedToLoad')}</p>
<Button onClick={() => refetch()} className="mt-4">
{t('gallery.tryAgain')}
</Button>
</div>
</div>
);
}
const showSidebar = theme.galleryLayout !== 'grid';
return (
<>
{/* Sidebar for non-grid layouts */}
{showSidebar ? (
<GallerySidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(!sidebarOpen)}
categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
isSelectionMode={isSelectionMode}
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
selectedCount={selectedPhotos.size}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
isDownloading={downloadAllMutation.isPending}
photoCounts={photoCounts}
totalPhotos={data?.photos.length || 0}
isMobile={isMobile}
galleryLayout={theme.galleryLayout}
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
onUploadClick={() => setShowUploadModal(true)}
/>
) : null}
<GalleryLayout
event={event}
brandingSettings={brandingSettings}
showLogout={true}
onLogout={logout}
showDownloadAll={!showSidebar}
onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending}
menuButton={showSidebar ? (
<Button
variant="ghost"
size="sm"
leftIcon={<Menu className="w-4 h-4" />}
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={t('gallery.toggleMenu')}
>
<span className="hidden sm:inline">{t('common.menu')}</span>
</Button>
) : undefined}
headerExtra={(() => {
const items = [];
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
items.push(
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
);
}
// Upload button only on desktop when sidebar is shown
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
if (allowUploads && showSidebar && !isMobile) {
items.push(
<Button
key="upload-button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
>
{t('upload.uploadPhotos')}
</Button>
);
}
// Upload button for non-sidebar layouts
if (allowUploads && !showSidebar) {
items.push(
<Button
key="upload-button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
className="flex-1 sm:flex-initial"
>
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
<span className="sm:hidden">{t('common.upload')}</span>
</Button>
);
}
return items.length > 0 ? <>{items}</> : null;
})()}
>
{/* Expiration Banner */}
{showUrgentWarning && (
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
{/* Search and Filters - Only for grid layout */}
{!showSidebar ? (
<div className="mt-6">
<PhotoFilterBar
categories={data.categories}
photos={data.photos}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
/>
</div>
) : null}
{/* Photo Grid */}
<div className={showSidebar ? "mt-6" : "mt-6"}>
<PhotoGridWithLayouts
photos={filteredPhotos}
slug={slug}
categoryId={selectedCategoryId}
isSelectionMode={isSelectionMode}
selectedPhotos={selectedPhotos}
onSelectionChange={setSelectedPhotos}
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
showSelectionControls={!showSidebar}
eventName={event.event_name}
eventLogo={brandingSettings?.logo_url}
eventDate={event.event_date}
expiresAt={event.expires_at}
/>
</div>
{/* Upload Modal */}
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => {
setShowUploadModal(false);
// Refetch photos after upload
window.location.reload(); // Simple reload for now
}}
onClose={() => setShowUploadModal(false)}
/>
)}
</GalleryLayout>
</>
);
};
@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { Search, SortAsc, Grid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
interface PhotoCategory {
id: number;
name: string;
slug: string;
is_global: boolean;
}
interface Photo {
id: number;
category_id?: number;
}
interface PhotoFilterBarProps {
categories?: PhotoCategory[];
photos: Photo[];
selectedCategoryId: number | null;
onCategoryChange: (categoryId: number | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size';
onSortChange: (sort: 'date' | 'name' | 'size') => void;
photoCount: number;
}
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
categories = [],
photos,
selectedCategoryId,
onCategoryChange,
searchTerm,
onSearchChange,
sortBy,
onSortChange,
photoCount,
}) => {
const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false);
return (
<div className="space-y-4">
{/* Search and Sort */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
{/* Search Bar */}
<div className="flex-1">
<Input
type="text"
placeholder={t('gallery.searchPhotos')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="text-sm sm:text-base"
/>
</div>
{/* Sort Dropdown */}
<div className="relative">
<Button
variant="outline"
size="md"
leftIcon={<SortAsc className="w-4 h-4" />}
onClick={() => setShowSortMenu(!showSortMenu)}
className="w-full sm:w-auto text-sm sm:text-base"
>
<span className="hidden sm:inline">{t('common.sortBy')} </span>
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') : sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') : t('gallery.sortBySize').replace('Sort by ', '')}
</Button>
{showSortMenu && (
<div className="absolute right-0 sm:right-auto sm:left-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
<button
onClick={() => {
onSortChange('date');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
}`}
>
{t('gallery.sortByDate')}
</button>
<button
onClick={() => {
onSortChange('name');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
}`}
>
{t('gallery.sortByName')}
</button>
<button
onClick={() => {
onSortChange('size');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
}`}
>
{t('gallery.sortBySize')}
</button>
</div>
)}
</div>
</div>
{/* Category Filter */}
{categories && categories.length > 0 && (
<div className="space-y-3">
<div className="flex items-start sm:items-center justify-between flex-col sm:flex-row gap-3">
<div className="w-full sm:w-auto overflow-x-auto pb-2 sm:pb-0">
<div className="flex items-center gap-2 min-w-max">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-3 h-3 sm:w-4 sm:h-4" />}
className="text-xs sm:text-sm whitespace-nowrap"
>
{t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return (
<Button
key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
className="text-xs sm:text-sm whitespace-nowrap"
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
</div>
</div>
<p className="text-xs sm:text-sm text-neutral-600 flex-shrink-0">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
</p>
</div>
</div>
)}
</div>
);
};
PhotoFilterBar.displayName = 'PhotoFilterBar';
@@ -0,0 +1,297 @@
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, Package } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { Button, AuthenticatedImage } from '../common';
import { galleryService } from '../../services/gallery.service';
import { analyticsService } from '../../services/analytics.service';
interface PhotoGridProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
}
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }) => {
const { t } = useTranslation();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
}, [categoryId]);
const handlePhotoClick = (index: number, e?: React.MouseEvent) => {
// Check for ctrl/cmd+click for quick selection
if (e && (e.ctrlKey || e.metaKey)) {
if (!isSelectionMode) {
setIsSelectionMode(true);
}
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 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();
// Track individual photo download
analyticsService.trackDownload(photo.id, slug, false);
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());
};
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
// Download failed - error handled by UI
return null;
})
);
try {
await Promise.all(downloadPromises);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: selectedPhotos.size
});
// Clear selection after download
setSelectedPhotos(new Set());
setIsSelectionMode(false);
} catch (error) {
toastify.error(t('gallery.downloadError'));
}
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
</div>
);
}
return (
<>
{/* Selection Mode Controls */}
{photos.length > 1 && (
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
title={t('gallery.selectPhotosHint')}
className="text-xs sm:text-sm"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{!isSelectionMode && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsSelectionMode(true);
selectAll();
}}
className="text-xs sm:text-sm"
>
{t('gallery.selectAll')}
</Button>
)}
</div>
{isSelectionMode && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-neutral-600">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="ghost" size="sm" onClick={selectAll} className="text-xs sm:text-sm">
{t('gallery.selectAll')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll} className="text-xs sm:text-sm">
{t('gallery.deselectAll')}
</Button>
{selectedPhotos.size > 0 && (
<Button
variant="primary"
size="sm"
leftIcon={<Package className="w-4 h-4" />}
onClick={handleDownloadSelected}
className="text-xs sm:text-sm"
>
<span className="hidden sm:inline">{t('gallery.downloadSelected', { count: selectedPhotos.size })}</span>
<span className="sm:hidden">{t('common.download')} ({selectedPhotos.size})</span>
</Button>
)}
</div>
</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={(e) => handlePhotoClick(index, e)}
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: (e: React.MouseEvent) => 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 aspect-square"
onClick={(e) => onClick(e)}
>
{inView ? (
<>
<AuthenticatedImage
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"
isGallery={true}
/>
{/* Overlay on hover/tap - Always visible on mobile for better UX */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 sm:p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 sm: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 - Larger on mobile for easier tapping */}
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100'} transition-opacity`}>
<div className={`w-7 h-7 sm:w-6 sm:h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{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,266 @@
import React, { useState, useEffect } from 'react';
import { Package } from 'lucide-react';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { Button } from '../common';
import { galleryService } from '../../services/gallery.service';
import { analyticsService } from '../../services/analytics.service';
import { useTheme } from '../../contexts/ThemeContext';
// Import all layouts
import {
GridGalleryLayout,
MasonryGalleryLayout,
CarouselGalleryLayout,
TimelineGalleryLayout,
HeroGalleryLayout,
MosaicGalleryLayout,
} from './layouts';
interface PhotoGridWithLayoutsProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
isSelectionMode?: boolean;
selectedPhotos?: Set<number>;
onSelectionChange?: (photos: Set<number>) => void;
onToggleSelectionMode?: () => void;
showSelectionControls?: boolean;
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos,
slug,
categoryId,
isSelectionMode: parentSelectionMode,
selectedPhotos: parentSelectedPhotos,
onSelectionChange,
onToggleSelectionMode: parentToggleSelectionMode,
showSelectionControls = true,
eventName,
eventLogo,
eventDate,
expiresAt
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
const [localSelectionMode, setLocalSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
// Use parent state if provided, otherwise use local state
const selectedPhotos = parentSelectedPhotos ?? localSelectedPhotos;
const isSelectionMode = parentSelectionMode ?? localSelectionMode;
const setSelectedPhotos = onSelectionChange ?? setLocalSelectedPhotos;
const toggleSelectionMode = parentToggleSelectionMode ?? (() => setLocalSelectionMode(!localSelectionMode));
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
}, [categoryId]);
const handlePhotoClick = (index: number) => {
setSelectedPhotoIndex(index);
};
const handlePhotoSelect = (photoId: number) => {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleDownload = (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
// Track individual photo download
analyticsService.trackDownload(photo.id, slug, false);
downloadPhotoMutation.mutate({
slug,
photoId: photo.id,
filename: photo.filename,
});
};
const selectAll = () => {
setSelectedPhotos(new Set(photos.map(p => p.id)));
};
const deselectAll = () => {
setSelectedPhotos(new Set());
};
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
// Download failed - error handled by UI
return null;
})
);
try {
await Promise.all(downloadPromises);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: selectedPhotos.size
});
// Clear selection after download
setSelectedPhotos(new Set());
if (parentToggleSelectionMode) {
parentToggleSelectionMode();
} else {
setLocalSelectionMode(false);
}
} catch (error) {
toastify.error(t('gallery.downloadError'));
}
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
</div>
);
}
// Get the current layout from theme
const galleryLayout = theme.galleryLayout || 'grid';
// Select the appropriate layout component
const layoutProps = {
photos,
slug,
onPhotoClick: handlePhotoClick,
onDownload: handleDownload,
selectedPhotos,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
eventName,
eventLogo,
eventDate,
expiresAt,
};
let LayoutComponent;
switch (galleryLayout) {
case 'masonry':
LayoutComponent = MasonryGalleryLayout;
break;
case 'carousel':
LayoutComponent = CarouselGalleryLayout;
break;
case 'timeline':
LayoutComponent = TimelineGalleryLayout;
break;
case 'hero':
LayoutComponent = HeroGalleryLayout;
break;
case 'mosaic':
LayoutComponent = MosaicGalleryLayout;
break;
default:
LayoutComponent = GridGalleryLayout;
}
return (
<>
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
title={t('gallery.selectPhotosHint')}
className="text-xs sm:text-sm"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{!isSelectionMode && (
<Button
variant="ghost"
size="sm"
onClick={() => {
toggleSelectionMode();
selectAll();
}}
className="text-xs sm:text-sm"
>
{t('gallery.selectAll')}
</Button>
)}
</div>
{isSelectionMode && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-neutral-600">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="ghost" size="sm" onClick={selectAll} className="text-xs sm:text-sm">
{t('gallery.selectAll')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll} className="text-xs sm:text-sm">
{t('gallery.deselectAll')}
</Button>
{selectedPhotos.size > 0 && (
<Button
variant="primary"
size="sm"
leftIcon={<Package className="w-4 h-4" />}
onClick={handleDownloadSelected}
className="text-xs sm:text-sm"
>
<span className="hidden sm:inline">{t('gallery.downloadSelected', { count: selectedPhotos.size })}</span>
<span className="sm:hidden">{t('common.download')} ({selectedPhotos.size})</span>
</Button>
)}
</div>
</div>
)}
</div>
)}
{/* Render the selected layout */}
<LayoutComponent {...layoutProps} />
{/* Lightbox */}
{selectedPhotoIndex !== null && (
<PhotoLightbox
photos={photos}
initialIndex={selectedPhotoIndex}
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
/>
)}
</>
);
};
@@ -0,0 +1,264 @@
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';
import { AuthenticatedImage } from '../common';
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 [touchDistance, setTouchDistance] = useState<number | null>(null);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
goToPrevious();
break;
case 'ArrowRight':
goToNext();
break;
case '+':
case '=':
handleZoomIn();
break;
case '-':
case '_':
handleZoomOut();
break;
case 'd':
case 'D':
handleDownload();
break;
}
};
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();
}
};
// Touch event handlers for pinch-to-zoom
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
setTouchDistance(distance);
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 2 && touchDistance !== null) {
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const newDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
const scale = newDistance / touchDistance;
const newZoom = Math.max(1, Math.min(3, zoom * scale));
setZoom(newZoom);
setTouchDistance(newDistance);
}
};
const handleTouchEnd = () => {
setTouchDistance(null);
};
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-20"
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-20"
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-20"
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 z-20">
<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>
</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 z-0"
onClick={handleImageClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
<AuthenticatedImage
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}
useWatermark={true}
isGallery={true}
/>
</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 z-20">
Swipe to navigate
</div>
</div>
);
};
@@ -0,0 +1,228 @@
import React, { useState } from 'react';
import { Upload, X, CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Button } from '../common';
import { api } from '../../config/api';
interface UserPhotoUploadProps {
eventId: number;
categoryId: number | null | undefined;
onUploadComplete: () => void;
onClose: () => void;
}
export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
eventId,
categoryId,
onUploadComplete,
onClose,
}) => {
const { t } = useTranslation();
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// Validate file types
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
const validFiles = selectedFiles.filter(file => {
if (!allowedTypes.includes(file.type)) {
toast.error(`Invalid file type: ${file.name}`);
return false;
}
// Check file size (50MB max)
if (file.size > 50 * 1024 * 1024) {
toast.error(`File too large: ${file.name}`);
return false;
}
return true;
});
setFiles(prev => [...prev, ...validFiles]);
};
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
if (files.length === 0) return;
setUploading(true);
let successCount = 0;
let failedCount = 0;
for (const file of files) {
const formData = new FormData();
formData.append('photos', file);
if (categoryId) {
formData.append('category_id', categoryId.toString());
}
try {
await api.post(`/gallery/${eventId}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setUploadProgress(prev => ({
...prev,
[file.name]: progress,
}));
}
},
});
successCount++;
} catch (error: any) {
// Upload error handled - user notified via UI
failedCount++;
// Show specific error message
const errorMessage = error.response?.data?.error || error.message || 'Upload failed';
toast.error(`${file.name}: ${errorMessage}`);
}
}
setUploading(false);
if (successCount > 0) {
toast.success(t('toast.uploadSuccess') + ` (${successCount} ${t('common.photos')})`);
onUploadComplete();
}
if (failedCount > 0) {
toast.error(`${failedCount} ${t('upload.someFilesFailed')}`);
}
if (failedCount === 0) {
onClose();
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
<div className="w-full sm:max-w-2xl bg-white flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-2xl shadow-xl overflow-hidden">
{/* Fixed Header */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-neutral-200 flex-shrink-0">
<h2 className="text-lg sm:text-xl font-semibold text-neutral-900">{t('upload.uploadPhotos')}</h2>
<button
onClick={onClose}
className="p-1.5 sm:p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
{/* Upload Area */}
<div className="mb-4 sm:mb-6">
<label className="block">
<div className="border-2 border-dashed border-neutral-300 rounded-lg p-6 sm:p-8 text-center hover:border-primary-500 transition-colors cursor-pointer">
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
<p className="text-sm font-medium text-neutral-700 mb-1">
{t('upload.clickToUpload')}
</p>
<p className="text-xs text-neutral-500">
{t('upload.fileRequirements')}
</p>
<input
type="file"
className="hidden"
multiple
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
disabled={uploading}
/>
</div>
</label>
</div>
{/* Selected Files */}
{files.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-neutral-700 mb-2">
{t('upload.selectedFiles')} ({files.length})
</h3>
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-neutral-50 rounded-lg"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900 truncate">
{file.name}
</p>
<p className="text-xs text-neutral-500">
{formatBytes(file.size)}
</p>
</div>
{uploadProgress[file.name] !== undefined ? (
<div className="flex items-center gap-2">
{uploadProgress[file.name] === 100 ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<div className="w-20">
<div className="bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${uploadProgress[file.name]}%` }}
/>
</div>
</div>
)}
</div>
) : (
<button
onClick={() => removeFile(index)}
className="p-1 hover:bg-neutral-200 rounded transition-colors"
disabled={uploading}
>
<X className="w-4 h-4 text-neutral-500" />
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Fixed Footer */}
<div className="flex items-center justify-end gap-2 sm:gap-3 p-4 sm:p-6 border-t border-neutral-200 bg-white flex-shrink-0">
<Button
variant="outline"
onClick={onClose}
disabled={uploading}
className="text-sm sm:text-base"
>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={handleUpload}
disabled={files.length === 0 || uploading}
isLoading={uploading}
className="text-sm sm:text-base"
>
{uploading ? t('upload.uploading') : t('common.upload')} ({files.length})
</Button>
</div>
</div>
</div>
);
};
UserPhotoUpload.displayName = 'UserPhotoUpload';
+8
View File
@@ -0,0 +1,8 @@
export { GalleryView } from './GalleryView';
export { PhotoGrid } from './PhotoGrid';
export { PhotoLightbox } from './PhotoLightbox';
export { ExpirationBanner } from './ExpirationBanner';
export { CountdownTimer } from './CountdownTimer';
export { GalleryLayout } from './GalleryLayout';
export { PhotoFilterBar } from './PhotoFilterBar';
export { UserPhotoUpload } from './UserPhotoUpload';
@@ -0,0 +1,20 @@
import React from 'react';
import type { Photo } from '../../../types';
export interface BaseGalleryLayoutProps {
photos: Photo[];
slug: string;
onPhotoClick: (index: number) => void;
onDownload: (photo: Photo, e: React.MouseEvent) => void;
selectedPhotos?: Set<number>;
isSelectionMode?: boolean;
onPhotoSelect?: (photoId: number) => void;
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
abstract render(): React.ReactNode;
}
@@ -0,0 +1,187 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
// selectedPhotos = new Set(),
// isSelectionMode = false
}) => {
const { theme } = useTheme();
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const gallerySettings = theme.gallerySettings || {};
const autoplay = gallerySettings.carouselAutoplay || false;
const interval = gallerySettings.carouselInterval || 5000;
const showThumbnails = gallerySettings.carouselShowThumbnails !== false;
// Auto-play functionality
useEffect(() => {
if (isPlaying && photos.length > 1) {
intervalRef.current = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % photos.length);
}, interval);
} else if (intervalRef.current) {
clearInterval(intervalRef.current);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPlaying, photos.length, interval]);
// Start autoplay if enabled
useEffect(() => {
if (autoplay) {
setIsPlaying(true);
}
}, [autoplay]);
const goToPrevious = () => {
setCurrentIndex((prev) => (prev - 1 + photos.length) % photos.length);
};
const goToNext = () => {
setCurrentIndex((prev) => (prev + 1) % photos.length);
};
const togglePlayPause = () => {
setIsPlaying(!isPlaying);
};
if (photos.length === 0) return null;
const currentPhoto = photos[currentIndex];
return (
<div className="relative">
{/* Main Carousel */}
<div className="relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="w-full h-full object-contain"
isGallery={true}
/>
{/* Navigation Controls */}
<div className="absolute inset-0 flex items-center justify-between p-4">
<button
onClick={goToPrevious}
className="p-2 bg-black/50 text-white rounded-full hover:bg-black/70 transition-colors"
aria-label="Previous photo"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={goToNext}
className="p-2 bg-black/50 text-white rounded-full hover:bg-black/70 transition-colors"
aria-label="Next photo"
>
<ChevronRight className="w-6 h-6" />
</button>
</div>
{/* Top Controls */}
<div className="absolute top-4 left-4 right-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="px-3 py-1 bg-black/50 text-white rounded-full text-sm">
{currentIndex + 1} / {photos.length}
</span>
{currentPhoto.category_name && (
<span className="px-3 py-1 bg-black/50 text-white rounded-full text-sm">
{currentPhoto.category_name}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={togglePlayPause}
className="text-white hover:bg-white/20"
title={isPlaying ? 'Pause slideshow' : 'Play slideshow'}
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onPhotoClick(currentIndex)}
className="text-white hover:bg-white/20"
title="View fullscreen"
>
<Maximize2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => onDownload(currentPhoto, e)}
className="text-white hover:bg-white/20"
title="Download photo"
>
<Download className="w-5 h-5" />
</Button>
</div>
</div>
{/* Progress Bar */}
{isPlaying && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/20">
<div
className="h-full bg-white transition-all duration-1000 ease-linear"
style={{
width: '100%',
animation: `progress ${interval}ms linear infinite`
}}
/>
</div>
)}
</div>
{/* Thumbnails */}
{showThumbnails && photos.length > 1 && (
<div className="mt-4 relative">
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-neutral-400">
{photos.map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentIndex(index)}
className={`relative flex-shrink-0 w-20 h-20 rounded overflow-hidden transition-all ${
index === currentIndex
? 'ring-2 ring-primary-600 scale-110'
: 'opacity-70 hover:opacity-100'
}`}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
isGallery={true}
/>
</button>
))}
</div>
</div>
)}
<style>{`
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}
`}</style>
</div>
);
};
@@ -0,0 +1,146 @@
import React from 'react';
import { Download, Maximize2, Check } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
interface GridPhotoProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
animationType?: string;
}
const GridPhoto: React.FC<GridPhotoProps> = ({
photo,
isSelected,
isSelectionMode,
onClick,
onDownload,
animationType = 'fade'
}) => {
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.1,
});
const animationClass = animationType === 'scale'
? 'transition-transform duration-300 hover:scale-105'
: animationType === 'fade'
? 'transition-opacity duration-300'
: '';
return (
<div
ref={ref}
className={`relative group cursor-pointer aspect-square ${animationClass}`}
onClick={onClick}
style={{
opacity: !inView && animationType === 'fade' ? 0 : 1
}}
>
{inView ? (
<>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
isGallery={true}
/>
<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(e);
}}
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>
{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 transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
{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 rounded-lg" />
)}
</div>
);
};
export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
const { theme } = useTheme();
const gallerySettings = theme.gallerySettings || {};
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
const spacing = gallerySettings.spacing || 'normal';
const animation = gallerySettings.photoAnimation || 'fade';
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `grid ${spacingClass}
grid-cols-${columns.mobile}
sm:grid-cols-${columns.tablet}
lg:grid-cols-${columns.desktop}
xl:grid-cols-${columns.desktop + 1}`;
return (
<div className={gridClass}>
{photos.map((photo, index) => (
<GridPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(index);
}
}}
onDownload={(e) => onDownload(photo, e)}
animationType={animation}
/>
))}
</div>
);
};
@@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { buildResourceUrl } from '../../../utils/url';
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
}
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
eventName,
eventLogo,
eventDate,
expiresAt
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const gallerySettings = theme.gallerySettings || {};
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
// Reset initialization when heroImageId changes
useEffect(() => {
if (gallerySettings.heroImageId) {
setHasInitialized(false);
}
}, [gallerySettings.heroImageId]);
// Select hero photo (admin-selected or first photo only if gallery was empty)
useEffect(() => {
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
// Process hero layout with provided photos
// If admin has selected a specific hero image, always use it
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
// Hero photo selected by admin
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasInitialized(true);
return;
}
}
// Only auto-select first photo on initial load when gallery was empty
// This prevents changing the hero when new photos are uploaded
if (!hasInitialized) {
setHeroPhoto(photos[0]);
setHasInitialized(true);
}
}
}, [photos, gallerySettings.heroImageId, hasInitialized]);
if (!heroPhoto) return null;
// Show all photos including the hero photo in the grid
const remainingPhotos = photos;
return (
<div className="relative -mt-6">
{/* Hero Section */}
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage
src={heroPhoto.url}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity }}
/>
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center px-4">
{/* Logo - Show custom logo or fallback to PicPeak logo */}
<div className="mb-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className="h-20 sm:h-24 lg:h-32 mx-auto"
style={{
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
{/* Event Title */}
{eventName && (
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
{eventName}
</h1>
)}
{/* Event Dates */}
{(eventDate || expiresAt) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
{eventDate && (
<span className="flex items-center text-lg sm:text-xl">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{format(parseISO(eventDate), 'PP')}
</span>
)}
{expiresAt && (
<span className="flex items-center text-lg sm:text-xl">
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
</span>
)}
</div>
)}
</div>
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</div>
</div>
{/* Grid Section */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="relative group cursor-pointer aspect-square"
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
/>
<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();
onPhotoClick(actualIndex);
}}
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={(e) => {
e.stopPropagation();
onDownload(photo, e);
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,167 @@
import React, { useEffect, useRef, useState } from 'react';
import { Download, Maximize2, Check } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
interface MasonryPhotoProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
photo,
isSelected,
isSelectionMode,
onClick,
onDownload,
style
}) => {
const [imageHeight, setImageHeight] = useState<number>(200);
// Generate random heights for masonry effect
useEffect(() => {
const heights = [200, 250, 300, 350, 400];
const randomHeight = heights[Math.floor(Math.random() * heights.length)];
setImageHeight(randomHeight);
}, [photo.id]);
return (
<div
className="relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
onClick={onClick}
style={{
...style,
height: `${imageHeight}px`,
breakInside: 'avoid'
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
isGallery={true}
/>
<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(e);
}}
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>
{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 transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
{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>
);
};
export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
const { theme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3);
const gallerySettings = theme.gallerySettings || {};
const gutter = gallerySettings.masonryGutter || 16;
// Calculate number of columns based on container width
useEffect(() => {
const updateColumns = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
if (width < 640) setColumns(2);
else if (width < 1024) setColumns(3);
else if (width < 1280) setColumns(4);
else setColumns(5);
}
};
updateColumns();
window.addEventListener('resize', updateColumns);
return () => window.removeEventListener('resize', updateColumns);
}, []);
// Distribute photos across columns
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
photos.forEach((photo, index) => {
photoColumns[index % columns].push(photo);
});
return (
<div
ref={containerRef}
className="flex gap-4"
style={{ gap: `${gutter}px` }}
>
{photoColumns.map((column, columnIndex) => (
<div
key={columnIndex}
className="flex-1 flex flex-col"
style={{ gap: `${gutter}px` }}
>
{column.map((photo) => {
const originalIndex = photos.findIndex(p => p.id === photo.id);
return (
<MasonryPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(originalIndex);
}
}}
onDownload={(e) => onDownload(photo, e)}
/>
);
})}
</div>
))}
</div>
);
};
@@ -0,0 +1,271 @@
import React from 'react';
import { Download, Maximize2, Check } from 'lucide-react';
// import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
interface MosaicPhotoProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
className?: string;
}
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
photo,
isSelected,
isSelectionMode,
onClick,
onDownload,
className = ''
}) => {
return (
<div
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
>
<div className="absolute inset-0">
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
isGallery={true}
/>
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 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(e);
}}
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>
{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 transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
{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>
);
};
export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
// const { theme } = useTheme();
// const gallerySettings = theme.gallerySettings || {};
// const pattern = gallerySettings.mosaicPattern || 'structured';
const handlePhotoClick = (index: number, photoId: number) => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photoId);
} else {
onPhotoClick(index);
}
};
// Create a more structured mosaic layout
const renderMosaicLayout = () => {
const elements = [];
let photoIndex = 0;
let patternIndex = 0;
while (photoIndex < photos.length) {
const remainingPhotos = photos.length - photoIndex;
// Choose pattern based on rotation and remaining photos
if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
// Pattern 1: Large left, 2 small right
// Capture indices immediately to avoid closure issues
const idx0 = photoIndex;
const idx1 = photoIndex + 1;
const idx2 = photoIndex + 2;
const photo0 = photos[idx0];
const photo1 = photos[idx1];
const photo2 = photos[idx2];
elements.push(
<div key={`pattern-${photoIndex}`} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
{photo0 && (
<MosaicPhoto
photo={photo0}
isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx0, photo0.id)}
onDownload={(e) => onDownload(photo0, e)}
className="col-span-1"
/>
)}
<div className="grid grid-rows-2 gap-2">
{photo1 && (
<MosaicPhoto
photo={photo1}
isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx1, photo1.id)}
onDownload={(e) => onDownload(photo1, e)}
className=""
/>
)}
{photo2 && (
<MosaicPhoto
photo={photo2}
isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx2, photo2.id)}
onDownload={(e) => onDownload(photo2, e)}
className=""
/>
)}
</div>
</div>
);
photoIndex += 3;
} else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
// Pattern 2: 3 equal columns
elements.push(
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[250px]">
{[0, 1, 2].map(offset => {
const currentIndex = photoIndex + offset;
const photo = photos[currentIndex];
return photo ? (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(currentIndex, photo.id)}
onDownload={(e) => onDownload(photo, e)}
className=""
/>
) : null;
})}
</div>
);
photoIndex += 3;
} else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
// Pattern 3: Large span-2 with 2 small on right
// Capture indices immediately to avoid closure issues
const idx0 = photoIndex;
const idx1 = photoIndex + 1;
const idx2 = photoIndex + 2;
const photo0 = photos[idx0];
const photo1 = photos[idx1];
const photo2 = photos[idx2];
elements.push(
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[400px]">
{photo0 && (
<MosaicPhoto
photo={photo0}
isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx0, photo0.id)}
onDownload={(e) => onDownload(photo0, e)}
className="col-span-2"
/>
)}
<div className="grid grid-rows-2 gap-2">
{photo1 && (
<MosaicPhoto
photo={photo1}
isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx1, photo1.id)}
onDownload={(e) => onDownload(photo1, e)}
className=""
/>
)}
{photo2 && (
<MosaicPhoto
photo={photo2}
isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx2, photo2.id)}
onDownload={(e) => onDownload(photo2, e)}
className=""
/>
)}
</div>
</div>
);
photoIndex += 3;
} else {
// Handle remaining photos that don't fit patterns
break;
}
patternIndex++;
}
// Add remaining photos in a regular grid
if (photoIndex < photos.length) {
const remainingPhotos = photos.slice(photoIndex);
elements.push(
<div key={`remaining-${photoIndex}`} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{remainingPhotos.map((photo, idx) => {
const index = photoIndex + idx;
return (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index, photo.id)}
onDownload={(e) => onDownload(photo, e)}
className="aspect-square"
/>
);
})}
</div>
);
}
return elements;
};
return (
<div className="w-full max-w-7xl mx-auto">
{renderMosaicLayout()}
</div>
);
};
@@ -0,0 +1,156 @@
import React, { useMemo } from 'react';
import { Download, Maximize2, Check, Calendar } from 'lucide-react';
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
const { theme } = useTheme();
const gallerySettings = theme.gallerySettings || {};
const grouping = gallerySettings.timelineGrouping || 'day';
const showDates = gallerySettings.timelineShowDates !== false;
// Group photos by date
const groupedPhotos = useMemo(() => {
const groups = new Map<string, Photo[]>();
photos.forEach(photo => {
const date = parseISO(photo.uploaded_at);
let groupKey: string;
switch (grouping) {
case 'week':
const weekStart = startOfWeek(date);
groupKey = format(weekStart, 'yyyy-MM-dd');
// groupLabel = `Week of ${format(weekStart, 'MMM d, yyyy')}`;
break;
case 'month':
const monthStart = startOfMonth(date);
groupKey = format(monthStart, 'yyyy-MM');
// groupLabel = format(monthStart, 'MMMM yyyy');
break;
default: // day
const dayStart = startOfDay(date);
groupKey = format(dayStart, 'yyyy-MM-dd');
// groupLabel = format(dayStart, 'EEEE, MMMM d, yyyy');
}
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(photo);
});
// Convert to array and sort by date
return Array.from(groups.entries())
.map(([date, photos]) => ({
date,
label: photos[0] ? format(parseISO(photos[0].uploaded_at), grouping === 'month' ? 'MMMM yyyy' : grouping === 'week' ? "'Week of' MMM d, yyyy" : 'EEEE, MMMM d, yyyy') : date,
photos: photos.sort((a, b) => new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime())
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [photos, grouping]);
return (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-neutral-300 hidden lg:block" />
{/* Timeline groups */}
<div className="space-y-12">
{groupedPhotos.map((group) => (
<div key={group.date} className="relative">
{/* Date marker */}
{showDates && (
<div className="flex items-center gap-4 mb-6">
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-primary-600 rounded-full z-10">
<Calendar className="w-6 h-6 text-primary-600" />
</div>
<h3 className="text-xl font-semibold text-neutral-800">
{group.label}
</h3>
</div>
)}
{/* Photos grid for this date */}
<div className="lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{group.photos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="relative group cursor-pointer aspect-square"
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
isGallery={true}
/>
{/* Time label */}
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
{format(parseISO(photo.uploaded_at), 'h:mm a')}
</div>
<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();
onPhotoClick(actualIndex);
}}
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={(e) => {
e.stopPropagation();
onDownload(photo, e);
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,7 @@
export { GridGalleryLayout } from './GridGalleryLayout';
export { MasonryGalleryLayout } from './MasonryGalleryLayout';
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
export { HeroGalleryLayout } from './HeroGalleryLayout';
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
+138
View File
@@ -0,0 +1,138 @@
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';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => {
maintenanceModeCallback = callback;
};
// Create axios instance
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
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');
if (isAdminRoute) {
const token = Cookies.get(ADMIN_TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// For gallery routes, try to extract slug from the request URL first
const galleryMatch = config.url?.match(/\/gallery\/([^\/]+)/);
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// Fallback to getting slug from the current page URL
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
}
}
// Don't set Content-Type for FormData - let browser set it with boundary
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle errors
api.interceptors.response.use(
(response) => response,
(error) => {
// Handle maintenance mode (503)
if (error.response?.status === 503) {
const isAdminRoute = error.config?.url?.includes('/admin');
const hasAdminAuth = error.config?.headers?.Authorization?.startsWith('Bearer ');
// Only trigger maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
if (maintenanceModeCallback) {
maintenanceModeCallback(true);
}
}
}
if (error.response?.status === 401) {
// Check if it's an admin route
const isAdminRoute = error.config?.url?.includes('/admin');
const currentPath = window.location.pathname;
if (isAdminRoute) {
// Clear admin token on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
// Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) {
window.location.href = '/admin/login';
}
} else {
// For gallery routes, check if the error is from a gallery API call
const galleryMatch = error.config?.url?.match(/\/gallery\/([^\/]+)/);
// Don't redirect if we're on any gallery page (to avoid redirect loops during login)
if (currentPath.startsWith('/gallery/')) {
// If we have a gallery match from the API URL, clear that specific gallery's token
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
}
// Don't redirect - let the component handle the auth state
} else {
// We're not on a gallery page but got a 401 from a gallery API
// This shouldn't happen in normal flow, but if it does, redirect to homepage
window.location.href = '/';
}
}
}
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,84 @@
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: (token: string, user: AdminUser) => 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 checkAuth = async () => {
try {
const token = getAuthToken(true);
if (token) {
// For now, just assume the token is valid
// TODO: Validate token with backend and get user info
setIsAuthenticated(true);
}
} catch (error) {
// Auth check failed - user needs to login
setError('Failed to check authentication');
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = (_token: string, user: AdminUser) => {
// Token is already stored in cookie by authService
setUser(user);
setError(null);
setIsAuthenticated(true);
};
const logout = () => {
authService.adminLogout();
setIsAuthenticated(false);
setUser(null);
};
return (
<AdminAuthContext.Provider
value={{
isAuthenticated,
user,
login,
logout,
isLoading,
error,
}}
>
{children}
</AdminAuthContext.Provider>
);
};
@@ -0,0 +1,131 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { authService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
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, recaptchaToken?: string | null) => 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);
// Get current gallery slug from URL
const getCurrentGallerySlug = () => {
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
return pathParts[2];
}
return null;
};
useEffect(() => {
// Clean up old authentication data on mount
cleanupOldGalleryAuth();
// Check if user has a valid token on mount
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
// Try to restore event data from localStorage with slug-specific key
const storedEvent = localStorage.getItem(`gallery_event_${currentSlug}`);
const storedToken = localStorage.getItem(`gallery_token_${currentSlug}`);
if (storedEvent && storedToken) {
try {
const eventData = JSON.parse(storedEvent);
// Verify the stored event matches the current gallery slug
if (eventData && eventData.id) {
setEvent(eventData);
setIsAuthenticated(true);
} else {
// Clear invalid data
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
} catch (error) {
// Invalid stored data - clear it
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
}
}
setIsLoading(false);
}, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
try {
setError(null);
setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event);
setIsAuthenticated(true);
// Store event data and token in localStorage with slug-specific key
localStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
localStorage.setItem(`gallery_token_${slug}`, response.token);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid password');
throw err;
} finally {
setIsLoading(false);
}
};
const logout = () => {
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
authService.galleryLogout();
setIsAuthenticated(false);
setEvent(null);
};
return (
<GalleryAuthContext.Provider
value={{
isAuthenticated,
event,
login,
logout,
isLoading,
error,
}}
>
{children}
</GalleryAuthContext.Provider>
);
};
@@ -0,0 +1,75 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api';
import { getApiBaseUrl } from '../utils/url';
interface MaintenanceContextType {
isMaintenanceMode: boolean;
setMaintenanceMode: (enabled: boolean) => void;
}
const MaintenanceContext = createContext<MaintenanceContextType | undefined>(undefined);
export const useMaintenanceMode = () => {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenanceMode must be used within MaintenanceProvider');
}
return context;
};
interface MaintenanceProviderProps {
children: ReactNode;
}
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
// Check maintenance mode status on mount
const { data: settings } = useQuery({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.status === 503) {
setIsMaintenanceMode(true);
return null;
}
return response.json();
} catch (error) {
// If we can't reach the server, don't assume maintenance mode
return null;
}
},
staleTime: 30 * 1000, // Check every 30 seconds
refetchInterval: 30 * 1000,
});
// Update maintenance mode based on settings
useEffect(() => {
if (settings?.maintenance_mode !== undefined) {
setIsMaintenanceMode(settings.maintenance_mode);
}
}, [settings]);
// Set up the callback for API interceptor
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setIsMaintenanceMode(enabled);
});
return () => {
setMaintenanceModeCallback(null as any);
};
}, []);
const setMaintenanceMode = (enabled: boolean) => {
setIsMaintenanceMode(enabled);
};
return (
<MaintenanceContext.Provider value={{ isMaintenanceMode, setMaintenanceMode }}>
{children}
</MaintenanceContext.Provider>
);
};
+228
View File
@@ -0,0 +1,228 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
interface ThemeContextType {
theme: ThemeConfig;
themeName: string;
setTheme: (theme: ThemeConfig) => void;
setThemeByName: (themeName: string) => void;
applyTheme: (theme: ThemeConfig) => void;
resetTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: ReactNode;
initialTheme?: ThemeConfig;
initialThemeName?: string;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children,
initialTheme = GALLERY_THEME_PRESETS.default.config,
initialThemeName = 'default'
}) => {
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
const [themeName, setThemeName] = useState(initialThemeName);
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
const root = document.documentElement;
// Apply CSS variables
if (themeConfig.primaryColor) {
root.style.setProperty('--color-primary', themeConfig.primaryColor);
// Generate primary color shades
root.style.setProperty('--color-primary-light', lightenColor(themeConfig.primaryColor, 20));
root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20));
}
if (themeConfig.accentColor) {
root.style.setProperty('--color-accent', themeConfig.accentColor);
}
if (themeConfig.backgroundColor) {
root.style.setProperty('--color-background', themeConfig.backgroundColor);
}
if (themeConfig.textColor) {
root.style.setProperty('--color-text', themeConfig.textColor);
}
if (themeConfig.fontFamily) {
root.style.setProperty('--font-family', themeConfig.fontFamily);
}
if (themeConfig.headingFontFamily) {
root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily);
}
if (themeConfig.borderRadius) {
const radiusMap = {
none: '0',
sm: '0.25rem',
md: '0.5rem',
lg: '1rem',
};
root.style.setProperty('--border-radius', radiusMap[themeConfig.borderRadius]);
}
// Apply font size
if (themeConfig.fontSize) {
const sizeMap = {
small: '14px',
normal: '16px',
large: '18px',
};
root.style.setProperty('--font-size-base', sizeMap[themeConfig.fontSize]);
}
// Apply shadow style
if (themeConfig.shadowStyle) {
const shadowMap = {
none: 'none',
subtle: '0 1px 3px rgba(0,0,0,0.12)',
normal: '0 4px 6px rgba(0,0,0,0.1)',
dramatic: '0 10px 25px rgba(0,0,0,0.15)',
};
root.style.setProperty('--shadow-default', shadowMap[themeConfig.shadowStyle]);
}
// Apply background pattern
if (themeConfig.backgroundPattern && themeConfig.backgroundPattern !== 'none') {
const patternMap = {
dots: `radial-gradient(circle, ${themeConfig.textColor}20 1px, transparent 1px)`,
grid: `linear-gradient(${themeConfig.textColor}10 1px, transparent 1px), linear-gradient(90deg, ${themeConfig.textColor}10 1px, transparent 1px)`,
waves: `url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='${themeConfig.textColor}' fill-opacity='0.05'/%3E%3C/svg%3E")`,
};
root.style.setProperty('--background-pattern', patternMap[themeConfig.backgroundPattern]);
root.style.setProperty('--background-pattern-size', themeConfig.backgroundPattern === 'dots' ? '20px 20px' : themeConfig.backgroundPattern === 'grid' ? '20px 20px' : '100px 20px');
} else {
root.style.removeProperty('--background-pattern');
root.style.removeProperty('--background-pattern-size');
}
// Apply custom CSS if provided
if (themeConfig.customCss) {
let styleElement = document.getElementById('custom-theme-styles');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'custom-theme-styles';
document.head.appendChild(styleElement);
}
styleElement.textContent = themeConfig.customCss;
}
}, []);
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
setTheme(newTheme);
applyTheme(newTheme);
}, [applyTheme]);
const setThemeByName = useCallback((name: string) => {
const presetTheme = GALLERY_THEME_PRESETS[name];
if (presetTheme) {
setThemeName(name);
setTheme(presetTheme.config);
applyTheme(presetTheme.config);
}
}, [applyTheme]);
const resetTheme = useCallback(() => {
setThemeByName('default');
}, [setThemeByName]);
// Apply theme when it changes, but skip if it's the same
useEffect(() => {
const root = document.documentElement;
const currentPrimary = root.style.getPropertyValue('--color-primary');
// Only apply if the theme has actually changed
if (currentPrimary !== theme.primaryColor) {
applyTheme(theme);
}
}, [theme, applyTheme]);
// Load theme from localStorage on mount (skip if in gallery view)
useEffect(() => {
// Check if we're in a gallery view by looking at the URL
const isGalleryView = window.location.pathname.includes('/gallery/');
if (!isGalleryView) {
const savedTheme = localStorage.getItem('gallery-theme');
if (savedTheme) {
try {
const parsed = JSON.parse(savedTheme);
setTheme(parsed.config);
setThemeName(parsed.name);
} catch (e) {
console.error('Failed to load saved theme:', e);
}
}
}
}, []);
// Save theme to localStorage when it changes (but not in gallery views)
useEffect(() => {
// Don't save theme in gallery views to avoid conflicts
const isGalleryView = window.location.pathname.includes('/gallery/');
if (!isGalleryView) {
// Only save if theme has actually changed
const currentSaved = localStorage.getItem('gallery-theme');
const newValue = JSON.stringify({ name: themeName, config: theme });
if (currentSaved !== newValue) {
localStorage.setItem('gallery-theme', newValue);
}
}
}, [theme, themeName]);
const contextValue = useMemo(() => ({
theme,
themeName,
setTheme: setThemeConfig,
setThemeByName,
applyTheme,
resetTheme
}), [theme, themeName, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
};
// Utility functions for color manipulation
function lightenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = (num >> 8 & 0x00FF) + amt;
const B = (num & 0x0000FF) + amt;
return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 +
(G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +
(B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1);
}
function darkenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) - amt;
const G = (num >> 8 & 0x00FF) - amt;
const B = (num & 0x0000FF) - amt;
return '#' + (0x1000000 + (R > 0 ? R : 0) * 0x10000 +
(G > 0 ? G : 0) * 0x100 +
(B > 0 ? B : 0)).toString(16).slice(1);
}
// Re-export types
export type { ThemeConfig, EventTheme };
export { GALLERY_THEME_PRESETS };
+6
View File
@@ -0,0 +1,6 @@
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
+4
View File
@@ -0,0 +1,4 @@
export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo';
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react';
export const useFocusTrap = (isActive: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableElements = container.querySelectorAll(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0] as HTMLElement;
const lastFocusable = focusableElements[focusableElements.length - 1] as HTMLElement;
// Focus first element when trap is activated
firstFocusable?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable?.focus();
}
} else {
// Tab
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable?.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
return () => {
container.removeEventListener('keydown', handleKeyDown);
};
}, [isActive]);
return containerRef;
};
+66
View File
@@ -0,0 +1,66 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { galleryService } from '../services';
import { toast } from 'react-toastify';
export const useGalleryInfo = (slug: string, token?: string) => {
return useQuery({
queryKey: ['gallery-info', slug, token],
queryFn: () => galleryService.getGalleryInfo(slug, token),
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
// Add a small delay to ensure auth token is properly set
retryDelay: 100,
});
};
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');
},
});
};
+27
View File
@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
export const useLocalizedDate = () => {
const { i18n } = useTranslation();
const getLocale = () => {
return i18n.language === 'de' ? de : enUS;
};
const format = (date: Date | string, formatStr: string) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormat(dateObj, formatStr, { locale: getLocale() });
};
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormatDistanceToNow(dateObj, { ...options, locale: getLocale() });
};
return {
format,
formatDistanceToNow,
locale: getLocale()
};
};
+72
View File
@@ -0,0 +1,72 @@
import { useTranslation } from 'react-i18next';
export const useLocalizedTimeAgo = () => {
const { i18n } = useTranslation();
const formatTimeAgo = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const seconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
const isGerman = i18n.language === 'de';
// Less than a minute
if (seconds < 60) {
return isGerman ? 'gerade eben' : 'just now';
}
// Minutes
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
if (minutes === 1) {
return isGerman ? 'vor 1 Minute' : '1 minute ago';
}
return isGerman ? `vor ${minutes} Minuten` : `${minutes} minutes ago`;
}
// Hours
const hours = Math.floor(minutes / 60);
if (hours < 24) {
if (hours === 1) {
return isGerman ? 'vor 1 Stunde' : '1 hour ago';
}
return isGerman ? `vor ${hours} Stunden` : `${hours} hours ago`;
}
// Days
const days = Math.floor(hours / 24);
if (days < 7) {
if (days === 1) {
return isGerman ? 'vor 1 Tag' : '1 day ago';
}
return isGerman ? `vor ${days} Tagen` : `${days} days ago`;
}
// Weeks
const weeks = Math.floor(days / 7);
if (weeks < 4) {
if (weeks === 1) {
return isGerman ? 'vor 1 Woche' : '1 week ago';
}
return isGerman ? `vor ${weeks} Wochen` : `${weeks} weeks ago`;
}
// Months
const months = Math.floor(days / 30);
if (months < 12) {
if (months === 1) {
return isGerman ? 'vor 1 Monat' : '1 month ago';
}
return isGerman ? `vor ${months} Monaten` : `${months} months ago`;
}
// Years
const years = Math.floor(days / 365);
if (years === 1) {
return isGerman ? 'vor 1 Jahr' : '1 year ago';
}
return isGerman ? `vor ${years} Jahren` : `${years} years ago`;
};
return { formatTimeAgo };
};
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, type RefObject } from 'react';
export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T | null>,
handler: () => void
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler();
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useCallback } from 'react';
import { useAdminAuth } from '../contexts';
import { api } from '../config/api';
// Hook to handle session timeout
export const useSessionTimeout = () => {
const { logout } = useAdminAuth();
const handleSessionTimeout = useCallback((error: any) => {
if (error?.response?.data?.code === 'SESSION_TIMEOUT') {
// Clear local auth state
logout();
// Redirect to login with message
window.location.href = '/admin/login?session=expired';
return true;
}
return false;
}, [logout]);
useEffect(() => {
// Add response interceptor to handle session timeout
const interceptor = api.interceptors.response.use(
response => response,
error => {
if (handleSessionTimeout(error)) {
// Don't propagate the error if it was a session timeout
return Promise.reject(new Error('Session expired'));
}
return Promise.reject(error);
}
);
// Clean up interceptor on unmount
return () => {
api.interceptors.response.eject(interceptor);
};
}, [handleSessionTimeout]);
return { handleSessionTimeout };
};

Some files were not shown because too many files have changed in this diff Show More