Compare commits

...

13 Commits

Author SHA1 Message Date
Gitea Actions Bot 3d3013d9d6 chore: bump version to 1.0.69 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-20 18:35:36 +00:00
paul bccaa649dc Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m18s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 49s
Version and Release / trigger-drone (push) Successful in 6s
2025-07-20 20:29:16 +02:00
paul 7aca927937 fix: implement 9 production enhancements and security fixes
- Password Complexity: Added 4-level complexity selector (Simple/Moderate/Strong/Very Strong) in admin security settings with dynamic backend validation
- Gallery Security: Removed event date from login page (security risk), replaced with event type badge
- Analytics Config: Fixed "Not Configured" detection logic to check both admin settings and env variables
- Analytics Accuracy: Aligned calculation logic between dashboard and analytics endpoints, added totals verification
- Translations: Added missing activity keys (analytics_settings_updated, cms_page_updated, security_settings_updated, password_reset, admin_logout, system_activity)
- UI Fixes: Fixed German text overflow in CMS page selector with proper CSS truncation
- Date Format: Event creation now respects admin-configured date format instead of browser locale
- Chrome Compatibility: Replaced emoji flags with SVG components for Windows Chrome support

All changes maintain backward compatibility and production stability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 20:27:28 +02:00
Gitea Actions Bot e229c60b22 chore: bump version to 1.0.68 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-18 20:51:20 +00:00
paul 4966bc6a58 fix: resolve database connection error for analytics settings
Mirror to GitHub / mirror (push) Successful in 20s
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m23s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Update publicSettings.js to handle missing analytics setting_type gracefully
- Add dedicated PUT /analytics endpoint for saving analytics settings
- Update frontend settings service to route to correct endpoints based on setting type
- Fix query to use WHERE clause that won't fail if analytics type doesn't exist

This fixes the "Connection terminated unexpectedly" error when fetching
public settings with analytics configuration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 22:46:41 +02:00
Gitea Actions Bot 617f292516 chore: bump version to 1.0.67 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-18 20:35:03 +00:00
paul 8e95004022 feat: fix analytics dashboard and implement complete Umami integration
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m21s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m24s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 4s
- Fix backend analytics to include both 'download' and 'download_all' actions
- Add Analytics tab to Settings page for Umami configuration
- Update public settings endpoint to expose Umami config when enabled
- Implement dynamic Umami initialization from backend settings
- Fix frontend analytics calculations (remove hardcoded estimations)
- Add proper download counts and unique visitor tracking
- Update CLAUDE.md with production safety guidelines

The analytics dashboard now shows accurate data for all metrics, and Umami
can be configured through the admin panel instead of environment variables.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 22:29:09 +02:00
Gitea Actions Bot 93df328853 chore: bump backend version to 1.0.66
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-18 18:47:07 +00:00
paul 657e74a2e3 fix: resolve duplicate logger declaration and syntax error in rate limit service
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Remove duplicate logger import in server.js (line 26)
- Fix missing closing bracket in rateLimitService.js headers object
- Ensure backend starts without syntax errors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 20:41:59 +02:00
Gitea Actions Bot ea77f7917e chore: bump version to 1.0.65 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-18 17:31:04 +00:00
paul f6e5a454ae feat: enhance security logging and ensure rate limit blocks are properly tracked
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- 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>
2025-07-18 19:25:42 +02:00
Gitea Actions Bot cc7ad4b2bc chore: bump frontend version to 1.0.61
continuous-integration/drone/push Build is passing
2025-07-17 09:20:40 +00:00
paul 827a599102 feat: enhance CMS editor with rich text features and fix line break preservation
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Failing after 38s
Version and Release / trigger-drone (push) Has been skipped
- Fix line break preservation with proper HardBreak extension configuration
- Add text alignment support (left, center, right, justify) for paragraphs and headings
- Add H4-H6 heading support with keyboard shortcuts
- Add code block support with syntax highlighting for multiple languages
- Enhance preview functionality with split-pane view (Edit/Preview/Split modes)
- Add custom prose CSS overrides for proper content rendering
- Update DOMPurify configuration to preserve br tags and text alignment
- Add visual indicators and help modal with all keyboard shortcuts
- Improve toolbar organization with grouped buttons and separators
- Add auto-save status indicator and word/character count

Fixes issues with:
- Line breaks not showing in gallery view
- Limited heading options (only H1-H3)
- No text formatting capabilities
- Missing preview functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-17 11:15:41 +02:00
36 changed files with 2298 additions and 224 deletions
+68 -1
View File
@@ -39,6 +39,12 @@ docker-compose -f docker-compose.prod.yml up -d # Production deployment
pm2 start ecosystem.config.js # Alternative: PM2 deployment
```
**⚠️ CRITICAL PRODUCTION NOTICE:**
- Production runs on a SEPARATE SERVER - never assume local changes affect production
- ALWAYS request production server details before any troubleshooting
- NO trial-and-error approaches in production - data loss is unacceptable
- Every change must be thoroughly analyzed and tested locally first
## Key Product Requirements (from PRD)
### Core Features
@@ -127,6 +133,41 @@ Background services run as separate processes:
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Troubleshooting Guidelines
### Before ANY Production Troubleshooting:
1. **ALWAYS request specific details**:
- Production server URL/IP
- Current error messages/logs
- Recent changes or deployments
- Affected users/galleries
- Time of issue occurrence
2. **Thorough Analysis Required**:
- Use detailed thinking/analysis for EVERY troubleshooting task
- Review all related code before suggesting changes
- Consider all potential side effects
- Never make assumptions about production environment
3. **Safe Troubleshooting Steps**:
- First, reproduce issue in local/dev environment
- Analyze logs without modifying production
- Create detailed action plan before any changes
- Always have rollback strategy ready
- Document every step taken
### Common Issues & Safe Approaches:
- **Email not sending**: Check email_queue table, SMTP settings, service status
- **Photos not loading**: Verify file permissions, storage paths, nginx config
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
- **Performance problems**: Analyze with monitoring tools first, never experiment
### Data Safety Rules:
- NEVER delete or modify production data without explicit backup confirmation
- ALWAYS verify backups exist before any data operations
- NO direct database modifications without transaction safety
- Log all actions for audit trail
## Environment Variables
### Backend (.env)
@@ -258,4 +299,30 @@ const { theme, setTheme, setThemeByName } = useTheme();
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
- Successful archiving: 100%
## Documentation & Development Practices
### Documentation Guidelines:
- **NEVER create new documentation files for simple tasks**
- **ALWAYS update existing documentation (like this CLAUDE.md)**
- Only create new .md files when explicitly requested
- Avoid creating temporary scripts for one-off tasks
### Development Best Practices:
- Test all changes thoroughly in local environment first
- Use version control for all changes
- Keep commits atomic and well-described
- Review impact on all integrated services
- Consider backward compatibility
- Update tests when changing functionality
### Production Deployment Checklist:
- [ ] All tests passing locally
- [ ] Linting and type checks pass
- [ ] Database migrations tested with rollback plan
- [ ] Environment variables documented
- [ ] Backup strategy confirmed
- [ ] Monitoring alerts configured
- [ ] Rollback procedure documented
- [ ] Stakeholders notified of maintenance window
+161
View File
@@ -0,0 +1,161 @@
# Security Logging Documentation
## Overview
This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities.
## Log Files
### 1. **security.log**
- Location: `logs/security.log`
- Contains: All security-related events (authentication, rate limiting, suspicious activity)
- Max Size: 20MB with rotation (keeps 10 files)
- Format: JSON with timestamp
### 2. **error.log**
- Location: `logs/error.log`
- Contains: All error-level logs including auth failures
- Max Size: 10MB with rotation (keeps 5 files)
### 3. **combined.log**
- Location: `logs/combined.log`
- Contains: All logs (info, warn, error)
- Max Size: 50MB with rotation (keeps 10 files)
## Security Events Logged
### Rate Limiting
When rate limits are exceeded, the following is logged:
```json
{
"timestamp": "2024-01-18 14:23:45.123",
"level": "warn",
"message": "Rate limit exceeded",
"security": true,
"ip": "192.168.1.1",
"path": "/api/admin/login",
"method": "POST",
"authenticated": false,
"userAgent": "Mozilla/5.0...",
"referer": "https://app.example.com",
"origin": "https://app.example.com",
"headers": {
"x-forwarded-for": "192.168.1.1",
"x-real-ip": "192.168.1.1"
},
"requestUrl": "/api/admin/login",
"rateLimitInfo": {
"limit": 5,
"current": 6,
"remaining": 0,
"resetTime": "2024-01-18T14:38:45.123Z"
}
}
```
### Authentication Failures
#### Admin Login Failures
- Tracked in `login_attempts` table
- Logged with: IP address, username, user agent, timestamp
- Account lockout after 5 failures in 15 minutes
#### Gallery Password Failures
- Tracked in `access_logs` table with action='login_fail'
- Logged with: event_id, IP address, user agent
- Gallery lockout after 5 failures in 15 minutes
### JWT Validation Failures
```json
{
"timestamp": "2024-01-18 14:23:45.123",
"level": "warn",
"message": "JWT validation failed",
"ip": "192.168.1.1",
"path": "/api/admin/events",
"method": "GET",
"userAgent": "Mozilla/5.0...",
"error": "TokenExpiredError",
"message": "jwt expired"
}
```
### Suspicious Activity
- Multiple IPs attempting login for same account
- Token usage from different IP than issued
- Token usage after password change
- Revoked token usage attempts
## Configuration Settings
All rate limiting settings are configurable via the admin panel:
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| rate_limit_enabled | true | - | Enable/disable rate limiting |
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
## Database Tables
### login_attempts
```sql
- id
- username
- ip_address
- user_agent
- success (boolean)
- created_at
```
### access_logs
```sql
- id
- event_id
- ip_address
- user_agent
- action ('view', 'download', 'login_success', 'login_fail')
- photo_id (nullable)
- created_at
```
## Environment Variables
- `LOG_LEVEL`: Set logging level (default: 'info')
- `LOG_TO_CONSOLE`: Enable console logging in production (default: false)
## Monitoring Recommendations
1. **Set up alerts for:**
- Rate limit exceeded events (possible DDoS)
- Multiple failed login attempts from same IP
- Account lockout events
- JWT validation failures spike
2. **Regular review:**
- Check security.log for patterns
- Review login_attempts table for brute force attempts
- Monitor access_logs for suspicious gallery access patterns
3. **Log analysis tools:**
- Use log aggregation tools (ELK stack, Splunk)
- Set up dashboards for security metrics
- Configure alerts for threshold breaches
## Production Deployment Notes
1. Ensure logs directory has proper permissions
2. Set up log rotation outside of application if needed
3. Consider shipping logs to centralized logging service
4. Monitor disk space for log files
5. Set `LOG_TO_CONSOLE=true` for container deployments
## Security Best Practices
1. Never log sensitive data (passwords, tokens)
2. Use generic error messages to prevent user enumeration
3. Clean up old login attempts regularly (7 days retention)
4. Monitor for unusual patterns in real-time
5. Keep rate limit settings appropriate for your usage
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.64",
"version": "1.0.69",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.64",
"version": "1.0.69",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.64",
"version": "1.0.69",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+52
View File
@@ -0,0 +1,52 @@
/**
* Test script to verify CMS and email formatting improvements
*/
const { formatWelcomeMessage, nl2br } = require('../src/utils/formatters');
console.log('Testing CMS and Email Formatting Improvements\n');
// Test 1: Basic line break conversion
console.log('Test 1: Basic line break conversion');
const basicText = `Hello,
This is line 1.
This is line 2.
This is line 4 with an extra break.`;
console.log('Input:');
console.log(basicText);
console.log('\nOutput (nl2br):');
console.log(nl2br(basicText));
console.log('\n---\n');
// Test 2: Welcome message formatting
console.log('Test 2: Welcome message formatting');
const welcomeMessage = `Dear guests,
We're so excited to share these special moments with you!
Please note:
- Download your photos before the expiration date
- The password is case-sensitive
- Contact us if you have any issues
Thank you for being part of our special day!
Best regards,
Sarah & John`;
console.log('Input:');
console.log(welcomeMessage);
console.log('\nOutput (formatWelcomeMessage):');
console.log(formatWelcomeMessage(welcomeMessage));
console.log('\n---\n');
// Test 3: Empty and edge cases
console.log('Test 3: Edge cases');
console.log('Empty string:', formatWelcomeMessage(''));
console.log('Null:', formatWelcomeMessage(null));
console.log('Only spaces:', formatWelcomeMessage(' \n \n '));
console.log('Single line:', formatWelcomeMessage('This is a single line message'));
console.log('\nAll tests completed!');
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Test script to verify security logging is working correctly
* Run with: node scripts/test-security-logging.js
*/
require('dotenv').config({ path: '../.env' });
const logger = require('../src/utils/logger');
console.log('Testing Security Logging...\n');
// Test 1: Basic logging
console.log('1. Testing basic logging levels:');
logger.info('Test info message', { test: true });
logger.warn('Test warning message', { test: true });
logger.error('Test error message', { test: true });
// Test 2: Security event logging
console.log('\n2. Testing security event logging:');
// Rate limit exceeded
logger.warn('Rate limit exceeded', {
ip: '192.168.1.100',
path: '/api/admin/login',
method: 'POST',
authenticated: false,
userAgent: 'Mozilla/5.0 Test',
timestamp: new Date().toISOString(),
rateLimitInfo: {
limit: 5,
current: 6,
remaining: 0,
resetTime: new Date(Date.now() + 900000).toISOString()
}
});
// Auth rate limit
logger.warn('Auth rate limit exceeded', {
ip: '192.168.1.101',
path: '/api/auth/admin/login',
method: 'POST',
userAgent: 'Mozilla/5.0 Test',
authType: 'admin',
timestamp: new Date().toISOString()
});
// Failed login
logger.warn('Failed login attempt', {
username: 'testuser',
ip: '192.168.1.102',
userAgent: 'Mozilla/5.0 Test',
reason: 'invalid_credentials',
timestamp: new Date().toISOString()
});
// JWT validation failure
logger.warn('JWT validation failed', {
ip: '192.168.1.103',
path: '/api/admin/events',
method: 'GET',
userAgent: 'Mozilla/5.0 Test',
error: 'TokenExpiredError',
message: 'jwt expired',
timestamp: new Date().toISOString()
});
// Account lockout
logger.warn('Login attempt on locked account', {
username: 'lockeduser',
ip: '192.168.1.104',
remainingLockTime: 1200,
timestamp: new Date().toISOString()
});
// Suspicious activity
logger.warn('Suspicious login activity detected', {
username: 'suspicioususer',
ips: ['192.168.1.105', '192.168.1.106', '192.168.1.107'],
timeWindow: '15 minutes',
timestamp: new Date().toISOString()
});
console.log('\n3. Check log files:');
console.log('- logs/security.log - Should contain all security warnings');
console.log('- logs/error.log - Should contain error messages');
console.log('- logs/combined.log - Should contain all messages');
console.log('\n✅ Security logging test complete!');
console.log('Review the log files to ensure all events are properly captured.');
// Give logger time to flush
setTimeout(() => {
process.exit(0);
}, 1000);
+8 -1
View File
@@ -4,6 +4,14 @@ require('dotenv').config();
const { validateEnvironment } = require('./src/config/validateEnv');
validateEnvironment();
// Initialize logger early to capture startup logs
const logger = require('./src/utils/logger');
logger.info('Server starting up', {
nodeVersion: process.version,
environment: process.env.NODE_ENV || 'development',
timestamp: new Date().toISOString()
});
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
@@ -15,7 +23,6 @@ const { initializeTransporter, startEmailQueueProcessor } = require('./src/servi
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const logger = require('./src/utils/logger');
// Import routes
const authRoutes = require('./src/routes/auth-enhanced');
+60 -1
View File
@@ -1,24 +1,83 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth attempt without token', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent']
});
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (jwtError) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('JWT validation failed', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
error: jwtError.name,
message: jwtError.message,
timestamp: new Date().toISOString()
});
if (jwtError.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth failed - user not found or inactive', {
ip: clientIp,
userId: decoded.id,
path: req.path,
method: req.method,
timestamp: new Date().toISOString()
});
return res.status(401).json({ error: 'Invalid token' });
}
req.admin = admin;
next();
} catch (error) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.error('Admin auth middleware error', {
ip: clientIp,
path: req.path,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
res.status(401).json({ error: 'Invalid token' });
}
}
+35 -11
View File
@@ -48,9 +48,9 @@ router.get('/stats', adminAuth, async (req, res) => {
.count('id as count')
.first();
// Get total downloads (last 30 days)
// Get total downloads (last 30 days) - include both single and bulk downloads
const totalDownloads = await db('access_logs')
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
@@ -73,7 +73,7 @@ router.get('/stats', adminAuth, async (req, res) => {
.first();
const previousDownloads = await db('access_logs')
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
@@ -243,10 +243,10 @@ router.get('/analytics', adminAuth, async (req, res) => {
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get downloads per day
// Get downloads per day - include both single and bulk downloads
const downloadsData = await db('access_logs')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
@@ -272,14 +272,15 @@ router.get('/analytics', adminAuth, async (req, res) => {
if (dateObj) dateObj.uniqueVisitors = row.count;
});
// Get top galleries by views
// Get top galleries by views with additional metrics
const topGalleries = await db('access_logs')
.select('events.event_name', 'events.slug')
.select(db.raw('COUNT(*) as views'))
.select('events.id', 'events.event_name', 'events.slug')
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
.join('events', 'access_logs.event_id', 'events.id')
.where('access_logs.action', 'view')
.where('access_logs.timestamp', '>=', startDateStr)
.groupBy('events.id')
.groupBy('events.id', 'events.event_name', 'events.slug')
.orderBy('views', 'desc')
.limit(5);
@@ -309,10 +310,33 @@ router.get('/analytics', adminAuth, async (req, res) => {
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
});
// Calculate totals for the period (matching /stats logic)
const totalViews = await db('access_logs')
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalDownloadsCount = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalUniqueVisitors = await db('access_logs')
.where('timestamp', '>=', startDateStr)
.countDistinct('ip_address as count')
.first();
res.json({
chartData: dates,
topGalleries,
devices
devices,
totals: {
views: totalViews?.count || 0,
downloads: totalDownloadsCount?.count || 0,
uniqueVisitors: totalUniqueVisitors?.count || 0
}
});
} catch (error) {
console.error('Analytics error:', error);
+37
View File
@@ -496,6 +496,43 @@ router.put('/security', adminAuth, async (req, res) => {
}
});
// Update analytics settings
router.put('/analytics', adminAuth, async (req, res) => {
try {
const settings = req.body;
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'analytics',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
// Log activity
await db('activity_logs').insert({
activity_type: 'analytics_settings_updated',
actor_type: 'admin',
actor_id: req.admin.id,
actor_name: req.admin.username,
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
});
res.json({ message: 'Analytics settings updated successfully' });
} catch (error) {
console.error('Analytics settings update error:', error);
res.status(500).json({ error: 'Failed to update analytics settings' });
}
});
// Get storage info
router.get('/storage/info', adminAuth, async (req, res) => {
try {
+12 -3
View File
@@ -5,9 +5,13 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding, theme, general, and select security settings
// Fetch branding, theme, general, and security settings
// Note: We include analytics in the query but it might not exist yet
const settings = await db('app_settings')
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
.where(function() {
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
.orWhere('setting_key', 'like', 'analytics_%');
})
.select('setting_key', 'setting_value');
// Convert to object format
@@ -41,7 +45,12 @@ router.get('/', async (req, res) => {
enable_analytics: settingsObject.general_enable_analytics !== false,
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
// Umami analytics configuration (only if enabled)
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
};
res.json(publicSettings);
+7 -1
View File
@@ -109,8 +109,9 @@ async function getRecipientLanguage(email, eventId = null) {
// Process email template with variables
async function processTemplate(template, variables, language = 'en') {
// Import date formatter
// Import date formatter and text formatters
const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters');
// Get the appropriate language fields
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
@@ -142,6 +143,11 @@ async function processTemplate(template, variables, language = 'en') {
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Get branding settings for logo
let logoUrl = '';
+44 -4
View File
@@ -177,13 +177,33 @@ async function createRateLimiter() {
return shouldSkipRateLimit(req, currentConfig);
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
ip: clientIp,
path: req.path,
method: req.method,
authenticated: isAuthenticated(req),
tokenType: req.tokenType
tokenType: req.tokenType,
userAgent: req.headers['user-agent'],
referer: req.headers['referer'],
origin: req.headers['origin'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
res.status(429).json({
@@ -217,10 +237,30 @@ async function createAuthRateLimiter() {
return !currentConfig.enabled;
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
ip: clientIp,
path: req.path
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
authType: req.path.includes('admin') ? 'admin' : 'gallery',
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
res.status(429).json({
+40
View File
@@ -0,0 +1,40 @@
/**
* Formatters for email content and other text transformations
*/
/**
* Convert plain text line breaks to HTML line breaks
* @param {string} text - The text to format
* @returns {string} - Text with HTML line breaks
*/
function nl2br(text) {
if (!text) return '';
// Normalize line endings
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Convert newlines to <br> tags
return text
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('<br />');
}
/**
* Format welcome message for email templates
* @param {string} message - The welcome message
* @returns {string} - Formatted message for HTML emails
*/
function formatWelcomeMessage(message) {
if (!message || message.trim() === '') {
return '';
}
return nl2br(message);
}
module.exports = {
nl2br,
formatWelcomeMessage
};
+77 -10
View File
@@ -1,31 +1,98 @@
const winston = require('winston');
const path = require('path');
const fs = require('fs');
// Ensure logs directory exists
const logDir = path.join(__dirname, '../../logs');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// Custom format for production logs
const productionFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.errors({ stack: true }),
winston.format.json(),
winston.format.printf(info => {
// Ensure all security events are properly formatted
if (info.level === 'warn' && (info.message.includes('rate limit') ||
info.message.includes('auth') ||
info.message.includes('login') ||
info.message.includes('JWT'))) {
return JSON.stringify({
timestamp: info.timestamp,
level: info.level,
message: info.message,
security: true,
...info
});
}
return JSON.stringify(info);
})
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
format: productionFormat,
transports: [
new winston.transports.File({
filename: path.join(__dirname, '../../logs/error.log'),
level: 'error'
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
tailable: true
}),
new winston.transports.File({
filename: path.join(__dirname, '../../logs/combined.log')
filename: path.join(logDir, 'combined.log'),
maxsize: 50 * 1024 * 1024, // 50MB
maxFiles: 10,
tailable: true
}),
// Separate security log for authentication and rate limiting
new winston.transports.File({
filename: path.join(logDir, 'security.log'),
level: 'warn',
maxsize: 20 * 1024 * 1024, // 20MB
maxFiles: 10,
tailable: true,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.json(),
winston.format.printf(info => {
// Only log security-related warnings
if (info.message.includes('rate limit') ||
info.message.includes('auth') ||
info.message.includes('login') ||
info.message.includes('JWT') ||
info.message.includes('lockout') ||
info.message.includes('suspicious')) {
return JSON.stringify(info);
}
return null;
})
)
})
]
].filter(Boolean)
});
// Add console logging for non-production environments
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf(info => {
return `[${info.timestamp}] ${info.level}: ${info.message} ${info.stack || ''}`;
})
)
}));
} else {
// In production, also log to console for container environments
if (process.env.LOG_TO_CONSOLE === 'true') {
logger.add(new winston.transports.Console({
format: productionFormat
}));
}
}
module.exports = logger;
+85 -11
View File
@@ -113,6 +113,81 @@ function validatePassword(password, options = {}) {
};
}
/**
* Get complexity settings from database
* @returns {Object} - Password complexity configuration
*/
async function getPasswordComplexitySettings() {
try {
const db = require('../db');
const settings = await db('app_settings')
.where('setting_key', 'password_complexity')
.where('setting_type', 'security')
.first();
if (!settings || !settings.setting_value) {
return 'moderate'; // Default
}
const value = typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
return value;
} catch (error) {
logger.error('Failed to get password complexity settings:', error);
return 'moderate'; // Default on error
}
}
/**
* Get password configuration based on complexity level
* @param {string} complexityLevel - Complexity level (simple, moderate, strong, very_strong)
* @returns {Object} - Password configuration
*/
function getPasswordConfigForComplexity(complexityLevel) {
const configs = {
simple: {
minLength: 6,
requireUppercase: false,
requireLowercase: false,
requireNumbers: false,
requireSpecialChars: false,
preventCommonPasswords: true,
minStrengthScore: 0
},
moderate: {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: false,
preventCommonPasswords: true,
minStrengthScore: 2
},
strong: {
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: false,
preventCommonPasswords: true,
minStrengthScore: 3
},
very_strong: {
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: true,
preventCommonPasswords: true,
minStrengthScore: 3
}
};
return configs[complexityLevel] || configs.moderate;
}
/**
* Validate password for specific contexts (admin, gallery)
* @param {string} password - Password to validate
@@ -120,19 +195,16 @@ function validatePassword(password, options = {}) {
* @param {Object} userData - Additional user data for context-aware validation
* @returns {Object} - Validation result
*/
function validatePasswordInContext(password, context, userData = {}) {
// For gallery context, use more lenient validation
async function validatePasswordInContext(password, context, userData = {}) {
// For gallery context, use dynamic complexity settings
if (context === 'gallery') {
// Gallery-specific validation options
// Get complexity settings from database
const complexityLevel = await getPasswordComplexitySettings();
// Get configuration for the complexity level
const galleryOptions = {
minLength: 6, // Reduced minimum length
requireUppercase: false, // Don't require uppercase for galleries
requireLowercase: false, // Don't require lowercase for galleries
requireNumbers: false, // Numbers are optional
requireSpecialChars: false, // Special chars are optional
preventCommonPasswords: true, // Still prevent common passwords
minStrengthScore: 0, // Accept any score for galleries
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
...getPasswordConfigForComplexity(complexityLevel),
skipStrengthCheck: complexityLevel === 'simple' // Skip zxcvbn for simple passwords
};
// Base validation with gallery-specific options
@@ -281,5 +353,7 @@ module.exports = {
generateSecurePassword,
getBcryptRounds,
logPasswordValidationFailure,
getPasswordComplexitySettings,
getPasswordConfigForComplexity,
PASSWORD_CONFIG
};
+153 -5
View File
@@ -1,18 +1,24 @@
{
"name": "picpeak-frontend",
"version": "1.0.58",
"version": "1.0.69",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.58",
"version": "1.0.69",
"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",
@@ -22,6 +28,8 @@
"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",
@@ -1541,6 +1549,20 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-character-count": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.26.1.tgz",
"integrity": "sha512-F7LP1a9GF28thbApowWT2I41baqX74HMUTrV9LGrNXaOkW2gxZz+CDOzfHsbHyfuwfIxIjv07Qf/HKA6Cc1qbA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.25.0.tgz",
@@ -1568,6 +1590,23 @@
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block-lowlight": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.26.1.tgz",
"integrity": "sha512-yptuTPYAzVMKHUTwNKYveuu0rYHYyFknPz3O2++PWeeBGxkNB+T6LhwZ/JhXceHcZxzlGyka9r2mXR7pslhugw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/extension-code-block": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"highlight.js": "^11",
"lowlight": "^2 || ^3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.25.0.tgz",
@@ -1627,9 +1666,9 @@
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.25.0.tgz",
"integrity": "sha512-h8be5Zdtsl5GQHxRXvYlGfIJsLvdbexflSTr12gr4kvcQqTdtrsqyu2eksfAK+p2szbiwP2G4VZlH0LNS47UXQ==",
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.26.1.tgz",
"integrity": "sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1749,6 +1788,20 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz",
"integrity": "sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.25.0.tgz",
@@ -1775,6 +1828,19 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.26.1.tgz",
"integrity": "sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.25.0.tgz",
@@ -1935,6 +2001,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
@@ -1955,6 +2030,12 @@
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
@@ -2012,6 +2093,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -3233,6 +3320,19 @@
"reusify": "^1.0.4"
}
},
"node_modules/fault": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
"integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3350,6 +3450,14 @@
"node": ">= 6"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -3576,6 +3684,16 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -3935,6 +4053,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -3954,6 +4078,30 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz",
"integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"fault": "^2.0.0",
"highlight.js": "~11.8.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lowlight/node_modules/highlight.js": {
"version": "11.8.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+9 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.58",
"version": "1.0.69",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,10 +12,16 @@
},
"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",
@@ -25,6 +31,8 @@
"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",
+29 -14
View File
@@ -23,6 +23,7 @@ import {
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';
@@ -43,17 +44,26 @@ 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();
try {
// Fetch public settings to get Umami configuration
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
// Check if Umami is enabled and configured in backend settings
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
// Use backend configuration
analyticsService.initialize({
websiteId: settings.umami_website_id,
hostUrl: settings.umami_url,
autoTrack: true,
doNotTrack: true
});
} else {
// Fall back to environment variables if backend not configured
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
// Only initialize if analytics is enabled in settings
if (settings.enable_analytics !== false) {
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
@@ -61,9 +71,14 @@ function App() {
doNotTrack: true
});
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Initialize analytics anyway if settings fetch fails
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Fall back to environment variables on error
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
@@ -109,7 +124,7 @@ function App() {
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPage />} />
<Route path="cms" element={<CMSPageEnhanced />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
+488 -108
View File
@@ -1,7 +1,13 @@
import React, { useState } from 'react';
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,
@@ -10,33 +16,103 @@ import {
Link as LinkIcon,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Quote,
Code,
Code2,
Minus,
Undo,
Redo
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;
}
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
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,
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()) {
@@ -61,12 +137,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
active?: boolean;
children: React.ReactNode;
title: string;
}> = ({ onClick, active, children, title }) => (
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
className={`p-2 rounded hover:bg-neutral-100 ${
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"
>
@@ -74,116 +152,418 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
</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="border border-neutral-300 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 border-b border-neutral-200 bg-neutral-50 flex-wrap">
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Heading 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"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<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>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold"
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic"
>
<Italic className="w-4 h-4" />
</MenuButton>
{/* 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().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered 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={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title="Add Link"
>
<LinkIcon 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={() => editor.chain().focus().undo().run()}
title="Undo"
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
title="Redo"
>
<Redo 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>
{/* 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>
{/* 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>
)}
{/* Editor */}
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral max-w-none focus:outline-none"
/>
</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';
+1
View File
@@ -8,6 +8,7 @@ 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';
@@ -2,9 +2,28 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
// SVG Flag Components
const GBFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#012169" d="M0 0h640v480H0z"/>
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0h75z"/>
<path fill="#C8102E" d="m424 281 216 159v40L369 281h55zm-184 20 6 35L54 480H0l240-179zM640 0v3L391 191l2-44L590 0h50zM0 0l239 176h-60L0 42V0z"/>
<path fill="#FFF" d="M241 0v480h160V0H241zM0 160v160h640V160H0z"/>
<path fill="#C8102E" d="M0 193v96h640v-96H0zM273 0v480h96V0h-96z"/>
</svg>
);
const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#000" d="M0 0h640v160H0z"/>
<path fill="#D00" d="M0 160h640v160H0z"/>
<path fill="#FFCE00" d="M0 320h640v160H0z"/>
</svg>
);
const languages = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
];
export const LanguageSelector: React.FC = () => {
@@ -25,7 +44,7 @@ export const LanguageSelector: React.FC = () => {
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>
<currentLanguage.Flag className="w-5 h-5" />
<span>{currentLanguage.name}</span>
</button>
@@ -41,7 +60,7 @@ export const LanguageSelector: React.FC = () => {
: 'text-neutral-700'
}`}
>
<span className="text-lg">{language.flag}</span>
<language.Flag className="w-5 h-5" />
<span>{language.name}</span>
</button>
))}
+15 -3
View File
@@ -1,17 +1,28 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../services/settings.service';
export const useLocalizedDate = () => {
const { i18n } = useTranslation();
// Fetch admin settings to get the date format
const { data: settings } = useQuery({
queryKey: ['admin-settings-general'],
queryFn: () => settingsService.getSettingsByType('general'),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const getLocale = () => {
return i18n.language === 'de' ? de : enUS;
};
const format = (date: Date | string, formatStr: string) => {
const format = (date: Date | string, formatStr?: string) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormat(dateObj, formatStr, { locale: getLocale() });
// Use admin-configured date format if available and no format string provided
const dateFormat = formatStr || settings?.general_date_format || 'PPP';
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
};
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
@@ -22,6 +33,7 @@ export const useLocalizedDate = () => {
return {
format,
formatDistanceToNow,
locale: getLocale()
locale: getLocale(),
dateFormat: settings?.general_date_format || 'PPP'
};
};
+12
View File
@@ -428,6 +428,12 @@
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge",
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
"passwordComplexity": "Passwort-Komplexität",
"passwordComplexityHelp": "Sicherheitsstufe für Galerie-Passwörter",
"complexitySimple": "Einfach (6+ Zeichen, beliebiger Text)",
"complexityModerate": "Moderat (8+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
"complexityStrong": "Stark (12+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
"complexityVeryStrong": "Sehr stark (12+ Zeichen, alle Zeichentypen)",
"sessionAuth": "Sitzung & Authentifizierung",
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
@@ -721,6 +727,12 @@
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
"favicon_uploaded": "Favicon hochgeladen",
"analytics_settings_updated": "Analytik-Einstellungen aktualisiert",
"cms_page_updated": "CMS-Seite aktualisiert: {{page}}",
"security_settings_updated": "Sicherheitseinstellungen aktualisiert",
"password_reset": "Passwort zurückgesetzt für: {{eventName}}",
"admin_logout": "Admin {{actorName}} abgemeldet",
"system_activity": "Systemaktivität: {{type}}",
"unknown": "Unbekannte Aktivität"
}
},
+36
View File
@@ -447,6 +447,12 @@
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length",
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
"passwordComplexity": "Password Complexity",
"passwordComplexityHelp": "Security level required for gallery passwords",
"complexitySimple": "Simple (6+ chars, any text)",
"complexityModerate": "Moderate (8+ chars, mixed case/numbers)",
"complexityStrong": "Strong (12+ chars, uppercase/lowercase/numbers)",
"complexityVeryStrong": "Very Strong (12+ chars, all character types)",
"sessionAuth": "Session & Authentication",
"sessionTimeout": "Session Timeout (minutes)",
"sessionTimeoutHelp": "Admin session timeout in minutes",
@@ -494,6 +500,30 @@
"sent": "Sent",
"failed": "Failed",
"lastUpdate": "Last update"
},
"analytics": {
"title": "Analytics",
"umamiIntegration": "Umami Analytics Integration",
"enableUmami": "Enable Umami Analytics",
"umamiUrl": "Umami URL",
"umamiUrlHelp": "The URL of your Umami instance (e.g., https://analytics.yourdomain.com)",
"websiteId": "Website ID",
"websiteIdHelp": "Your Umami website ID (found in Umami dashboard)",
"shareUrl": "Share URL (Optional)",
"shareUrlHelp": "Public share URL for embedding the full dashboard (create in Umami)",
"umamiInfo": "About Umami Analytics",
"umamiInfoText": "Umami is a privacy-focused, open-source analytics platform. It tracks page views, unique visitors, and custom events without using cookies.",
"learnMore": "Learn more about Umami",
"saveAnalyticsSettings": "Save Analytics Settings",
"backendAnalytics": "Backend Analytics",
"backendAnalyticsText": "The system also tracks basic analytics server-side for security and performance monitoring.",
"tracked": "Tracked Metrics",
"galleryViews": "Gallery page views",
"photoDownloads": "Individual and bulk downloads",
"uniqueVisitors": "Unique visitors by IP",
"deviceTypes": "Device types from user agents",
"privacy": "Privacy",
"privacyText": "IP addresses are hashed for privacy. No personal data is stored. Analytics data is retained for 90 days."
}
},
"analytics": {
@@ -772,6 +802,12 @@
"category_deleted": "Category deleted: {{categoryName}}",
"general_settings_updated": "General settings updated",
"favicon_uploaded": "Favicon uploaded",
"analytics_settings_updated": "Analytics settings updated",
"cms_page_updated": "CMS page updated: {{page}}",
"security_settings_updated": "Security settings updated",
"password_reset": "Password reset for: {{eventName}}",
"admin_logout": "Admin {{actorName}} logged out",
"system_activity": "System activity: {{type}}",
"unknown": "Unknown activity"
}
},
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './styles/prose-overrides.css'
import './i18n/config'
import App from './App.tsx'
+10 -5
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Calendar, AlertCircle, Clock } from 'lucide-react';
import { AlertCircle, Clock } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
@@ -292,10 +292,15 @@ export const GalleryPage: React.FC = () => {
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
<span className="truncate">{format(parseISO(galleryInfo!.event_date), 'PP')}</span>
</div>
{galleryInfo?.event_type && (
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<span className="px-3 py-1 rounded-full" style={{ backgroundColor: 'var(--color-primary, #5C8762)', opacity: 0.1 }}>
<span style={{ color: 'var(--color-primary, #5C8762)' }}>
{t(`events.types.${galleryInfo.event_type}`)}
</span>
</span>
</div>
)}
</div>
{/* Expiration Warning */}
+60 -15
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
BarChart3,
TrendingUp,
@@ -52,10 +52,8 @@ export const AnalyticsPage: React.FC = () => {
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d');
const [isEmbedMode, setIsEmbedMode] = useState(false);
// Check if Umami is configured
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
// const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
const umamiShareUrl = import.meta.env.VITE_UMAMI_SHARE_URL;
// Check if Umami is configured from settings or environment
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string }>({});
// Fetch analytics data from backend
const { data: apiData, isLoading, refetch } = useQuery({
@@ -73,6 +71,49 @@ export const AnalyticsPage: React.FC = () => {
queryFn: () => adminService.getDashboardStats(),
});
// Fetch public settings to get Umami config
useEffect(() => {
const fetchUmamiConfig = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
const settings = await response.json();
// Check if Umami is enabled in admin settings
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
setUmamiConfig({
url: settings.umami_url,
shareUrl: settings.umami_share_url
});
} else {
// Fall back to environment variables if they exist
const envUrl = import.meta.env.VITE_UMAMI_URL;
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (envUrl && envWebsiteId) {
setUmamiConfig({
url: envUrl,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
});
}
}
} catch (error) {
console.error('Failed to fetch Umami config:', error);
// Fall back to environment variables if they exist
const envUrl = import.meta.env.VITE_UMAMI_URL;
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (envUrl && envWebsiteId) {
setUmamiConfig({
url: envUrl,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
});
}
}
};
fetchUmamiConfig();
}, []);
// Calculate trends and format data
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
if (!apiData) return undefined;
@@ -96,11 +137,15 @@ export const AnalyticsPage: React.FC = () => {
const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0);
const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0;
// Format top galleries for downloads
const topGalleriesWithDownloads = apiData.topGalleries.map(gallery => ({
name: gallery.event_name,
downloads: gallery.views // Using views as download count for now
}));
// Get actual download data for top galleries - sort by downloads
const topGalleriesWithDownloads = apiData.topGalleries
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
.slice(0, 5) // Take top 5
.map(gallery => ({
name: gallery.event_name,
downloads: gallery.downloads || 0
}));
return {
pageViews: {
@@ -122,7 +167,7 @@ export const AnalyticsPage: React.FC = () => {
topPages: apiData.topGalleries.map(gallery => ({
path: `/gallery/${gallery.slug}`,
views: gallery.views,
uniqueVisitors: Math.round(gallery.views * 0.4) // Estimate unique visitors
uniqueVisitors: gallery.uniqueVisitors || gallery.views // Use actual unique visitors if available
}))
};
}, [apiData]);
@@ -166,7 +211,7 @@ export const AnalyticsPage: React.FC = () => {
}
// If Umami is configured and embed mode is enabled, show the Umami dashboard
if (isEmbedMode && umamiShareUrl) {
if (isEmbedMode && umamiConfig.shareUrl) {
return (
<div>
<div className="flex justify-between items-center mb-6">
@@ -185,7 +230,7 @@ export const AnalyticsPage: React.FC = () => {
<Card padding="none" className="overflow-hidden" style={{ height: '800px' }}>
<iframe
src={umamiShareUrl}
src={umamiConfig.shareUrl}
className="w-full h-full border-0"
title="Umami Analytics Dashboard"
/>
@@ -203,7 +248,7 @@ export const AnalyticsPage: React.FC = () => {
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
{umamiShareUrl && (
{umamiConfig.shareUrl && (
<Button
variant="outline"
onClick={() => setIsEmbedMode(true)}
@@ -405,7 +450,7 @@ export const AnalyticsPage: React.FC = () => {
</div>
{/* Configuration Notice */}
{!umamiUrl && (
{!umamiConfig.url && (
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
+2 -11
View File
@@ -184,21 +184,12 @@ export const CMSPage: React.FC = () => {
<CMSEditor
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
onChange={handleContentChange}
onSave={handleSave}
isSaving={updateMutation.isPending}
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="primary"
onClick={handleSave}
isLoading={updateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('cms.saveChanges')}
</Button>
</div>
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 mt-4">
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
@@ -0,0 +1,290 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, FileText, Globe, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { debounce } from 'lodash';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
export const CMSPageEnhanced: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isAutoSaving, setIsAutoSaving] = useState(false);
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
queryKey: ['cms-pages'],
queryFn: cmsService.getPages,
});
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
cmsService.updatePage(slug, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
setHasUnsavedChanges(false);
setLastSaved(new Date());
setIsAutoSaving(false);
if (!isAutoSaving) {
toast.success(t('cms.pageUpdated'));
}
},
onError: () => {
setIsAutoSaving(false);
toast.error(t('toast.saveError'));
},
});
// Auto-save functionality
const autoSave = useCallback(
debounce(() => {
if (hasUnsavedChanges && !updateMutation.isPending) {
setIsAutoSaving(true);
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
}
}, 3000),
[hasUnsavedChanges, editForm, selectedPage]
);
// Trigger auto-save when content changes
useEffect(() => {
if (hasUnsavedChanges) {
autoSave();
}
return () => {
autoSave.cancel();
};
}, [hasUnsavedChanges, autoSave]);
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
setHasUnsavedChanges(false);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
autoSave.cancel(); // Cancel any pending auto-save
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
setHasUnsavedChanges(true);
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
setHasUnsavedChanges(true);
};
// Warn before leaving with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('cms.loadingPages')} />
</div>
);
}
const currentPage = pages?.find(p => p.slug === selectedPage);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">{t('cms.title')}</h1>
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('cms.pages')}</h2>
<div className="space-y-2">
{pages?.map((page) => (
<button
key={page.slug}
onClick={() => {
if (hasUnsavedChanges) {
if (confirm('You have unsaved changes. Do you want to save them?')) {
handleSave();
}
}
setSelectedPage(page.slug);
}}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
selectedPage === page.slug
? 'bg-primary-100 text-primary-700 border border-primary-300'
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
<FileText className="w-5 h-5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
<p className="text-sm text-neutral-500">/{page.slug}</p>
</div>
{selectedPage === page.slug && hasUnsavedChanges && (
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
)}
</button>
))}
</div>
</Card>
<Card padding="md" className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('cms.previewLinks')}</h3>
<div className="space-y-2 text-sm">
<a
href={`${window.location.origin}/${selectedPage}?lang=en`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.englishVersion')}
</a>
<a
href={`${window.location.origin}/${selectedPage}?lang=de`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.germanVersion')}
</a>
</div>
</Card>
{/* Auto-save status */}
{(hasUnsavedChanges || lastSaved) && (
<Card padding="md" className="mt-4">
<div className="text-sm">
{isAutoSaving && (
<div className="flex items-center gap-2 text-neutral-600">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Auto-saving...
</div>
)}
{!isAutoSaving && hasUnsavedChanges && (
<div className="flex items-center gap-2 text-yellow-600">
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
Unsaved changes
</div>
)}
{!hasUnsavedChanges && lastSaved && (
<div className="flex items-center gap-2 text-green-600">
<Clock className="w-4 h-4" />
Saved {new Date(lastSaved).toLocaleTimeString()}
</div>
)}
</div>
</Card>
)}
</div>
{/* Editor */}
<div className="lg:col-span-3">
<Card padding="md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900">
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
</h2>
{/* Language Tabs */}
<div className="flex gap-2">
<button
onClick={() => setEditingLang('en')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
Deutsch
</button>
</div>
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<Input
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={t('cms.pageTitlePlaceholder')}
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<CMSEditor
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
onChange={handleContentChange}
onSave={handleSave}
isSaving={updateMutation.isPending}
/>
</div>
</div>
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 mt-4">
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
</p>
)}
</Card>
</div>
</div>
</div>
);
};
@@ -10,14 +10,14 @@ import {
Eye,
EyeOff
} from 'lucide-react';
import { format, addDays } from 'date-fns';
import { enUS, de } from 'date-fns/locale';
import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
@@ -57,6 +57,7 @@ const EVENT_TYPES = [
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { format } = useLocalizedDate();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
@@ -320,12 +321,11 @@ export const CreateEventPageEnhanced: React.FC = () => {
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.welcomeMessage')}
</label>
<textarea
<WelcomeMessageEditor
value={formData.welcome_message}
onChange={handleInputChange('welcome_message')}
onChange={(value) => setFormData(prev => ({ ...prev, welcome_message: value }))}
placeholder={t('events.welcomeMessagePlaceholder')}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={3}
rows={4}
/>
</div>
</div>
@@ -500,7 +500,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
</p>
)}
</div>
+187 -2
View File
@@ -21,7 +21,7 @@ import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics'>('general');
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
@@ -64,6 +64,7 @@ export const SettingsPage: React.FC = () => {
const [securitySettings, setSecuritySettings] = useState({
require_password: true,
password_min_length: 8,
password_complexity: 'moderate',
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
@@ -72,6 +73,14 @@ export const SettingsPage: React.FC = () => {
recaptcha_secret_key: ''
});
// Analytics settings state
const [analyticsSettings, setAnalyticsSettings] = useState({
umami_enabled: false,
umami_url: '',
umami_website_id: '',
umami_share_url: ''
});
React.useEffect(() => {
if (settings) {
// Set the language if it's different from current
@@ -104,8 +113,16 @@ export const SettingsPage: React.FC = () => {
recaptcha_site_key: settings.security_recaptcha_site_key || '',
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
});
// Extract analytics settings
setAnalyticsSettings({
umami_enabled: settings.analytics_umami_enabled || false,
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
});
}
}, [settings]);
}, [settings, i18n]);
// Save mutations
const saveGeneralMutation = useMutation({
@@ -144,6 +161,24 @@ export const SettingsPage: React.FC = () => {
}
});
const saveAnalyticsMutation = useMutation({
mutationFn: async () => {
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => {
settingsData[`analytics_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -202,6 +237,16 @@ export const SettingsPage: React.FC = () => {
>
{t('settings.categories.title')}
</button>
<button
onClick={() => setActiveTab('analytics')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'analytics'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.analytics.title')}
</button>
</nav>
</div>
@@ -621,6 +666,25 @@ export const SettingsPage: React.FC = () => {
max="32"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.passwordComplexity')}
</label>
<select
value={securitySettings.password_complexity}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="simple">{t('settings.security.complexitySimple')}</option>
<option value="moderate">{t('settings.security.complexityModerate')}</option>
<option value="strong">{t('settings.security.complexityStrong')}</option>
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
</select>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.passwordComplexityHelp')}
</p>
</div>
</div>
</Card>
@@ -754,6 +818,127 @@ export const SettingsPage: React.FC = () => {
</Card>
</div>
)}
{/* Analytics Tab */}
{activeTab === 'analytics' && (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={analyticsSettings.umami_enabled}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
</label>
{analyticsSettings.umami_enabled && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
<p>{t('settings.analytics.umamiInfoText')}</p>
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
{t('settings.analytics.learnMore')}
</a>
</div>
</div>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveAnalyticsMutation.mutate()}
isLoading={saveAnalyticsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.analytics.saveAnalyticsSettings')}
</Button>
</div>
</Card>
{/* Backend Analytics Info */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
<ul className="text-xs text-neutral-600 space-y-1">
<li> {t('settings.analytics.galleryViews')}</li>
<li> {t('settings.analytics.photoDownloads')}</li>
<li> {t('settings.analytics.uniqueVisitors')}</li>
<li> {t('settings.analytics.deviceTypes')}</li>
</ul>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
<p className="text-xs text-neutral-600">
{t('settings.analytics.privacyText')}
</p>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
+15 -1
View File
@@ -7,6 +7,7 @@ import DOMPurify from 'dompurify';
import { Loading, Card } from '../../components/common';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
import '../../styles/prose-overrides.css';
export const LegalPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
@@ -104,7 +105,20 @@ export const LegalPage: React.FC = () => {
<div
className="prose prose-neutral max-w-none"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(page.content) }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.content, {
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
})
}}
/>
</Card>
+13 -1
View File
@@ -162,7 +162,19 @@ export const settingsService = {
// Update multiple settings at once
async updateSettings(settings: Record<string, any>): Promise<void> {
await api.put('/admin/settings/general', settings);
// Determine the endpoint based on setting keys
const firstKey = Object.keys(settings)[0];
let endpoint = '/admin/settings/general';
if (firstKey?.startsWith('security_')) {
endpoint = '/admin/settings/security';
} else if (firstKey?.startsWith('analytics_')) {
endpoint = '/admin/settings/analytics';
} else if (firstKey?.startsWith('branding_')) {
endpoint = '/admin/settings/branding';
}
await api.put(endpoint, settings);
},
// Get storage information
+98
View File
@@ -0,0 +1,98 @@
/* Prose overrides for CMS content */
/* Preserve line breaks in prose content */
.prose br {
display: block !important;
content: "" !important;
margin: 0.5em 0 !important;
}
/* Hard breaks should create visible line breaks */
.prose .hard-break {
display: block !important;
height: 0.5em !important;
}
/* Ensure paragraphs have proper spacing */
.prose p {
margin-top: 1em;
margin-bottom: 1em;
}
.prose p:first-child {
margin-top: 0;
}
.prose p:last-child {
margin-bottom: 0;
}
/* Code block styling */
.prose pre {
background-color: #f5f5f5;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
margin: 1.5em 0;
}
.prose pre code {
background-color: transparent;
padding: 0;
font-size: 0.875em;
color: inherit;
}
/* Inline code styling */
.prose code {
background-color: #f5f5f5;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-weight: 400;
}
/* Text alignment classes */
.prose .text-left {
text-align: left !important;
}
.prose .text-center {
text-align: center !important;
}
.prose .text-right {
text-align: right !important;
}
.prose .text-justify {
text-align: justify !important;
}
/* Syntax highlighting for code blocks */
.prose .hljs {
background: transparent !important;
padding: 0 !important;
}
/* Basic syntax highlighting colors */
.hljs-keyword { color: #a626a4; }
.hljs-string { color: #50a14f; }
.hljs-comment { color: #a0a1a7; font-style: italic; }
.hljs-number { color: #e45649; }
.hljs-function { color: #4078f2; }
.hljs-tag { color: #e45649; }
.hljs-attribute { color: #986801; }
.hljs-selector-class { color: #986801; }
.hljs-selector-id { color: #986801; }
/* Preserve whitespace in content */
.prose-preserve-whitespace {
white-space: pre-wrap;
}
/* Ensure empty paragraphs with only line breaks are visible */
.prose p:empty::before {
content: "\200B"; /* Zero-width space */
display: inline;
}