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
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:
@@ -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*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;"]
|
||||
@@ -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;"]
|
||||
@@ -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;"]
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+6030
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
@@ -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}</>;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './useSessionTimeout';
|
||||
export * from './useOnClickOutside';
|
||||
export * from './useLocalizedDate';
|
||||
export * from './useLocalizedTimeAgo';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user