Fix brand theme application and add comprehensive translations

- Fixed theme not being reflected on gallery and admin login pages
- Created GlobalThemeProvider to apply themes globally
- Updated gallery and admin login pages to use dynamic CSS variables
- Added complete translations for all admin sections in English and German:
  - Notifications management
  - Event view and creation
  - Photo upload functionality
  - Category management
  - Archive page view
  - Analytics dashboard
  - Branding and theme settings
  - System settings
  - CMS page management
  - Email configuration
- Fixed admin photo management display issues
- Fixed photo upload category assignment
- Added password reset functionality for galleries
- Improved error handling and user feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-08 17:07:40 +02:00
parent 2012b0bab9
commit d594d00227
79 changed files with 4570 additions and 329 deletions
View File
Binary file not shown.
@@ -0,0 +1,15 @@
exports.up = async function(knex) {
// Add read_at column to activity_logs table
const hasReadAt = await knex.schema.hasColumn('activity_logs', 'read_at');
if (!hasReadAt) {
await knex.schema.table('activity_logs', (table) => {
table.datetime('read_at').nullable();
});
}
};
exports.down = async function(knex) {
await knex.schema.table('activity_logs', (table) => {
table.dropColumn('read_at');
});
};
+7 -8
View File
@@ -28,7 +28,7 @@ async function runMigration(filename) {
if (migration.up) {
console.log(`Running migration: ${filename}`);
await migration.up();
await migration.up(db);
await db('migrations').insert({ filename });
console.log(`Migration ${filename} completed`);
}
@@ -39,13 +39,12 @@ async function runMigrations() {
try {
console.log('Starting database migrations...');
// First run the init.js if it exists
const initPath = path.join(__dirname, 'init.js');
if (require.resolve(initPath)) {
console.log('Running initial setup...');
require(initPath);
// Wait a bit for init to complete
await new Promise(resolve => setTimeout(resolve, 2000));
// First run the init.js if it exists but only if migrations table doesn't exist
const tableExists = await db.schema.hasTable('migrations');
if (!tableExists) {
const { initializeDatabase } = require('../src/database/db');
console.log('Running initial database setup...');
await initializeDatabase();
}
// Create migrations table
+49 -6
View File
@@ -8,7 +8,9 @@
"name": "photo-sharing-backend",
"version": "1.0.0",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.10.0",
"bcrypt": "^5.1.0",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
@@ -16,6 +18,7 @@
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"form-data": "^4.0.3",
"helmet": "^7.0.0",
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
@@ -1525,6 +1528,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -1773,9 +1785,19 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@@ -2575,7 +2597,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -2892,7 +2913,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -3151,7 +3171,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3701,11 +3720,30 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -4011,7 +4049,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -6724,6 +6761,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+3
View File
@@ -11,7 +11,9 @@
"lint": "eslint src/"
},
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.10.0",
"bcrypt": "^5.1.0",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
@@ -19,6 +21,7 @@
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"form-data": "^4.0.3",
"helmet": "^7.0.0",
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
const sqlite3 = require('sqlite3').verbose();
// Connect to the database
const dbPath = '/app/data/photo_sharing.db';
console.log(`Connecting to database at: ${dbPath}`);
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
if (err) {
console.error('Error opening database:', err.message);
process.exit(1);
}
console.log('Connected to the SQLite database.\n');
});
// Get schema for events table
console.log('=== EVENTS TABLE SCHEMA ===');
db.all("PRAGMA table_info(events)", [], (err, rows) => {
if (err) {
console.error('Error getting events schema:', err.message);
} else {
rows.forEach(row => {
console.log(`${row.name} (${row.type})`);
});
}
console.log('\n=== PHOTOS TABLE SCHEMA ===');
// Get schema for photos table
db.all("PRAGMA table_info(photos)", [], (err, rows) => {
if (err) {
console.error('Error getting photos schema:', err.message);
} else {
rows.forEach(row => {
console.log(`${row.name} (${row.type})`);
});
}
// Close the database
db.close();
});
});
+56
View File
@@ -0,0 +1,56 @@
const knex = require('knex')({
client: 'sqlite3',
connection: { filename: '/app/data/photo_sharing.db' },
useNullAsDefault: true
});
async function debugEventPhotos() {
try {
// Get all photos for event 12
const photos = await knex('photos')
.where('event_id', 12)
.select('id', 'filename', 'path', 'thumbnail_path')
.orderBy('id');
console.log('Total photos for event 12:', photos.length);
console.log('\nSample photos:');
// Show first few and specific IDs that were failing
const sampleIds = [1686, 1687, 1688, 1689, 1715, 1717, 1718, 1719];
const samples = photos.filter(p => sampleIds.includes(p.id));
samples.forEach(p => {
console.log(`\nID ${p.id}: ${p.filename}`);
console.log(` Path: ${p.path}`);
console.log(` Thumbnail: ${p.thumbnail_path}`);
});
// Check for any photos without thumbnails
const noThumbs = photos.filter(p => !p.thumbnail_path);
if (noThumbs.length > 0) {
console.log(`\nPhotos without thumbnails: ${noThumbs.length}`);
noThumbs.forEach(p => console.log(` ID ${p.id}: ${p.filename}`));
}
// Check file existence for failing photos
const fs = require('fs').promises;
console.log('\nChecking file existence for samples:');
for (const photo of samples) {
const thumbPath = `/app/storage/${photo.thumbnail_path}`;
try {
await fs.access(thumbPath);
console.log(`✓ ID ${photo.id}: Thumbnail exists at ${thumbPath}`);
} catch (err) {
console.log(`✗ ID ${photo.id}: Thumbnail NOT FOUND at ${thumbPath}`);
}
}
} catch (error) {
console.error('Error:', error);
} finally {
knex.destroy();
}
}
debugEventPhotos();
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
const sqlite3 = require('sqlite3').verbose();
const fs = require('fs');
const path = require('path');
// Connect to the database
const dbPath = '/app/data/photo_sharing.db';
console.log(`Connecting to database at: ${dbPath}`);
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
if (err) {
console.error('Error opening database:', err.message);
process.exit(1);
}
console.log('Connected to the SQLite database.\n');
});
// Query for photos with IDs 1688 and 1689 where event_id = 12
const query = `
SELECT p.id, p.filename, p.path, p.thumbnail_path, p.event_id,
e.slug as event_slug, e.is_active, e.is_archived
FROM photos p
JOIN events e ON p.event_id = e.id
WHERE p.id IN (1688, 1689) AND p.event_id = 12
`;
console.log('Executing query to get photo details with event information...\n');
db.all(query, [], (err, rows) => {
if (err) {
console.error('Error executing query:', err.message);
db.close();
process.exit(1);
}
console.log(`Found ${rows.length} photo(s):\n`);
if (rows.length === 0) {
console.log('No photos found matching the criteria.');
} else {
rows.forEach((row) => {
console.log('=== Photo ID:', row.id, '===');
console.log('Filename:', row.filename);
console.log('DB Path:', row.path);
console.log('DB Thumbnail Path:', row.thumbnail_path);
console.log('Event ID:', row.event_id);
console.log('Event Slug:', row.event_slug);
console.log('Event is_active:', row.is_active);
console.log('Event is_archived:', row.is_archived);
// Check file existence
const storageBase = '/app/storage';
const eventStatusDir = row.is_active ? 'active' : 'archived';
// Check full image path
const fullImagePath1 = path.join(storageBase, row.path);
const fullImagePath2 = path.join(storageBase, 'events', eventStatusDir, row.path);
console.log('\nChecking full image paths:');
console.log(` Path 1: ${fullImagePath1} - ${fs.existsSync(fullImagePath1) ? 'EXISTS' : 'NOT FOUND'}`);
console.log(` Path 2: ${fullImagePath2} - ${fs.existsSync(fullImagePath2) ? 'EXISTS' : 'NOT FOUND'}`);
// Check thumbnail path
const thumbnailPath = path.join(storageBase, row.thumbnail_path);
console.log('\nChecking thumbnail path:');
console.log(` ${thumbnailPath} - ${fs.existsSync(thumbnailPath) ? 'EXISTS' : 'NOT FOUND'}`);
console.log('\n---\n');
});
}
// Close the database connection
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
} else {
console.log('Database connection closed.');
}
});
});
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
// Connect to the database
const dbPath = '/app/data/photo_sharing.db';
console.log(`Connecting to database at: ${dbPath}`);
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
if (err) {
console.error('Error opening database:', err.message);
process.exit(1);
}
console.log('Connected to the SQLite database.');
});
// Query for photos with IDs 1688 and 1689 where event_id = 12
const query = `
SELECT id, filename, path, thumbnail_path
FROM photos
WHERE id IN (1688, 1689) AND event_id = 12
`;
console.log('\nExecuting query:', query);
db.all(query, [], (err, rows) => {
if (err) {
console.error('Error executing query:', err.message);
db.close();
process.exit(1);
}
console.log(`\nFound ${rows.length} photo(s):\n`);
if (rows.length === 0) {
console.log('No photos found matching the criteria.');
} else {
rows.forEach((row) => {
console.log('Photo ID:', row.id);
console.log('Filename:', row.filename);
console.log('Path:', row.path);
console.log('Thumbnail Path:', row.thumbnail_path);
console.log('---');
});
}
// Close the database connection
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
} else {
console.log('\nDatabase connection closed.');
}
});
});
+55
View File
@@ -0,0 +1,55 @@
const axios = require('axios');
async function testAdminPhotoEndpoint() {
try {
// First login
console.log('1. Logging in as admin...');
const loginResponse = await axios.post('http://localhost:3000/api/admin/auth/login', {
username: 'admin',
password: 'admin123'
});
const token = loginResponse.data.token;
console.log('✓ Login successful, got token');
// Test thumbnail endpoint
console.log('\n2. Testing thumbnail endpoint for photo 1688...');
try {
const thumbResponse = await axios.get('http://localhost:3000/api/admin/events/12/thumbnail/1688', {
headers: {
Authorization: `Bearer ${token}`
},
responseType: 'arraybuffer'
});
console.log('✓ Thumbnail request successful');
console.log(' Response headers:', thumbResponse.headers);
console.log(' Data size:', thumbResponse.data.length, 'bytes');
} catch (error) {
console.error('✗ Thumbnail request failed:', error.response?.status, error.response?.data?.toString());
}
// Test from frontend proxy port
console.log('\n3. Testing through nginx proxy (port 3001)...');
try {
const proxyResponse = await axios.get('http://localhost:3001/api/admin/events/12/thumbnail/1688', {
headers: {
Authorization: `Bearer ${token}`,
Origin: 'http://localhost:3005'
},
responseType: 'arraybuffer'
});
console.log('✓ Proxy request successful');
console.log(' Response headers:', proxyResponse.headers);
console.log(' Data size:', proxyResponse.data.length, 'bytes');
} catch (error) {
console.error('✗ Proxy request failed:', error.response?.status, error.response?.data?.toString());
}
} catch (error) {
console.error('Error:', error.message);
}
}
testAdminPhotoEndpoint();
+59
View File
@@ -0,0 +1,59 @@
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
async function testUpload() {
try {
// First login
console.log('1. Logging in as admin...');
const loginResponse = await axios.post('http://localhost:3000/api/admin/auth/login', {
username: 'admin',
password: 'admin123'
});
const token = loginResponse.data.token;
console.log('✓ Login successful');
// Create a test image file
const testImagePath = path.join(__dirname, 'test-image.png');
const imageBuffer = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
fs.writeFileSync(testImagePath, imageBuffer);
// Test upload
console.log('\n2. Testing upload with category_id=7...');
const form = new FormData();
form.append('photos', fs.createReadStream(testImagePath), 'test-image.png');
form.append('category_id', '7');
console.log('Form data headers:', form.getHeaders());
try {
const uploadResponse = await axios.post(
'http://localhost:3000/api/admin/events/12/upload',
form,
{
headers: {
...form.getHeaders(),
'Authorization': `Bearer ${token}`
}
}
);
console.log('✓ Upload successful:', uploadResponse.data);
} catch (error) {
console.error('✗ Upload failed:', error.response?.status, error.response?.data);
if (error.response?.data) {
console.error('Error details:', JSON.stringify(error.response.data, null, 2));
}
}
// Clean up
fs.unlinkSync(testImagePath);
} catch (error) {
console.error('Error:', error.message);
}
}
testUpload();
+13 -1
View File
@@ -8,6 +8,8 @@ const path = require('path');
const { initializeDatabase } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
const { startExpirationChecker } = require('./src/services/expirationChecker');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const logger = require('./src/utils/logger');
// Import routes
@@ -50,7 +52,7 @@ app.use(cors(corsOptions));
// Rate limiting with admin bypass
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
skip: (req) => {
// Skip rate limiting for authenticated admin users
if (req.path.startsWith('/api/admin/') && req.headers.authorization) {
@@ -62,6 +64,10 @@ const limiter = rateLimit({
return false;
}
}
// Also skip rate limiting for public settings endpoint in development
if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') {
return true;
}
return false;
}
});
@@ -79,6 +85,12 @@ app.use('/api/auth', authLimiter);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
// Session timeout middleware for admin routes
app.use('/api/admin', sessionTimeoutMiddleware);
// Middleware to set CORS headers for static files
const setCorsHeaders = (req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
+9
View File
@@ -161,7 +161,16 @@ async function initializeDatabase() {
table.json('metadata'); // Additional data about the activity
table.integer('event_id').references('id').inTable('events');
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('read_at').nullable();
});
} else {
// Check if read_at column exists
const hasReadAt = await db.schema.hasColumn('activity_logs', 'read_at');
if (!hasReadAt) {
await db.schema.table('activity_logs', (table) => {
table.datetime('read_at').nullable();
});
}
}
}
+73
View File
@@ -0,0 +1,73 @@
const { db } = require('../database/db');
// Cache maintenance mode status to avoid DB queries on every request
let maintenanceMode = false;
let lastCheck = 0;
const CACHE_DURATION = 60000; // 1 minute
async function checkMaintenanceMode() {
const now = Date.now();
// Use cached value if recent
if (now - lastCheck < CACHE_DURATION) {
return maintenanceMode;
}
try {
const setting = await db('app_settings')
.where('setting_key', 'general_maintenance_mode')
.where('setting_type', 'general')
.first();
maintenanceMode = setting ? (setting.setting_value === 'true' || setting.setting_value === true) : false;
lastCheck = now;
return maintenanceMode;
} catch (error) {
console.error('Error checking maintenance mode:', error);
return false;
}
}
// Middleware to enforce maintenance mode
async function maintenanceMiddleware(req, res, next) {
// Skip maintenance check for certain paths
const skipPaths = [
'/api/admin/login',
'/api/admin/auth/login',
'/api/public/settings',
'/health'
];
// Allow static assets (uploads, favicons, logos)
const isStaticAsset = req.path.startsWith('/uploads/') ||
req.path.startsWith('/favicons/') ||
req.path.startsWith('/logos/');
// Allow admin routes if admin is authenticated
const isAdminRoute = req.path.startsWith('/api/admin');
const hasAdminAuth = req.headers.authorization?.startsWith('Bearer ');
if (skipPaths.includes(req.path) || isStaticAsset || (isAdminRoute && hasAdminAuth)) {
return next();
}
const inMaintenance = await checkMaintenanceMode();
if (inMaintenance && !isAdminRoute) {
return res.status(503).json({
error: 'Service Unavailable',
message: 'The system is currently undergoing maintenance. Please try again later.',
maintenance: true
});
}
next();
}
// Function to clear cache when settings change
function clearMaintenanceCache() {
lastCheck = 0;
}
module.exports = { maintenanceMiddleware, clearMaintenanceCache };
+37 -7
View File
@@ -4,7 +4,17 @@ const { db } = require('../database/db');
async function photoAuth(req, res, next) {
try {
const eventSlug = req.path.split('/')[1];
// Extract event slug from the path
let eventSlug;
// For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access
eventSlug = null;
} else {
// For regular photos, the slug is the first part of the path
eventSlug = req.path.split('/')[1];
}
// First check for JWT token (from gallery access)
const authHeader = req.headers.authorization;
@@ -13,17 +23,32 @@ async function photoAuth(req, res, next) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if it's a gallery token for this event
if (decoded.type === 'gallery' && decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
if (event) {
req.event = event;
return next();
// Check if it's a gallery token
if (decoded.type === 'gallery') {
// For thumbnails, we accept any valid gallery token
if (!eventSlug) {
const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first();
if (event) {
req.event = event;
return next();
}
}
// For regular photos, check if token matches the event
else if (decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
if (event) {
req.event = event;
return next();
}
}
}
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
if (!eventSlug) {
// For thumbnails with admin token, allow access
return next();
}
const event = await db('events').where({ slug: eventSlug }).first();
if (event) {
req.event = event;
@@ -42,6 +67,11 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
}
// If no eventSlug (thumbnails), we require JWT token
if (!eventSlug) {
return res.status(401).json({ error: 'Authentication required for thumbnails' });
}
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
+122
View File
@@ -0,0 +1,122 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
// In-memory session tracking (in production, use Redis)
const sessions = new Map();
// Default session timeout (60 minutes)
const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
// Clean up expired sessions every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [token, lastActivity] of sessions.entries()) {
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
sessions.delete(token);
}
}
}, 5 * 60 * 1000);
async function getSessionTimeout() {
try {
const setting = await db('app_settings')
.where('setting_key', 'security_session_timeout_minutes')
.first();
if (setting && setting.setting_value) {
const minutes = parseInt(JSON.parse(setting.setting_value));
return minutes * 60 * 1000; // Convert to milliseconds
}
} catch (error) {
console.error('Error getting session timeout:', error);
}
return DEFAULT_SESSION_TIMEOUT;
}
async function sessionTimeoutMiddleware(req, res, next) {
// Skip for non-authenticated routes
if (!req.headers.authorization) {
return next();
}
const token = req.headers.authorization.split(' ')[1];
if (!token) {
return next();
}
try {
// Verify token is valid
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if this is an admin token
if (!decoded.id) {
return next();
}
const now = Date.now();
const lastActivity = sessions.get(token);
const timeout = await getSessionTimeout();
// If session exists, check if it's expired
if (lastActivity) {
if (now - lastActivity > timeout) {
sessions.delete(token);
return res.status(401).json({
error: 'Session expired',
code: 'SESSION_TIMEOUT'
});
}
}
// Update last activity
sessions.set(token, now);
// Clean up old token if user has a new one
// This prevents memory leaks from token renewals
const userId = decoded.id;
for (const [oldToken, _] of sessions.entries()) {
if (oldToken !== token) {
try {
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET);
if (oldDecoded.id === userId) {
sessions.delete(oldToken);
}
} catch (e) {
// Token is invalid, remove it
sessions.delete(oldToken);
}
}
}
next();
} catch (error) {
// Token is invalid
next();
}
}
// Function to end a session
function endSession(token) {
sessions.delete(token);
}
// Function to get active sessions count
function getActiveSessions() {
const now = Date.now();
let active = 0;
for (const [_, lastActivity] of sessions.entries()) {
if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) {
active++;
}
}
return active;
}
module.exports = {
sessionTimeoutMiddleware,
endSession,
getActiveSessions
};
+2
View File
@@ -10,6 +10,7 @@ const eventsRoutes = require('./adminEvents');
const photosRoutes = require('./adminPhotos');
const categoriesRoutes = require('./adminCategories');
const cmsRoutes = require('./adminCMS');
const notificationsRoutes = require('./adminNotifications');
// Mount sub-routers
router.use('/dashboard', dashboardRoutes);
@@ -20,5 +21,6 @@ router.use('/events', eventsRoutes);
router.use('/events', photosRoutes);
router.use('/categories', categoriesRoutes);
router.use('/cms', cmsRoutes);
router.use('/notifications', notificationsRoutes);
module.exports = router;
+137 -18
View File
@@ -4,6 +4,7 @@ const fs = require('fs').promises;
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const router = express.Router();
// Get all archived events
@@ -34,11 +35,13 @@ router.get('/', adminAuth, async (req, res) => {
.offset(offset);
// Check if archive files exist and get their sizes
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archivesWithFileInfo = await Promise.all(archives.map(async (archive) => {
let archiveFileSize = 0;
if (archive.archive_path) {
try {
const stats = await fs.stat(archive.archive_path);
const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath);
archiveFileSize = stats.size;
} catch (error) {
console.error(`Archive file not found: ${archive.archive_path}`);
@@ -52,8 +55,8 @@ router.get('/', adminAuth, async (req, res) => {
eventDate: archive.event_date,
eventType: archive.event_type,
hostEmail: archive.host_email,
archivedAt: archive.archived_at,
expiresAt: archive.expires_at,
archivedAt: archive.archived_at ? new Date(archive.archived_at).toISOString() : null,
expiresAt: archive.expires_at ? new Date(archive.expires_at).toISOString() : null,
photoCount: archive.photo_count || 0,
originalSize: archive.total_size || 0,
archiveSize: archiveFileSize,
@@ -97,7 +100,9 @@ router.get('/:id', adminAuth, async (req, res) => {
let archiveFileInfo = null;
if (archive.archive_path) {
try {
const stats = await fs.stat(archive.archive_path);
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath);
archiveFileInfo = {
size: stats.size,
createdAt: stats.birthtime,
@@ -142,12 +147,123 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Archive not found' });
}
// Check if archive directory exists
const archiveDir = path.dirname(archive.archive_path);
const extractedDir = archive.archive_path.replace('.zip', '');
// TODO: Implement actual extraction logic
// For now, just update the database
// Check if archive file exists
if (!archive.archive_path) {
return res.status(400).json({ error: 'No archive file found' });
}
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const fullArchivePath = path.join(storagePath, archive.archive_path);
try {
await fs.access(fullArchivePath);
} catch (error) {
return res.status(404).json({ error: 'Archive file not found on disk' });
}
// Extract the archive
try {
const zip = new AdmZip(fullArchivePath);
const eventsDir = path.join(storagePath, 'events/active');
const eventDir = path.join(eventsDir, archive.slug);
// Create event directory if it doesn't exist
await fs.mkdir(eventDir, { recursive: true });
// Log ZIP contents for debugging
console.log(`Extracting archive to: ${eventDir}`);
const entries = zip.getEntries();
console.log(`Archive contains ${entries.length} entries`);
// Extract files to the event directory
zip.extractAllTo(eventDir, true);
// Get list of extracted files to update database
const extractedPhotos = [];
// First, collect all category information from the ZIP structure
const categoriesMap = new Map();
for (const entry of entries) {
if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
const filename = path.basename(entry.entryName);
const dirPath = path.dirname(entry.entryName);
const actualFilePath = path.join(eventDir, entry.entryName);
try {
// Check if file was extracted successfully
const stats = await fs.stat(actualFilePath);
// Determine category from directory structure
let categoryId = null;
if (dirPath && dirPath !== '.') {
// Get the first level directory as category
const categoryName = dirPath.split(path.sep)[0];
if (!categoriesMap.has(categoryName)) {
// Check if this category exists in the database
const existingCategory = await db('photo_categories')
.where('event_id', archive.id)
.where('name', categoryName)
.first();
if (existingCategory) {
categoriesMap.set(categoryName, existingCategory.id);
} else {
// Create the category if it doesn't exist
const [newCategoryId] = await db('photo_categories').insert({
event_id: archive.id,
name: categoryName,
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
created_at: new Date()
});
categoriesMap.set(categoryName, newCategoryId);
}
}
categoryId = categoriesMap.get(categoryName);
}
// Check if photo already exists in database
const existingPhoto = await db('photos')
.where('event_id', archive.id)
.where('filename', filename)
.first();
if (!existingPhoto) {
// Store relative path from storage root
const relativePath = path.relative(storagePath, actualFilePath);
extractedPhotos.push({
event_id: archive.id,
filename: filename,
original_filename: filename,
path: relativePath,
thumbnail_path: null, // Will be regenerated by thumbnail service
type: path.extname(filename).substring(1).toLowerCase(),
size_bytes: stats.size,
category_id: categoryId,
uploaded_at: new Date()
});
}
} catch (statError) {
console.error(`Failed to stat file: ${actualFilePath}`);
console.error(`Entry name was: ${entry.entryName}`);
console.error(`Error:`, statError.message);
// Skip this file if we can't stat it
continue;
}
}
}
// Insert new photos if any
if (extractedPhotos.length > 0) {
await db('photos').insert(extractedPhotos);
}
} catch (extractError) {
console.error('Archive extraction error:', extractError);
return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message });
}
// Update event status
await db('events')
@@ -164,8 +280,8 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
await db('activity_logs').insert({
activity_type: 'archive_restored',
actor_type: 'admin',
actor_id: req.user.id,
actor_name: req.user.username,
actor_id: req.admin.id,
actor_name: req.admin.username,
event_id: archive.id,
metadata: JSON.stringify({ event_name: archive.event_name })
});
@@ -194,8 +310,11 @@ router.get('/:id/download', adminAuth, async (req, res) => {
}
// Check if file exists
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const fullArchivePath = path.join(storagePath, archive.archive_path);
try {
await fs.access(archive.archive_path);
await fs.access(fullArchivePath);
} catch (error) {
return res.status(404).json({ error: 'Archive file not found on disk' });
}
@@ -205,15 +324,15 @@ router.get('/:id/download', adminAuth, async (req, res) => {
res.setHeader('Content-Disposition', `attachment; filename="${archive.slug}.zip"`);
// Stream the file
const fileStream = require('fs').createReadStream(archive.archive_path);
const fileStream = require('fs').createReadStream(fullArchivePath);
fileStream.pipe(res);
// Log download
await db('activity_logs').insert({
activity_type: 'archive_downloaded',
actor_type: 'admin',
actor_id: req.user.id,
actor_name: req.user.username,
actor_id: req.admin.id,
actor_name: req.admin.username,
event_id: archive.id,
metadata: JSON.stringify({ event_name: archive.event_name })
});
@@ -251,8 +370,8 @@ router.delete('/:id', adminAuth, async (req, res) => {
await db('activity_logs').insert({
activity_type: 'archive_deleted',
actor_type: 'admin',
actor_id: req.user.id,
actor_name: req.user.username,
actor_id: req.admin.id,
actor_name: req.admin.username,
metadata: JSON.stringify({
event_name: archive.event_name,
archived_date: archive.archived_at
+34 -2
View File
@@ -1,8 +1,9 @@
const express = require('express');
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { endSession } = require('../middleware/sessionTimeout');
const router = express.Router();
// Change password
@@ -18,7 +19,7 @@ router.post('/change-password', [
}
const { currentPassword, newPassword } = req.body;
const userId = req.user.id;
const userId = req.admin.id; // Changed from req.user.id to req.admin.id
// Get user from database
const user = await db('admin_users')
@@ -46,6 +47,13 @@ router.post('/change-password', [
updated_at: new Date()
});
// Log activity
await logActivity('password_changed',
{ admin_id: userId },
null,
{ type: 'admin', id: userId, name: user.username }
);
res.json({ message: 'Password changed successfully' });
} catch (error) {
console.error('Password change error:', error);
@@ -53,4 +61,28 @@ router.post('/change-password', [
}
});
// Logout
router.post('/logout', adminAuth, async (req, res) => {
try {
// Get token from header
const token = req.headers.authorization?.split(' ')[1];
if (token) {
// End the session
endSession(token);
}
// Log activity
await logActivity('admin_logout',
{ admin_id: req.admin.id },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Logged out successfully' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: 'Failed to logout' });
}
});
module.exports = router;
+143 -8
View File
@@ -7,6 +7,7 @@ const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const { archiveEvent } = require('../services/archiveService');
// Create new event
router.post('/', adminAuth, [
@@ -360,6 +361,67 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
}
});
// Reset event password
router.post('/:id/reset-password', adminAuth, async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true } = req.body;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.is_archived) {
return res.status(400).json({ error: 'Cannot reset password for archived event' });
}
// Generate new password
const { generatePassword } = require('../utils/passwordGenerator');
const newPassword = generatePassword();
const passwordHash = await bcrypt.hash(newPassword, 10);
// Update event with new password
await db('events')
.where('id', id)
.update({
password_hash: passwordHash,
updated_at: new Date()
});
// Log activity
await logActivity('password_reset',
{ eventName: event.event_name, emailSent: sendEmail },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue email notification if requested
if (sendEmail) {
await db('email_queue').insert({
event_id: id,
recipient_email: event.host_email,
email_type: 'password_reset',
email_data: JSON.stringify({
event_name: event.event_name,
share_link: event.share_link,
new_password: newPassword,
reset_by: req.admin.username
})
});
}
res.json({
message: 'Password reset successfully',
newPassword: newPassword,
emailSent: sendEmail
});
} catch (error) {
console.error('Error resetting password:', error);
res.status(500).json({ error: 'Failed to reset password' });
}
});
// Archive event
router.post('/:id/archive', adminAuth, async (req, res) => {
try {
@@ -374,14 +436,8 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
return res.status(400).json({ error: 'Event is already archived' });
}
await db('events')
.where('id', id)
.update({
is_archived: true,
is_active: false,
archived_at: new Date(),
updated_at: new Date()
});
// Use the archive service to create ZIP archive
await archiveEvent(event);
// Log activity
await logActivity('event_archived',
@@ -397,4 +453,83 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
}
});
// Bulk archive events
router.post('/bulk-archive', adminAuth, [
body('eventIds').isArray().withMessage('eventIds must be an array'),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { eventIds } = req.body;
if (eventIds.length === 0) {
return res.status(400).json({ error: 'No events selected for archiving' });
}
// Get all events to archive
const events = await db('events')
.whereIn('id', eventIds)
.where('is_archived', false);
if (events.length === 0) {
return res.status(400).json({ error: 'No valid events found to archive' });
}
const results = {
successful: [],
failed: []
};
// Process each event
for (const event of events) {
try {
// Use the archive service to create ZIP archive
await archiveEvent(event);
// Log activity
await logActivity('event_archived',
{ eventName: event.event_name, bulkOperation: true },
event.id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
results.successful.push({
id: event.id,
name: event.event_name
});
} catch (error) {
console.error(`Failed to archive event ${event.id}:`, error);
results.failed.push({
id: event.id,
name: event.event_name,
error: error.message
});
}
}
// Log bulk archive activity
await logActivity('bulk_archive_completed',
{
totalEvents: eventIds.length,
successfulCount: results.successful.length,
failedCount: results.failed.length
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
results
});
} catch (error) {
console.error('Error in bulk archive:', error);
res.status(500).json({ error: 'Failed to perform bulk archive' });
}
});
module.exports = router;
+109
View File
@@ -0,0 +1,109 @@
const express = require('express');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get notifications (unread activity logs)
router.get('/', adminAuth, async (req, res) => {
try {
const { limit = 20, includeRead = false } = req.query;
let query = db('activity_logs')
.select(
'activity_logs.*',
'events.event_name'
)
.leftJoin('events', 'activity_logs.event_id', 'events.id')
.orderBy('activity_logs.created_at', 'desc')
.limit(parseInt(limit));
// By default, only show unread notifications
if (includeRead !== 'true') {
query = query.whereNull('activity_logs.read_at');
}
const notifications = await query;
// Format notifications
const formattedNotifications = notifications.map(notification => ({
id: notification.id,
type: notification.activity_type,
actorType: notification.actor_type,
actorName: notification.actor_name,
eventName: notification.event_name,
eventId: notification.event_id,
metadata: notification.metadata ? JSON.parse(notification.metadata) : {},
createdAt: notification.created_at,
readAt: notification.read_at,
isRead: !!notification.read_at
}));
// Get unread count
const unreadCount = await db('activity_logs')
.whereNull('read_at')
.count('id as count')
.first();
res.json({
notifications: formattedNotifications,
unreadCount: unreadCount.count || 0
});
} catch (error) {
console.error('Notifications fetch error:', error);
res.status(500).json({ error: 'Failed to fetch notifications' });
}
});
// Mark notification as read
router.put('/:id/read', adminAuth, async (req, res) => {
try {
const { id } = req.params;
await db('activity_logs')
.where('id', id)
.update({
read_at: new Date()
});
res.json({ message: 'Notification marked as read' });
} catch (error) {
console.error('Mark notification read error:', error);
res.status(500).json({ error: 'Failed to mark notification as read' });
}
});
// Mark all notifications as read
router.put('/read-all', adminAuth, async (req, res) => {
try {
await db('activity_logs')
.whereNull('read_at')
.update({
read_at: new Date()
});
res.json({ message: 'All notifications marked as read' });
} catch (error) {
console.error('Mark all notifications read error:', error);
res.status(500).json({ error: 'Failed to mark all notifications as read' });
}
});
// Delete old notifications (older than 30 days and read)
router.delete('/clear-old', adminAuth, async (req, res) => {
try {
const deletedCount = await db('activity_logs')
.whereNotNull('read_at')
.where('created_at', '<', db.raw("datetime('now', '-30 days')"))
.delete();
res.json({
message: 'Old notifications cleared',
deletedCount
});
} catch (error) {
console.error('Clear old notifications error:', error);
res.status(500).json({ error: 'Failed to clear old notifications' });
}
});
module.exports = router;
+299 -16
View File
@@ -14,12 +14,14 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
console.log('Multer destination called for file:', file.originalname);
const { eventId } = req.params;
try {
// Get event details
const event = await db('events').where({ id: eventId }).first();
if (!event) {
console.error('Event not found in multer destination:', eventId);
return cb(new Error('Event not found'));
}
@@ -28,21 +30,26 @@ const storage = multer.diskStorage({
// Create destination path - now just event folder, no type subfolder
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
console.log('Destination path:', destPath);
// Ensure directory exists
await fs.mkdir(destPath, { recursive: true });
cb(null, destPath);
} catch (error) {
console.error('Error in multer destination:', error);
cb(error);
}
},
filename: async (req, file, cb) => {
console.log('Multer filename called for file:', file.originalname);
try {
// Use temporary filename for now, will rename after getting category info
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
console.log('Temp filename:', tempName);
cb(null, tempName);
} catch (error) {
console.error('Error in multer filename:', error);
cb(error);
}
}
@@ -68,31 +75,51 @@ const upload = multer({
});
// Upload photos for an event
router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (req, res) => {
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
upload.array('photos', 20)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
return res.status(400).json({ error: err.message || 'Upload failed' });
}
next();
});
}, async (req, res) => {
try {
const { eventId } = req.params;
const { category_id } = req.body;
console.log('Upload request received:');
console.log('Upload request received for event:', eventId);
console.log('Body:', req.body);
console.log('Files:', req.files ? req.files.length : 'none');
console.log('Headers:', req.headers);
console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
console.log('Category ID received:', category_id);
// Verify event exists and admin has access
const event = await db('events').where({ id: eventId }).first();
if (!event) {
console.error('Event not found:', eventId);
return res.status(404).json({ error: 'Event not found' });
}
if (!req.files || req.files.length === 0) {
console.log('No files in request. req.files:', req.files);
console.error('No files in request. req.files:', req.files);
console.error('Request body keys:', Object.keys(req.body));
return res.status(400).json({ error: 'No files uploaded' });
}
// Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
// Get category details if provided
let category = null;
if (category_id) {
category = await db('photo_categories').where({ id: category_id }).first();
if (parsedCategoryId) {
category = await db('photo_categories').where({ id: parsedCategoryId }).first();
if (!category) {
return res.status(400).json({ error: 'Invalid category' });
}
@@ -112,7 +139,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
if (category) {
// Lock the category row and get current counter
const categoryData = await trx('photo_categories')
.where({ id: category_id })
.where({ id: parsedCategoryId })
.forUpdate()
.first();
@@ -120,7 +147,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
// Update counter
await trx('photo_categories')
.where({ id: category_id })
.where({ id: parsedCategoryId })
.update({ photo_counter: counter });
} else {
// For uncategorized photos, count existing uncategorized photos
@@ -165,7 +192,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: category_id || null,
category_id: parsedCategoryId || null,
type: 'individual', // Keep for backwards compatibility
size_bytes: file.size
});
@@ -177,7 +204,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
id: photoId,
filename: file.filename,
size: file.size,
category_id: category_id || null
category_id: parsedCategoryId || null
});
} catch (error) {
console.error(`Error processing file ${file.filename}:`, error);
@@ -255,11 +282,171 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
}
});
// Update a photo (e.g., change category)
router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Update photo
await db('photos')
.where({ id: photoId })
.update({ category_id: category_id || null });
res.json({ message: 'Photo updated successfully' });
} catch (error) {
console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' });
}
});
// Bulk delete photos
router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
// Get all photos to delete
const photos = await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId);
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found' });
}
// Delete physical files
const storagePath = getStoragePath();
const event = await db('events').where({ id: eventId }).first();
for (const photo of photos) {
// Delete photo file
const photoPath = path.join(storagePath, 'events/active', photo.path);
try {
await fs.unlink(photoPath);
} catch (error) {
console.error('Error deleting photo file:', error);
}
// Delete thumbnail
if (photo.thumbnail_path) {
const thumbPath = path.join(storagePath, photo.thumbnail_path);
try {
await fs.unlink(thumbPath);
} catch (error) {
console.error('Error deleting thumbnail:', error);
}
}
}
// Delete from database
await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.delete();
// Log activity
await logActivity('photos_bulk_deleted',
{ count: photos.length, eventName: event.event_name },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: `${photos.length} photos deleted successfully` });
} catch (error) {
console.error('Error bulk deleting photos:', error);
res.status(500).json({ error: 'Failed to delete photos' });
}
});
// Bulk update photos
router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds, updates } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
// Verify all photos belong to the event
const photoCount = await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.count('id as count')
.first();
if (photoCount.count !== photoIds.length) {
return res.status(400).json({ error: 'Some photos do not belong to this event' });
}
// Update photos
const updateData = {};
if (updates.category_id !== undefined) {
updateData.category_id = updates.category_id || null;
}
await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.update(updateData);
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
console.error('Error bulk updating photos:', error);
res.status(500).json({ error: 'Failed to update photos' });
}
});
// Download a photo
router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, 'events/active', photo.path);
// Check if file exists
try {
await fs.access(filePath);
} catch (error) {
return res.status(404).json({ error: 'Photo file not found' });
}
// Send file
res.download(filePath, photo.filename);
} catch (error) {
console.error('Error downloading photo:', error);
res.status(500).json({ error: 'Failed to download photo' });
}
});
// Get all photos for an event
router.get('/:eventId/photos', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type } = req.query;
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
let query = db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
@@ -270,8 +457,13 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
'photo_categories.slug as category_slug'
);
if (category_id) {
query = query.where({ 'photos.category_id': category_id });
// Filter by category (including uncategorized)
if (category_id !== undefined) {
if (category_id === '' || category_id === '0') {
query = query.whereNull('photos.category_id');
} else {
query = query.where({ 'photos.category_id': category_id });
}
}
// Keep type filter for backwards compatibility
@@ -279,14 +471,27 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
query = query.where({ 'photos.type': type });
}
const photos = await query.orderBy('photos.uploaded_at', 'desc');
// Search by filename
if (search) {
query = query.where('photos.filename', 'like', `%${search}%`);
}
// Sorting
let orderByColumn = 'photos.uploaded_at';
if (sort === 'name') {
orderByColumn = 'photos.filename';
} else if (sort === 'size') {
orderByColumn = 'photos.size_bytes';
}
const photos = await query.orderBy(orderByColumn, order);
res.json({
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${photo.thumbnail_path}` : null,
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -301,4 +506,82 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
}
});
// Serve photo with admin authentication
router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, 'events/active', photo.path);
// Check if file exists
try {
await fs.access(filePath);
} catch (error) {
return res.status(404).json({ error: 'Photo file not found' });
}
// Set appropriate headers
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Send file (sendFile requires absolute path)
res.sendFile(path.resolve(filePath));
} catch (error) {
console.error('Error serving photo:', error);
res.status(500).json({ error: 'Failed to serve photo' });
}
});
// Serve thumbnail with admin authentication
router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo || !photo.thumbnail_path) {
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
return res.status(404).json({ error: 'Thumbnail not found' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, photo.thumbnail_path);
console.log(`Attempting to serve thumbnail: ${filePath}`);
// Check if file exists
try {
await fs.access(filePath);
} catch (error) {
console.error(`Thumbnail file not found: ${filePath}`, error);
return res.status(404).json({ error: 'Thumbnail file not found' });
}
// Set appropriate headers
res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Send file (sendFile requires absolute path)
res.sendFile(path.resolve(filePath));
} catch (error) {
console.error('Error serving thumbnail:', error);
console.error('Photo ID:', req.params.photoId);
console.error('Event ID:', req.params.eventId);
res.status(500).json({ error: 'Failed to serve thumbnail' });
}
});
module.exports = router;
+6
View File
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const router = express.Router();
// Configure multer for logo uploads
@@ -357,6 +358,11 @@ router.put('/general', adminAuth, async (req, res) => {
updated_at: new Date()
});
}
// Clear maintenance mode cache if it was updated
if ('general_maintenance_mode' in settings) {
clearMaintenanceCache();
}
// Log activity
await db('activity_logs').insert({
+16 -3
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { verifyRecaptcha } = require('../services/recaptcha');
const router = express.Router();
// Admin login
@@ -16,7 +17,13 @@ router.post('/admin/login', [
return res.status(400).json({ errors: errors.array() });
}
const { username, password } = req.body;
const { username, password, recaptchaToken } = req.body;
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const admin = await db('admin_users')
.where({ username })
@@ -60,9 +67,15 @@ router.post('/gallery/verify', [
return res.status(400).json({ errors: errors.array() });
}
const { slug, password } = req.body;
const { slug, password, recaptchaToken } = req.body;
const event = await db('events').where({ slug, is_active: true }).first();
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
+13 -6
View File
@@ -18,7 +18,9 @@ async function verifyGalleryAccess(req, res, next) {
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first();
const event = await db('events')
.where({ id: decoded.eventId, is_active: true, is_archived: false })
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
@@ -38,7 +40,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: true })
.where({ slug, is_active: true, is_archived: false })
.select('id', 'share_link')
.first();
@@ -67,13 +69,18 @@ router.get('/:slug/info', async (req, res) => {
const event = await db('events')
.where({ slug })
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'share_link')
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link')
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// Check if event is archived
if (event.is_archived) {
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
}
// If token provided, verify it matches the share link
if (token) {
const expectedToken = event.share_link.split('/').pop();
@@ -147,7 +154,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -187,7 +194,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
photo_id: photoId
});
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
@@ -236,7 +243,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
// Add photos to archive
for (const photo of photos) {
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark
+7 -3
View File
@@ -5,9 +5,9 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding, theme, and general settings
// Fetch branding, theme, general, and select security settings
const settings = await db('app_settings')
.whereIn('setting_type', ['branding', 'theme', 'general'])
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
.select('setting_key', 'setting_value');
// Convert to object format
@@ -37,7 +37,11 @@ router.get('/', async (req, res) => {
branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_url || '',
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en'
default_language: settingsObject.general_default_language || 'en',
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'
};
res.json(publicSettings);
+58
View File
@@ -0,0 +1,58 @@
const axios = require('axios');
const { db } = require('../database/db');
async function verifyRecaptcha(token) {
// Check if reCAPTCHA is enabled
const settings = await db('app_settings')
.whereIn('setting_key', ['security_enable_recaptcha', 'security_recaptcha_secret_key'])
.select('setting_key', 'setting_value');
const settingsMap = {};
settings.forEach(setting => {
try {
settingsMap[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
settingsMap[setting.setting_key] = setting.setting_value;
}
});
const isEnabled = settingsMap.security_enable_recaptcha === true ||
settingsMap.security_enable_recaptcha === 'true';
const secretKey = settingsMap.security_recaptcha_secret_key;
// If reCAPTCHA is not enabled, always return true
if (!isEnabled) {
return true;
}
// If enabled but no token provided, fail
if (!token) {
return false;
}
// If no secret key configured, log warning but pass
if (!secretKey) {
console.warn('reCAPTCHA enabled but no secret key configured');
return true;
}
try {
const response = await axios.post(
'https://www.google.com/recaptcha/api/siteverify',
null,
{
params: {
secret: secretKey,
response: token
}
}
);
return response.data.success === true;
} catch (error) {
console.error('reCAPTCHA verification error:', error);
return false;
}
}
module.exports = { verifyRecaptcha };
+39
View File
@@ -0,0 +1,39 @@
/**
* Generate a secure random password
* @param {number} length - Password length (default 12)
* @returns {string} Generated password
*/
function generatePassword(length = 12) {
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const numbers = '0123456789';
const symbols = '!@#$%&*';
// Ensure at least one character from each set
const requiredChars = [
lowercase[Math.floor(Math.random() * lowercase.length)],
uppercase[Math.floor(Math.random() * uppercase.length)],
numbers[Math.floor(Math.random() * numbers.length)],
symbols[Math.floor(Math.random() * symbols.length)]
];
// Fill the rest with random characters from all sets
const allChars = lowercase + uppercase + numbers + symbols;
const remainingLength = length - requiredChars.length;
let password = '';
for (let i = 0; i < remainingLength; i++) {
password += allChars[Math.floor(Math.random() * allChars.length)];
}
// Combine and shuffle
const passwordArray = [...requiredChars, ...password];
for (let i = passwordArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[passwordArray[i], passwordArray[j]] = [passwordArray[j], passwordArray[i]];
}
return passwordArray.join('');
}
module.exports = { generatePassword };
+4 -5
View File
@@ -34,15 +34,14 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
target: builder
ports:
- "3005:5173"
- "3005:80"
environment:
- REACT_APP_API_URL=http://localhost:3001
volumes:
- ./frontend:/app
- /app/node_modules
command: npm run dev
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
mailhog:
image: mailhog/mailhog:latest
+2 -4
View File
@@ -31,10 +31,8 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Create non-root user
RUN addgroup -g 101 -S nginx && \
adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx && \
chown -R nginx:nginx /usr/share/nginx/html && \
# Set permissions (nginx user already exists in nginx:alpine)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
+14
View File
@@ -65,6 +65,20 @@ server {
proxy_cache_valid 404 1m;
}
# Thumbnail serving proxy
location /thumbnails {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache thumbnails
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
+47 -4
View File
@@ -12,6 +12,7 @@
"@tiptap/extension-link": "^2.25.0",
"@tiptap/react": "^2.25.0",
"@tiptap/starter-kit": "^2.25.0",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.3.2",
"clsx": "^2.0.0",
"date-fns": "^2.29.3",
@@ -23,6 +24,7 @@
"react": "^18.3.1",
"react-countdown": "^2.3.5",
"react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-i18next": "^15.6.0",
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
@@ -1962,14 +1964,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1986,6 +1986,15 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-google-recaptcha": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"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",
@@ -2739,7 +2748,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
@@ -3542,6 +3550,15 @@
"node": ">= 0.4"
}
},
"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",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -4732,6 +4749,19 @@
"node": ">=0.10.0"
}
},
"node_modules/react-async-script": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0",
"prop-types": "^15.5.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-countdown": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/react-countdown/-/react-countdown-2.3.6.tgz",
@@ -4758,6 +4788,19 @@
"react": "^18.3.1"
}
},
"node_modules/react-google-recaptcha": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.5.0",
"react-async-script": "^1.2.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-i18next": {
"version": "15.6.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz",
@@ -5425,7 +5468,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+2
View File
@@ -14,6 +14,7 @@
"@tiptap/extension-link": "^2.25.0",
"@tiptap/react": "^2.25.0",
"@tiptap/starter-kit": "^2.25.0",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.3.2",
"clsx": "^2.0.0",
"date-fns": "^2.29.3",
@@ -25,6 +26,7 @@
"react": "^18.3.1",
"react-countdown": "^2.3.5",
"react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-i18next": "^15.6.0",
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
+95 -65
View File
@@ -5,7 +5,7 @@ import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider } from './contexts';
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
@@ -25,6 +25,8 @@ import {
} from './pages/admin';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
// Create a client
const queryClient = new QueryClient({
@@ -37,82 +39,110 @@ const queryClient = new QueryClient({
});
function App() {
// Initialize Umami Analytics
// Initialize Umami Analytics based on settings
useEffect(() => {
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
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(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
const settings = await response.json();
// Only initialize if analytics is enabled in settings
if (settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Initialize analytics anyway if settings fetch fails
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
}
};
if (umamiUrl && umamiWebsiteId) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
initializeAnalytics();
}, []);
return (
<PageErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<DynamicFavicon />
<Router>
<SkipLink />
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
</GalleryAuthProvider>
} />
<MaintenanceProvider>
<ThemeProvider>
<GlobalThemeProvider>
<DynamicFavicon />
<Router>
<MaintenanceWrapper>
<SkipLink />
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
</GalleryAuthProvider>
} />
{/* Admin routes - wrap with AdminAuthProvider */}
<Route path="/admin" element={<AdminAuthWrapper />}>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="events" element={<EventsListPage />} />
<Route path="events/new" element={<CreateEventPage />} />
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="email" element={<EmailConfigPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Admin routes - wrap with AdminAuthProvider */}
<Route path="/admin" element={<AdminAuthWrapper />}>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="events" element={<EventsListPage />} />
<Route path="events/new" element={<CreateEventPage />} />
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="email" element={<EmailConfigPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
<Route path="/:slug" element={<LegalPage />} />
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
<Route path="/:slug" element={<LegalPage />} />
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
</Routes>
</Router>
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
</Routes>
</MaintenanceWrapper>
</Router>
{/* Offline indicator */}
<OfflineIndicator />
{/* Offline indicator */}
<OfflineIndicator />
{/* Toast notifications */}
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</ThemeProvider>
{/* Toast notifications */}
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</GlobalThemeProvider>
</ThemeProvider>
</MaintenanceProvider>
</QueryClientProvider>
</PageErrorBoundary>
);
@@ -0,0 +1,33 @@
import React, { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTheme } from '../contexts/ThemeContext';
import { api } from '../config/api';
interface GlobalThemeProviderProps {
children: React.ReactNode;
}
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
const { setTheme } = useTheme();
const themeAppliedRef = useRef(false);
// Fetch public settings including theme config
const { data: settingsData } = useQuery({
queryKey: ['global-theme-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Apply global theme when settings are loaded
useEffect(() => {
if (!themeAppliedRef.current && settingsData?.theme_config) {
themeAppliedRef.current = true;
setTheme(settingsData.theme_config);
}
}, [settingsData, setTheme]);
return <>{children}</>;
};
+113
View File
@@ -0,0 +1,113 @@
import React, { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../config/api';
interface BrandingSettings {
branding_company_name?: string;
branding_company_tagline?: string;
branding_support_email?: string;
branding_footer_text?: string;
branding_favicon_url?: string;
branding_logo_url?: string;
default_language?: string;
}
export const MaintenanceMode: React.FC = () => {
const { t, i18n } = useTranslation();
// Fetch branding settings
const { data: settings } = useQuery<BrandingSettings>({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await api.get('/api/public/settings');
return response.data;
} catch (error) {
// Return empty object if settings can't be fetched
return {};
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false, // Don't retry on failure
});
// Set language based on system settings
useEffect(() => {
if (settings?.default_language && settings.default_language !== i18n.language) {
i18n.changeLanguage(settings.default_language);
}
}, [settings?.default_language, i18n]);
return (
<div className="min-h-screen bg-neutral-50 flex flex-col">
{/* Header with branding */}
{(settings?.branding_logo_url || settings?.branding_company_name) && (
<div className="bg-white border-b border-neutral-200 py-4">
<div className="container">
<div className="flex items-center justify-center">
{settings.branding_logo_url ? (
<img
src={settings.branding_logo_url.startsWith('http')
? settings.branding_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`
}
alt={settings.branding_company_name || 'Company Logo'}
className="h-12 w-auto object-contain"
/>
) : (
<div className="text-center">
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
{settings.branding_company_tagline && (
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Main content */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md w-full text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-amber-100 rounded-full mb-6">
<AlertTriangle className="w-10 h-10 text-amber-600" />
</div>
<h1 className="text-3xl font-bold text-neutral-900 mb-4">
{t('maintenance.title')}
</h1>
<p className="text-lg text-neutral-600 mb-8">
{t('maintenance.message')}
</p>
{settings?.branding_support_email && (
<p className="text-sm text-neutral-500 mt-8">
{t('maintenance.urgentMatters')}{' '}
<a
href={`mailto:${settings.branding_support_email}`}
className="text-primary-600 hover:text-primary-700"
>
{settings.branding_support_email}
</a>
</p>
)}
</div>
</div>
{/* Footer */}
{settings?.branding_footer_text && (
<footer className="py-4 border-t border-neutral-200">
<div className="container text-center">
<p className="text-sm text-neutral-500">
{settings.branding_footer_text}
</p>
</div>
</footer>
)}
</div>
);
};
@@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api';
interface MaintenanceWrapperProps {
children: React.ReactNode;
}
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
// Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin');
const hasAdminAuth = !!getAuthToken(true);
// Register the maintenance mode callback
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
// Check maintenance mode on mount and when location changes
useQuery({
queryKey: ['maintenance-check', location.pathname],
queryFn: async () => {
try {
// Make a lightweight request to check maintenance status
await api.get('/api/public/settings');
// If successful, maintenance mode is off
setMaintenanceMode(false);
return { maintenance: false };
} catch (error: any) {
if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
setMaintenanceMode(true);
return { maintenance: true };
}
}
return { maintenance: false };
}
},
staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance
});
// Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) {
return <MaintenanceMode />;
}
return <>{children}</>;
};
@@ -0,0 +1,93 @@
import React, { useState, useEffect } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = ({
src,
fallback,
alt,
...props
}) => {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
const loadImage = async () => {
try {
setLoading(true);
setError(false);
// Make authenticated request to get the image
const response = await api.get(src, {
responseType: 'blob',
});
if (!cancelled) {
// Create object URL from blob
const imageUrl = URL.createObjectURL(response.data);
setImageSrc(imageUrl);
setLoading(false);
}
} catch (err: any) {
console.error('Failed to load image:', src, err);
// Log more details about the error
if (err.response) {
console.error('Response status:', err.response.status);
console.error('Response headers:', err.response.headers);
if (err.response.data instanceof Blob) {
// Try to read error message from blob
try {
const text = await err.response.data.text();
console.error('Response data:', text);
} catch (e) {
console.error('Could not read blob data');
}
} else {
console.error('Response data:', err.response.data);
}
}
if (!cancelled) {
setError(true);
setLoading(false);
}
}
};
if (src) {
loadImage();
}
// Cleanup function
return () => {
cancelled = true;
if (imageSrc) {
URL.revokeObjectURL(imageSrc);
}
};
}, [src]);
if (loading) {
return (
<div className="w-full h-full bg-neutral-200 animate-pulse" />
);
}
if (error) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return <img src={imageSrc || ''} alt={alt} {...props} />;
};
+94 -33
View File
@@ -1,13 +1,16 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react';
import { format } from 'date-fns';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -20,6 +23,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -32,21 +36,33 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
navigate('/admin/login');
};
// Mock notifications
const notifications = [
{
id: 1,
type: 'warning',
message: '3 events expiring in the next 7 days',
time: new Date(),
// Fetch notifications
const { data: notificationsData } = useQuery({
queryKey: ['notifications', showNotifications],
queryFn: () => notificationsService.getNotifications(showNotifications, 20),
refetchInterval: 60000, // Refetch every minute
});
// Mark all as read mutation
const markAllAsReadMutation = useMutation({
mutationFn: notificationsService.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success('All notifications marked as read');
},
{
id: 2,
type: 'success',
message: 'Wedding Smith-Jones archived successfully',
time: new Date(Date.now() - 3600000),
});
// Clear old notifications mutation
const clearOldMutation = useMutation({
mutationFn: notificationsService.clearOldNotifications,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(`Cleared ${data.deletedCount} old notifications`);
},
];
});
const notifications = notificationsData?.notifications || [];
const unreadCount = notificationsData?.unreadCount || 0;
return (
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
@@ -79,35 +95,80 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{notifications.length > 0 && (
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
{/* Notifications dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-neutral-200 py-2">
<div className="px-4 py-2 border-b border-neutral-100">
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-neutral-200">
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
title="Mark all as read"
>
<CheckCircle className="w-3 h-3" />
Mark all read
</button>
)}
<button
onClick={() => clearOldMutation.mutate()}
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
title="Clear old notifications"
>
<Trash2 className="w-3 h-3" />
Clear old
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
<div
key={notification.id}
className="px-4 py-3 hover:bg-neutral-50 cursor-pointer"
>
<p className="text-sm text-neutral-900">{notification.message}</p>
<p className="text-xs text-neutral-500 mt-1">
{format(notification.time, 'h:mm a')}
</p>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-neutral-500">
No notifications
</div>
))}
</div>
<div className="px-4 py-2 border-t border-neutral-100">
<button className="text-sm text-primary-600 hover:text-primary-700">
{t('admin.viewAllNotifications')}
</button>
) : (
notifications.map((notification) => {
const style = notificationsService.getNotificationStyle(notification.type);
return (
<div
key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${style.color}`}>
<Bell className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900">
{notificationsService.formatNotificationMessage(notification)}
</p>
<p className="text-xs text-neutral-500 mt-1">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</div>
);
})
)}
</div>
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 text-center">
<button
onClick={() => setShowNotifications(false)}
className="text-sm text-primary-600 hover:text-primary-700"
>
Close
</button>
</div>
)}
</div>
)}
</div>
@@ -2,12 +2,17 @@ import React, { useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAdminAuth } from '../../contexts';
import { useSessionTimeout } from '../../hooks/useSessionTimeout';
import { AdminSidebar } from './AdminSidebar';
import { AdminHeader } from './AdminHeader';
import { MaintenanceBanner } from './MaintenanceBanner';
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading } = useAdminAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
// Handle session timeout
useSessionTimeout();
if (isLoading) {
return (
@@ -41,6 +46,9 @@ export const AdminLayout: React.FC = () => {
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* Page content */}
<main id="main-content" className="flex-1 px-4 sm:px-6 lg:px-8 py-8">
@@ -0,0 +1,251 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package } from 'lucide-react';
import { toast } from 'react-toastify';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
interface AdminPhotoGridProps {
photos: AdminPhoto[];
eventId: number;
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
photos,
eventId,
onPhotoClick,
onPhotosDeleted
}) => {
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingPhotoId, setDeletingPhotoId] = useState<number | null>(null);
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleSelectAll = () => {
if (selectedPhotos.size === photos.length) {
setSelectedPhotos(new Set());
} else {
setSelectedPhotos(new Set(photos.map(p => p.id)));
}
};
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete "${photo.filename}"?`)) {
return;
}
setDeletingPhotoId(photo.id);
try {
await photosService.deletePhoto(eventId, photo.id);
toast.success('Photo deleted successfully');
onPhotosDeleted();
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setDeletingPhotoId(null);
}
};
const handleDeleteSelected = async () => {
if (selectedPhotos.size === 0) return;
const count = selectedPhotos.size;
if (!confirm(`Are you sure you want to delete ${count} photo${count > 1 ? 's' : ''}?`)) {
return;
}
setIsDeleting(true);
try {
await photosService.deletePhotos(eventId, Array.from(selectedPhotos));
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onPhotosDeleted();
} catch (error) {
toast.error('Failed to delete photos');
} finally {
setIsDeleting(false);
}
};
const handleDownload = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
try {
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download photo');
}
};
const toggleSelectionMode = () => {
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedPhotos(new Set());
}
};
return (
<div>
{/* Action Bar */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant={isSelectionMode ? "primary" : "outline"}
size="sm"
onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button>
{isSelectionMode && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAll}
>
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
</Button>
{selectedPhotos.size > 0 && (
<>
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
</span>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete Selected
</button>
</>
)}
</>
)}
</div>
<div className="text-sm text-neutral-600">
{photos.length} photo{photos.length !== 1 ? 's' : ''}
</div>
</div>
{/* Photo Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo, index) => (
<div
key={photo.id}
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 ${
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
}`}
onClick={() => isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)}
>
{/* Selection Checkbox */}
{isSelectionMode && (
<div className="absolute top-2 left-2 z-10">
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id)
? 'bg-primary-500 border-primary-500'
: 'bg-white/80 border-neutral-300'
}`}>
{selectedPhotos.has(photo.id) && (
<Check className="w-4 h-4 text-white" />
)}
</div>
</div>
)}
{/* Thumbnail */}
<div className="aspect-square">
{photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
fallback={
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
)}
</div>
{/* Overlay with actions */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="text-white text-xs font-medium truncate mb-1">
{photo.filename}
</p>
<p className="text-white/80 text-xs mb-2">
{photosService.formatBytes(photo.size)}
</p>
{!isSelectionMode && (
<div className="flex gap-1">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
>
<Download className="w-3 h-3" />
</button>
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
disabled={deletingPhotoId === photo.id}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
{/* Category Badge */}
{photo.category_name && (
<div className="absolute top-2 right-2">
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded">
{photo.category_name}
</span>
</div>
)}
</div>
))}
</div>
{photos.length === 0 && (
<div className="text-center py-12">
<p className="text-neutral-500">No photos uploaded yet</p>
</div>
)}
</div>
);
};
@@ -0,0 +1,269 @@
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
interface AdminPhotoViewerProps {
photos: AdminPhoto[];
initialIndex: number;
eventId: number;
onClose: () => void;
onPhotoDeleted: () => void;
categories: Array<{ id: number; name: string; slug: string }>;
}
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
photos,
initialIndex,
eventId,
onClose,
onPhotoDeleted,
categories
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false);
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
const currentPhoto = photos[currentIndex];
const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
};
const handleDelete = async () => {
if (!confirm(`Are you sure you want to delete "${currentPhoto.filename}"?`)) {
return;
}
setIsDeleting(true);
try {
await photosService.deletePhoto(eventId, currentPhoto.id);
toast.success('Photo deleted successfully');
// Close viewer if this was the last photo
if (photos.length === 1) {
onClose();
} else {
// Move to next photo if available, otherwise previous
if (currentIndex === photos.length - 1) {
setCurrentIndex(currentIndex - 1);
}
}
onPhotoDeleted();
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setIsDeleting(false);
}
};
const handleDownload = async () => {
try {
await photosService.downloadPhoto(eventId, currentPhoto.id, currentPhoto.filename);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download photo');
}
};
const handleCategoryChange = async (categoryId: number | null) => {
try {
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
toast.success('Category updated');
setShowCategoryMenu(false);
// Trigger refresh to update the photo data
onPhotoDeleted(); // This will refresh the photos list
} catch (error) {
toast.error('Failed to update category');
}
};
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
goToPrevious();
break;
case 'ArrowRight':
goToNext();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentIndex]);
return (
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<X className="w-6 h-6" />
</button>
{/* Navigation */}
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
{/* Main content */}
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
</div>
{/* Sidebar */}
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
<h3 className="text-white font-medium text-lg mb-4">{currentPhoto.filename}</h3>
{/* Actions */}
<div className="flex gap-2 mb-6">
<Button
variant="primary"
size="sm"
onClick={handleDownload}
leftIcon={<Download className="w-4 h-4" />}
className="flex-1"
>
Download
</Button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex-1 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
{/* Category */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-neutral-400 text-sm flex items-center gap-1">
<Tag className="w-4 h-4" />
Category
</span>
<button
onClick={() => setShowCategoryMenu(!showCategoryMenu)}
className="text-xs text-primary-400 hover:text-primary-300"
>
Change
</button>
</div>
<p className="text-white">
{currentPhoto.category_name || 'Uncategorized'}
</p>
{showCategoryMenu && (
<div className="mt-2 bg-neutral-800 rounded-lg p-2">
<button
onClick={() => handleCategoryChange(null)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
Uncategorized
</button>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => handleCategoryChange(cat.id)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
{cat.name}
</button>
))}
</div>
)}
</div>
{/* Metadata */}
<div className="space-y-4 text-sm">
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<HardDrive className="w-4 h-4" />
File Size
</span>
<p className="text-white">{photosService.formatBytes(currentPhoto.size)}</p>
</div>
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Calendar className="w-4 h-4" />
Uploaded
</span>
<p className="text-white">
{format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
{currentPhoto.view_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Eye className="w-4 h-4" />
Views
</span>
<p className="text-white">{currentPhoto.view_count}</p>
</div>
)}
{currentPhoto.download_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<MousePointer className="w-4 h-4" />
Downloads
</span>
<p className="text-white">{currentPhoto.download_count}</p>
</div>
)}
</div>
{/* Navigation info */}
<div className="mt-6 pt-6 border-t border-neutral-700">
<p className="text-neutral-400 text-sm text-center">
{currentIndex + 1} of {photos.length}
</p>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,92 @@
import React from 'react';
import { Archive, AlertTriangle, X } from 'lucide-react';
import { Button, Card } from '../common';
import type { Event } from '../../types';
interface BulkArchiveModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
selectedEvents: Event[];
isLoading?: boolean;
}
export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
isOpen,
onClose,
onConfirm,
selectedEvents,
isLoading = false,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Confirm Bulk Archive</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<div className="mb-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-neutral-700">
<p className="mb-2">
You are about to archive <strong>{selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}</strong>.
This action will:
</p>
<ul className="list-disc list-inside space-y-1 text-neutral-600">
<li>Create a ZIP archive of all photos for each event</li>
<li>Make the galleries inaccessible to guests</li>
<li>Remove the events from active listings</li>
<li>Free up storage space by compressing photos</li>
</ul>
</div>
</div>
<div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto">
<div className="p-3">
<h3 className="text-sm font-medium text-neutral-700 mb-2">Events to be archived:</h3>
<ul className="space-y-1">
{selectedEvents.map((event) => (
<li key={event.id} className="text-sm text-neutral-600">
{event.event_name} ({event.event_type})
</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
leftIcon={<Archive className="w-4 h-4" />}
>
Archive {selectedEvents.length} Event{selectedEvents.length > 1 ? 's' : ''}
</Button>
</div>
</div>
</Card>
</div>
);
};
BulkArchiveModal.displayName = 'BulkArchiveModal';
@@ -0,0 +1,99 @@
import React from 'react';
import { X, Mail, FileText } from 'lucide-react';
import { Button, Card } from '../common';
interface EmailPreviewModalProps {
isOpen: boolean;
onClose: () => void;
subject: string;
htmlContent: string;
textContent?: string;
}
export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
isOpen,
onClose,
subject,
htmlContent,
textContent
}) => {
const [viewMode, setViewMode] = React.useState<'html' | 'text'>('html');
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-primary-600" />
<h2 className="text-xl font-semibold text-neutral-900">Email Preview</h2>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Subject */}
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
<p className="text-sm font-medium text-neutral-600">Subject:</p>
<p className="text-lg font-semibold text-neutral-900 mt-1">{subject}</p>
</div>
{/* View mode toggle */}
<div className="px-6 py-3 border-b border-neutral-200">
<div className="flex gap-2">
<Button
variant={viewMode === 'html' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('html')}
leftIcon={<Mail className="w-4 h-4" />}
>
HTML View
</Button>
{textContent && (
<Button
variant={viewMode === 'text' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('text')}
leftIcon={<FileText className="w-4 h-4" />}
>
Text View
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{viewMode === 'html' ? (
<div className="bg-white border border-neutral-200 rounded-lg shadow-sm">
<iframe
srcDoc={htmlContent}
className="w-full h-[600px] border-0"
title="Email Preview"
/>
</div>
) : (
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-6">
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700">
{textContent}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
export const MaintenanceBanner: React.FC = () => {
const [dismissed, setDismissed] = React.useState(false);
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
refetchInterval: 60000 // Check every minute
});
const isMaintenanceMode = settings?.general_maintenance_mode === true ||
settings?.general_maintenance_mode === 'true';
if (!isMaintenanceMode || dismissed) {
return null;
}
return (
<div className="bg-amber-50 border-b border-amber-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<p className="text-sm font-medium text-amber-900">
Maintenance mode is currently enabled. Public access to galleries is restricted.
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="text-amber-600 hover:text-amber-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { X, Key, Copy, CheckCircle, Mail } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card } from '../common';
interface PasswordResetModalProps {
eventName: string;
onConfirm: (sendEmail: boolean) => Promise<{ newPassword: string; emailSent: boolean }>;
onClose: () => void;
}
export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
eventName,
onConfirm,
onClose
}) => {
const [isResetting, setIsResetting] = useState(false);
const [sendEmail, setSendEmail] = useState(true);
const [newPassword, setNewPassword] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleReset = async () => {
setIsResetting(true);
try {
const result = await onConfirm(sendEmail);
setNewPassword(result.newPassword);
toast.success('Password reset successfully');
} catch (error) {
toast.error('Failed to reset password');
onClose();
} finally {
setIsResetting(false);
}
};
const handleCopy = async () => {
if (newPassword) {
await navigator.clipboard.writeText(newPassword);
setCopied(true);
toast.success('Password copied to clipboard');
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{newPassword ? 'New Password' : 'Reset Gallery Password'}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600"
>
<X className="w-5 h-5" />
</button>
</div>
{!newPassword ? (
<>
<p className="text-neutral-600 mb-6">
Are you sure you want to reset the password for <strong>{eventName}</strong>?
This will generate a new password for gallery access.
</p>
<div className="mb-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">
Send email notification
</span>
</div>
<p className="text-xs text-neutral-500 mt-1">
Notify the host about the password change
</p>
</div>
</label>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
<p className="text-sm text-amber-800">
<strong>Note:</strong> The old password will no longer work.
Make sure to share the new password with the host.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isResetting}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleReset}
disabled={isResetting}
isLoading={isResetting}
leftIcon={<Key className="w-4 h-4" />}
className="flex-1"
>
Reset Password
</Button>
</div>
</>
) : (
<>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<p className="font-medium text-green-900">Password reset successfully!</p>
</div>
{sendEmail && (
<p className="text-sm text-green-700">
An email notification has been sent to the host.
</p>
)}
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-neutral-700 mb-2">
New Gallery Password
</label>
<div className="flex gap-2">
<input
type="text"
value={newPassword}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm"
/>
<Button
variant="outline"
onClick={handleCopy}
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
<p className="text-sm text-blue-800">
<strong>Important:</strong> Save this password securely. It cannot be recovered once you close this window.
</p>
</div>
<Button
variant="primary"
onClick={onClose}
className="w-full"
>
Done
</Button>
</>
)}
</Card>
</div>
);
};
@@ -0,0 +1,89 @@
import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common';
interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>;
selectedCategory: number | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size', order: 'asc' | 'desc') => void;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
categories,
selectedCategory,
searchTerm,
sortBy,
sortOrder,
onCategoryChange,
onSearchChange,
onSortChange
}) => {
const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
};
return (
<div className="bg-white border border-neutral-200 rounded-lg p-4 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<Input
type="text"
placeholder="Search by filename..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Categories</option>
<option value="0">Uncategorized</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{/* Sort Options */}
<div className="flex items-center gap-2">
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="size">Sort by Size</option>
</select>
<button
onClick={handleSortToggle}
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'}
>
{sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600" />
) : (
<SortDesc className="w-5 h-5 text-neutral-600" />
)}
</button>
</div>
</div>
</div>
);
};
@@ -61,9 +61,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
try {
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
// Don't set Content-Type header - axios will set it with the boundary
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
+9 -1
View File
@@ -7,4 +7,12 @@ export { AdminAuthWrapper } from './AdminAuthWrapper';
export { PhotoUpload } from './PhotoUpload';
export { CategoryManager } from './CategoryManager';
export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';
export { CMSEditor } from './CMSEditor';
export { BulkArchiveModal } from './BulkArchiveModal';
export { MaintenanceBanner } from './MaintenanceBanner';
export { EmailPreviewModal } from './EmailPreviewModal';
export { AdminPhotoGrid } from './AdminPhotoGrid';
export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
@@ -54,8 +54,12 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
}
}
console.log('Fetching authenticated image:', imageUrl);
const response = await fetch(imageUrl, {
// Prepend API URL for absolute paths
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const fullImageUrl = imageUrl.startsWith('/') ? `${apiUrl}${imageUrl}` : imageUrl;
console.log('Fetching authenticated image:', fullImageUrl);
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query';
interface ReCaptchaProps {
onChange: (token: string | null) => void;
onExpired?: () => void;
size?: 'normal' | 'compact';
}
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
onExpired,
size = 'normal'
}) => {
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
// Fetch public settings to get reCAPTCHA site key
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
return response.json();
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
useEffect(() => {
if (settings?.recaptcha_site_key) {
setSiteKey(settings.recaptcha_site_key);
}
}, [settings]);
// If reCAPTCHA is not enabled or site key is not available, return null
if (!settings?.enable_recaptcha || !siteKey) {
return null;
}
return (
<div className="flex justify-center">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={siteKey}
onChange={onChange}
onExpired={onExpired}
size={size}
theme="light"
/>
</div>
);
};
export default ReCaptcha;
+2 -1
View File
@@ -15,4 +15,5 @@ export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage';
export { AuthenticatedImage } from './AuthenticatedImage';
export { ReCaptcha } from './ReCaptcha';
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { Button, LanguageSelector } from '../common';
import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
interface GalleryLayoutProps {
@@ -56,7 +56,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{brandingSettings?.logo_url && (
<div className="pr-4 border-r border-neutral-200">
<img
src={brandingSettings.logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}`}
alt={brandingSettings.company_name || 'Company Logo'}
className="h-12 w-auto object-contain"
/>
@@ -94,7 +94,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<div className="flex items-center gap-2">
{headerExtra}
<LanguageSelector />
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
@@ -231,14 +231,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
{/* Welcome Message */}
{event.welcome_message && (
<div className="mt-6">
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<p className="text-primary-900">{event.welcome_message}</p>
</div>
</div>
)}
{/* Search and Filters */}
<div className="mt-6">
+20
View File
@@ -5,6 +5,13 @@ import Cookies from 'js-cookie';
export const ADMIN_TOKEN_KEY = 'admin_token';
export const GALLERY_TOKEN_KEY = 'gallery_token';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => {
maintenanceModeCallback = callback;
};
// Create axios instance
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
@@ -42,6 +49,19 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
(error) => {
// Handle maintenance mode (503)
if (error.response?.status === 503) {
const isAdminRoute = error.config?.url?.includes('/admin');
const hasAdminAuth = error.config?.headers?.Authorization?.startsWith('Bearer ');
// Only trigger maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
if (maintenanceModeCallback) {
maintenanceModeCallback(true);
}
}
}
if (error.response?.status === 401) {
// Clear tokens on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
+3 -3
View File
@@ -16,7 +16,7 @@ interface GalleryEvent {
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
login: (slug: string, password: string) => Promise<void>;
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
@@ -62,11 +62,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsLoading(false);
}, []);
const login = async (slug: string, password: string) => {
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
try {
setError(null);
setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event);
setIsAuthenticated(true);
@@ -0,0 +1,74 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api';
interface MaintenanceContextType {
isMaintenanceMode: boolean;
setMaintenanceMode: (enabled: boolean) => void;
}
const MaintenanceContext = createContext<MaintenanceContextType | undefined>(undefined);
export const useMaintenanceMode = () => {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenanceMode must be used within MaintenanceProvider');
}
return context;
};
interface MaintenanceProviderProps {
children: ReactNode;
}
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
// Check maintenance mode status on mount
const { data: settings } = useQuery({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
if (response.status === 503) {
setIsMaintenanceMode(true);
return null;
}
return response.json();
} catch (error) {
// If we can't reach the server, don't assume maintenance mode
return null;
}
},
staleTime: 30 * 1000, // Check every 30 seconds
refetchInterval: 30 * 1000,
});
// Update maintenance mode based on settings
useEffect(() => {
if (settings?.maintenance_mode !== undefined) {
setIsMaintenanceMode(settings.maintenance_mode);
}
}, [settings]);
// Set up the callback for API interceptor
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setIsMaintenanceMode(enabled);
});
return () => {
setMaintenanceModeCallback(null as any);
};
}, []);
const setMaintenanceMode = (enabled: boolean) => {
setIsMaintenanceMode(enabled);
};
return (
<MaintenanceContext.Provider value={{ isMaintenanceMode, setMaintenanceMode }}>
{children}
</MaintenanceContext.Provider>
);
};
+2 -1
View File
@@ -1,4 +1,5 @@
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
export { ThemeProvider, useTheme, PRESET_THEMES } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useCallback } from 'react';
import { useAdminAuth } from '../contexts';
import { api } from '../config/api';
// Hook to handle session timeout
export const useSessionTimeout = () => {
const { logout } = useAdminAuth();
const handleSessionTimeout = useCallback((error: any) => {
if (error?.response?.data?.code === 'SESSION_TIMEOUT') {
// Clear local auth state
logout();
// Redirect to login with message
window.location.href = '/admin/login?session=expired';
return true;
}
return false;
}, [logout]);
useEffect(() => {
// Add response interceptor to handle session timeout
const interceptor = api.interceptors.response.use(
response => response,
error => {
if (handleSessionTimeout(error)) {
// Don't propagate the error if it was a session timeout
return Promise.reject(new Error('Session expired'));
}
return Promise.reject(error);
}
);
// Clean up interceptor on unmount
return () => {
api.interceptors.response.eject(interceptor);
};
}, [handleSessionTimeout]);
return { handleSessionTimeout };
};
+262 -7
View File
@@ -22,7 +22,25 @@
"uploading": "Wird hochgeladen...",
"uploaded": "Hochgeladen",
"photo": "Foto",
"photos": "Fotos"
"photos": "Fotos",
"restore": "Wiederherstellen",
"actions": "Aktionen",
"refresh": "Aktualisieren",
"preview": "Vorschau",
"processing": "Wird verarbeitet...",
"upload": "Hochladen"
},
"upload": {
"photoCategory": "Fotokategorie",
"noCategory": "Keine Kategorie",
"eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)",
"selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...",
"uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden"
},
"navigation": {
"dashboard": "Dashboard",
@@ -34,6 +52,46 @@
"emailSettings": "E-Mail-Einstellungen",
"cmsPages": "CMS-Seiten"
},
"archives": {
"title": "Archive",
"subtitle": "Archivierte Fotogalerien verwalten",
"loadingArchives": "Archive werden geladen...",
"totalArchives": "Gesamtarchive",
"storageUsed": "Genutzter Speicher",
"totalPhotos": "Gesamtfotos",
"avgArchiveSize": "Durchschn. Archivgröße",
"searchPlaceholder": "Archive durchsuchen...",
"allTypes": "Alle Typen",
"wedding": "Hochzeit",
"birthday": "Geburtstag",
"corporate": "Geschäftlich",
"other": "Andere",
"sortByDate": "Nach Datum sortieren",
"sortByName": "Nach Name sortieren",
"sortBySize": "Nach Größe sortieren",
"tableHeaders": {
"event": "Veranstaltung",
"type": "Typ",
"archivedDate": "Archivierungsdatum",
"size": "Größe",
"photos": "Fotos",
"actions": "Aktionen"
},
"noArchivesFound": "Keine Archive gefunden",
"eventDateNA": "Veranstaltungsdatum: N/A",
"processing": "Wird verarbeitet...",
"download": "Herunterladen",
"restore": "Wiederherstellen",
"delete": "Löschen",
"showing": "Zeige {{from}} bis {{to}} von {{total}} Archiven",
"page": "Seite {{current}} von {{total}}",
"storageManagement": "Speicherverwaltung",
"storageInfo": "Archive werden dauerhaft gespeichert, es sei denn, sie werden manuell gelöscht. Erwägen Sie die Implementierung einer Aufbewahrungsrichtlinie zur Verwaltung der Speicherkosten.",
"confirmRestore": "Sind Sie sicher, dass Sie dieses Archiv wiederherstellen möchten? Die Veranstaltung wird wieder aktiv.",
"confirmDelete": "Sind Sie sicher, dass Sie dieses Archiv dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"restoreSuccess": "Archiv erfolgreich wiederhergestellt",
"deleteSuccess": "Archiv dauerhaft gelöscht"
},
"auth": {
"login": "Anmelden",
"password": "Passwort",
@@ -120,17 +178,78 @@
"uploadPhotos": "Fotos hochladen",
"archiveEvent": "Veranstaltung archivieren",
"archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"extendExpiration": "Um {{days}} Tage verlängern"
"extendExpiration": "Um {{days}} Tage verlängern",
"backToEvents": "Zurück zu Veranstaltungen",
"loadingEventDetails": "Veranstaltungsdetails werden geladen...",
"saveChanges": "Änderungen speichern",
"eventExpired": "Diese Veranstaltung ist abgelaufen",
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
"overview": "Übersicht",
"photos": "Fotos",
"categories": "Kategorien",
"eventInformation": "Veranstaltungsinformationen",
"welcomeMessage": "Willkommensnachricht",
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
"created": "Erstellt",
"expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
"photoStatistics": "Fotostatistiken",
"managePhotos": "Fotos verwalten",
"actions": "Aktionen",
"archivingInfo": "Beim Archivieren wird eine ZIP-Datei aller Fotos erstellt und die Galerie aus dem öffentlichen Zugriff entfernt.",
"statistics": "Statistiken",
"views": "Aufrufe",
"downloads": "Downloads",
"uniqueVisitors": "Eindeutige Besucher",
"noStatistics": "Noch keine Statistiken verfügbar",
"archiveStatus": "Archivstatus",
"archivedOn": "Archiviert am",
"downloadArchive": "Archiv herunterladen",
"loadingPhotos": "Fotos werden geladen...",
"photoCategories": "Fotokategorien",
"organizeCategoriesInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
"categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.",
"contactInformation": "Kontaktinformationen",
"hostEmailHelp": "Erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
"securityAccess": "Sicherheit & Zugriff",
"galleryPassword": "Galerie-Passwort",
"confirmPassword": "Passwort bestätigen",
"showPasswords": "Passwörter anzeigen",
"gallerySettings": "Galerie-Einstellungen",
"colorTheme": "Farbthema",
"galleryExpiresIn": "Galerie läuft ab in",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"processingRequest": "Ihre Anfrage wird verarbeitet...",
"eventTypeWedding": "Hochzeit",
"eventTypeBirthday": "Geburtstag",
"eventTypeCorporate": "Geschäftlich",
"eventTypeOther": "Andere",
"days30": "30 Tage",
"days60": "60 Tage",
"days90": "90 Tage",
"days365": "1 Jahr",
"createNewEvent": "Neue Veranstaltung erstellen",
"setupNewGallery": "Richten Sie eine neue Fotogalerie für Ihre Veranstaltung ein",
"adminNotificationEmail": "Admin-Benachrichtigungs-E-Mail"
},
"settings": {
"title": "Systemeinstellungen",
"subtitle": "Systemweite Einstellungen und Präferenzen konfigurieren",
"loadingSettings": "Einstellungen werden geladen...",
"general": {
"title": "Allgemein",
"siteConfiguration": "Website-Konfiguration",
"siteUrl": "Website-URL",
"siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet",
"defaultExpiration": "Standardablauf (Tage)",
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
"maxFileSize": "Max. Dateigröße (MB)",
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter",
@@ -139,7 +258,10 @@
"enableRegistration": "Selbstregistrierung für Admins erlauben",
"maintenanceMode": "Wartungsmodus aktivieren",
"language": "Sprache",
"saveSettings": "Allgemeine Einstellungen speichern"
"defaultLanguage": "Standardsprache",
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
"saveSettings": "Allgemeine Einstellungen speichern",
"saveGeneralSettings": "Allgemeine Einstellungen speichern"
},
"storage": {
"title": "Speicher",
@@ -150,23 +272,31 @@
"storageUsage": "Speichernutzung",
"storageByEvent": "Speicher nach Veranstaltung",
"storageManagement": "Speicherverwaltung",
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien."
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien.",
"noEventsUsingStorage": "Keine Veranstaltungen verwenden Speicher",
"unlimited": "Unbegrenzt"
},
"security": {
"title": "Sicherheit",
"passwordSettings": "Passworteinstellungen",
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge",
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
"sessionAuth": "Sitzung & Authentifizierung",
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
"maxLoginAttempts": "Max. Anmeldeversuche",
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
"recaptchaSettings": "reCAPTCHA-Einstellungen",
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
"siteKey": "Site-Schlüssel",
"siteKeyHelp": "Ihr reCAPTCHA v2 Site-Schlüssel (öffentlich)",
"secretKey": "Geheimer Schlüssel",
"secretKeyHelp": "Ihr reCAPTCHA v2 Geheimschlüssel (privat halten)",
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
"saveSettings": "Sicherheitseinstellungen speichern"
"saveSettings": "Sicherheitseinstellungen speichern",
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
},
"categories": {
"title": "Kategorien",
@@ -175,32 +305,63 @@
}
},
"branding": {
"title": "Branding & Anpassung",
"title": "Branding & Themen",
"titleFull": "Branding & Anpassung",
"subtitle": "Passen Sie das Aussehen Ihrer Galerien an",
"loadingBranding": "Branding-Einstellungen werden geladen...",
"companyInfo": "Unternehmensinformationen",
"companyName": "Unternehmensname",
"companyNameHelp": "Wird in Galerie-Headern und E-Mails angezeigt",
"companyTagline": "Unternehmens-Slogan",
"companyTaglineHelp": "Eine kurze Beschreibung Ihres Unternehmens",
"supportEmail": "Support-E-Mail",
"supportEmailHelp": "Kontakt-E-Mail für Gäste-Support",
"footerText": "Fußzeilentext",
"footerTextHelp": "Wird am unteren Rand der Galerien angezeigt",
"logo": "Logo",
"currentLogo": "Aktuelles Logo",
"uploadLogo": "Logo hochladen",
"removeLogo": "Logo entfernen",
"logoHelp": "Empfohlene Größe: 200x60px, PNG oder JPEG",
"favicon": "Favicon",
"currentFavicon": "Aktuelles Favicon",
"uploadFavicon": "Favicon hochladen",
"removeFavicon": "Favicon entfernen",
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
"watermark": "Wasserzeichen",
"watermarkSettings": "Wasserzeichen-Einstellungen",
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableWatermarks": "Wasserzeichen aktivieren",
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
"watermarkLogo": "Wasserzeichen-Logo",
"currentWatermark": "Aktuelles Wasserzeichen",
"uploadWatermarkLogo": "Wasserzeichen-Logo hochladen",
"watermarkPosition": "Wasserzeichen-Position",
"topLeft": "Oben Links",
"topRight": "Oben Rechts",
"center": "Mitte",
"bottomLeft": "Unten Links",
"bottomRight": "Unten Rechts",
"watermarkOpacity": "Wasserzeichen-Transparenz",
"watermarkSize": "Wasserzeichen-Größe",
"theme": "Theme",
"galleryTheme": "Galerie-Theme",
"themeCustomization": "Theme-Anpassung",
"selectPreset": "Vorgefertigtes Theme auswählen",
"colors": "Farben",
"primaryColor": "Primärfarbe",
"secondaryColor": "Sekundärfarbe",
"accentColor": "Akzentfarbe",
"backgroundColor": "Hintergrundfarbe",
"textColor": "Textfarbe",
"customCSS": "Benutzerdefiniertes CSS",
"preview": "Vorschau",
"previewInNewTab": "Vorschau in neuem Tab",
"reset": "Zurücksetzen",
"saveChanges": "Änderungen speichern"
"saveChanges": "Änderungen speichern",
"applyLivePreview": "Änderungen sofort anwenden (Live-Vorschau)",
"eventSpecificThemes": "Veranstaltungsspezifische Themen",
"eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben."
},
"admin": {
"title": "Admin-Panel",
@@ -214,6 +375,10 @@
"storagePercent": "{{percent}}% von {{limit}}",
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
"markAsRead": "Als gelesen markieren",
"markAllAsRead": "Alle als gelesen markieren",
"notificationSettings": "Benachrichtigungseinstellungen",
"changePassword": "Passwort ändern",
"loadingDashboard": "Dashboard wird geladen...",
"activeEvents": "Aktive Veranstaltungen",
@@ -238,6 +403,8 @@
"notFound": "Nicht gefunden",
"galleryNotFound": "Galerie nicht gefunden",
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
"galleryArchived": "Galerie archiviert",
"galleryArchivedMessage": "Diese Galerie wurde archiviert und ist nicht mehr zugänglich. Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
"unauthorized": "Nicht autorisiert",
"forbidden": "Verboten",
"serverError": "Serverfehler",
@@ -280,5 +447,93 @@
"pageUpdated": "Seite erfolgreich aktualisiert",
"archiveRestored": "Archiv erfolgreich wiederhergestellt",
"archiveDeleted": "Archiv dauerhaft gelöscht"
},
"analytics": {
"title": "Analytics Dashboard",
"titleSimple": "Analytik",
"subtitle": "Galerie-Performance und Besucherengagement verfolgen",
"detailedSubtitle": "Detaillierte Analysen mit Umami",
"loadingAnalytics": "Analytik wird geladen...",
"showSummaryView": "Zusammenfassungsansicht anzeigen",
"fullDashboard": "Vollständiges Dashboard",
"refresh": "Aktualisieren",
"last7Days": "Letzte 7 Tage",
"last30Days": "Letzte 30 Tage",
"last90Days": "Letzte 90 Tage",
"pageViews": "Seitenaufrufe",
"uniqueVisitors": "Eindeutige Besucher",
"totalDownloads": "Gesamte Downloads",
"topGallery": "Top-Galerie",
"topPages": "Top-Seiten",
"views": "Aufrufe",
"visitors": "eindeutige Besucher",
"topDownloadsByGallery": "Top-Downloads nach Galerie",
"deviceBreakdown": "Geräteaufschlüsselung",
"desktop": "Desktop",
"mobile": "Mobil",
"tablet": "Tablet",
"storageUsage": "Speichernutzung",
"used": "Verwendet",
"of": "von",
"totalPhotos": "Gesamte Fotos",
"activeEvents": "Aktive Veranstaltungen",
"notConfigured": "Umami Analytics nicht konfiguriert",
"configureInstructions": "Um echte Analysedaten zu sehen, konfigurieren Sie Umami in Ihren Umgebungsvariablen und Admin-Panel-Einstellungen.",
"noData": "Keine Daten verfügbar",
"percentChange": "{{percent}}% gegenüber letztem Zeitraum"
},
"email": {
"title": "E-Mail-Konfiguration",
"subtitle": "E-Mail-Einstellungen für Benachrichtigungen konfigurieren",
"loadingSettings": "E-Mail-Einstellungen werden geladen...",
"smtpConfiguration": "SMTP-Konfiguration",
"smtpHost": "SMTP-Host",
"smtpHostHelp": "Ihr E-Mail-Server-Hostname",
"smtpPort": "SMTP-Port",
"smtpPortHelp": "Normalerweise 587 für TLS, 465 für SSL, 25 für unverschlüsselt",
"smtpSecure": "SSL/TLS verwenden",
"smtpSecureHelp": "Für sichere E-Mail-Übertragung aktivieren",
"smtpUsername": "SMTP-Benutzername",
"smtpUsernameHelp": "Ihr E-Mail-Konto-Benutzername",
"smtpPassword": "SMTP-Passwort",
"smtpPasswordHelp": "Ihr E-Mail-Konto-Passwort",
"fromDetails": "Absenderdetails",
"fromEmail": "Absender-E-Mail",
"fromEmailHelp": "E-Mail-Adresse, die als Absender erscheint",
"fromName": "Absendername",
"fromNameHelp": "Name, der als Absender erscheint",
"testConfiguration": "Konfiguration testen",
"testEmail": "Test-E-Mail-Adresse",
"testEmailHelp": "Senden Sie eine Test-E-Mail zur Überprüfung der Einstellungen",
"sendTestEmail": "Test-E-Mail senden",
"saveConfiguration": "Konfiguration speichern",
"emailTemplates": "E-Mail-Vorlagen",
"templateVariables": "Verfügbare Variablen",
"previewTemplate": "Vorlage anzeigen"
},
"cms": {
"title": "CMS-Seiten",
"subtitle": "Rechtliche und informative Seiten verwalten",
"loadingPages": "Seiten werden geladen...",
"pages": "Seiten",
"previewLinks": "Vorschau-Links",
"englishVersion": "Englische Version",
"germanVersion": "Deutsche Version",
"editPage": "{{page}} bearbeiten",
"pageTitle": "Seitentitel",
"pageContent": "Seiteninhalt",
"pageTitlePlaceholder": "Seitentitel eingeben...",
"saveChanges": "Änderungen speichern",
"lastUpdated": "Zuletzt aktualisiert:",
"impressum": "Impressum",
"datenschutz": "Datenschutzerklärung",
"pageUpdated": "Seite erfolgreich aktualisiert"
},
"maintenance": {
"title": "Systemwartung",
"message": "Wir führen derzeit geplante Wartungsarbeiten durch, um unseren Service zu verbessern. Wir sind in Kürze wieder online.",
"expectedCompletion": "Voraussichtliche Fertigstellung:",
"checkBackLater": "Bitte schauen Sie später wieder vorbei",
"urgentMatters": "Bei dringenden Anliegen kontaktieren Sie bitte"
}
}
+262 -7
View File
@@ -22,7 +22,25 @@
"uploading": "Uploading...",
"uploaded": "Uploaded",
"photo": "photo",
"photos": "photos"
"photos": "photos",
"restore": "Restore",
"actions": "Actions",
"refresh": "Refresh",
"preview": "Preview",
"processing": "Processing...",
"upload": "Upload"
},
"upload": {
"photoCategory": "Photo Category",
"noCategory": "No category",
"eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)",
"selectedFiles": "Selected files",
"uploading": "Uploading...",
"uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload"
},
"navigation": {
"dashboard": "Dashboard",
@@ -34,6 +52,46 @@
"emailSettings": "Email Settings",
"cmsPages": "CMS Pages"
},
"archives": {
"title": "Archives",
"subtitle": "Manage archived photo galleries",
"loadingArchives": "Loading archives...",
"totalArchives": "Total Archives",
"storageUsed": "Storage Used",
"totalPhotos": "Total Photos",
"avgArchiveSize": "Avg Archive Size",
"searchPlaceholder": "Search archives...",
"allTypes": "All Types",
"wedding": "Wedding",
"birthday": "Birthday",
"corporate": "Corporate",
"other": "Other",
"sortByDate": "Sort by Date",
"sortByName": "Sort by Name",
"sortBySize": "Sort by Size",
"tableHeaders": {
"event": "Event",
"type": "Type",
"archivedDate": "Archived Date",
"size": "Size",
"photos": "Photos",
"actions": "Actions"
},
"noArchivesFound": "No archives found",
"eventDateNA": "Event date: N/A",
"processing": "Processing...",
"download": "Download",
"restore": "Restore",
"delete": "Delete",
"showing": "Showing {{from}} to {{to}} of {{total}} archives",
"page": "Page {{current}} of {{total}}",
"storageManagement": "Storage Management",
"storageInfo": "Archives are stored permanently unless manually deleted. Consider implementing a retention policy to manage storage costs.",
"confirmRestore": "Are you sure you want to restore this archive? The event will become active again.",
"confirmDelete": "Are you sure you want to permanently delete this archive? This action cannot be undone.",
"restoreSuccess": "Archive restored successfully",
"deleteSuccess": "Archive deleted permanently"
},
"auth": {
"login": "Login",
"password": "Password",
@@ -102,12 +160,15 @@
"events": {
"title": "Events",
"createEvent": "Create Event",
"createNewEvent": "Create New Event",
"setupNewGallery": "Set up a new photo gallery for your event",
"eventDetails": "Event Details",
"eventName": "Event Name",
"eventType": "Event Type",
"eventDate": "Event Date",
"hostEmail": "Host Email",
"adminEmail": "Admin Email",
"adminNotificationEmail": "Admin Notification Email",
"expirationDate": "Expiration Date",
"active": "Active",
"archived": "Archived",
@@ -120,17 +181,75 @@
"uploadPhotos": "Upload Photos",
"archiveEvent": "Archive Event",
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
"extendExpiration": "Extend {{days}} Days"
"extendExpiration": "Extend {{days}} Days",
"backToEvents": "Back to Events",
"loadingEventDetails": "Loading event details...",
"saveChanges": "Save Changes",
"eventExpired": "This event has expired",
"eventExpiresIn": "This event expires in {{days}} days",
"guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.",
"warningEmailsSent": "Warning emails have been sent to the host.",
"overview": "Overview",
"photos": "Photos",
"categories": "Categories",
"eventInformation": "Event Information",
"welcomeMessage": "Welcome Message",
"noWelcomeMessage": "No welcome message set",
"created": "Created",
"expires": "Expires",
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
"resetGalleryPassword": "Reset Gallery Password",
"photoStatistics": "Photo Statistics",
"managePhotos": "Manage Photos",
"actions": "Actions",
"archivingInfo": "Archiving will create a ZIP file of all photos and remove the gallery from public access.",
"statistics": "Statistics",
"views": "Views",
"downloads": "Downloads",
"uniqueVisitors": "Unique Visitors",
"noStatistics": "No statistics available yet",
"archiveStatus": "Archive Status",
"archivedOn": "Archived On",
"downloadArchive": "Download Archive",
"loadingPhotos": "Loading photos...",
"photoCategories": "Photo Categories",
"organizeCategoriesInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
"categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.",
"contactInformation": "Contact Information",
"hostEmailHelp": "Will receive gallery creation and expiration notifications",
"adminEmailHelp": "Will receive system notifications and archive confirmations",
"securityAccess": "Security & Access",
"galleryPassword": "Gallery Password",
"confirmPassword": "Confirm Password",
"showPasswords": "Show passwords",
"gallerySettings": "Gallery Settings",
"colorTheme": "Color Theme",
"galleryExpiresIn": "Gallery Expires In",
"galleryWillExpireOn": "Gallery will expire on {{date}}",
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
"processingRequest": "Processing your request...",
"eventTypeWedding": "Wedding",
"eventTypeBirthday": "Birthday",
"eventTypeCorporate": "Corporate",
"eventTypeOther": "Other",
"days30": "30 days",
"days60": "60 days",
"days90": "90 days",
"days365": "1 year"
},
"settings": {
"title": "System Settings",
"subtitle": "Configure system-wide settings and preferences",
"loadingSettings": "Loading settings...",
"general": {
"title": "General",
"siteConfiguration": "Site Configuration",
"siteUrl": "Site URL",
"siteUrlHelp": "Used for generating gallery links in emails",
"defaultExpiration": "Default Expiration (days)",
"defaultExpirationHelp": "How long galleries remain active by default",
"maxFileSize": "Max File Size (MB)",
"maxFileSizeHelp": "Maximum size per uploaded photo",
"allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles",
@@ -139,7 +258,10 @@
"enableRegistration": "Allow self-registration for admins",
"maintenanceMode": "Enable maintenance mode",
"language": "Language",
"saveSettings": "Save General Settings"
"defaultLanguage": "Default Language",
"defaultLanguageHelp": "Language shown to guests before login",
"saveSettings": "Save General Settings",
"saveGeneralSettings": "Save General Settings"
},
"storage": {
"title": "Storage",
@@ -150,23 +272,31 @@
"storageUsage": "Storage Usage",
"storageByEvent": "Storage by Event",
"storageManagement": "Storage Management",
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries."
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries.",
"noEventsUsingStorage": "No events using storage",
"unlimited": "Unlimited"
},
"security": {
"title": "Security",
"passwordSettings": "Password Settings",
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length",
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
"sessionAuth": "Session & Authentication",
"sessionTimeout": "Session Timeout (minutes)",
"sessionTimeoutHelp": "Admin session timeout in minutes",
"maxLoginAttempts": "Max Login Attempts",
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
"enable2FA": "Enable two-factor authentication for admins",
"recaptchaSettings": "reCAPTCHA Settings",
"enableRecaptcha": "Enable reCAPTCHA for login forms",
"siteKey": "Site Key",
"siteKeyHelp": "Your reCAPTCHA v2 site key (public)",
"secretKey": "Secret Key",
"secretKeyHelp": "Your reCAPTCHA v2 secret key (keep private)",
"recaptchaHelp": "Get your reCAPTCHA keys from",
"saveSettings": "Save Security Settings"
"saveSettings": "Save Security Settings",
"saveSecuritySettings": "Save Security Settings"
},
"categories": {
"title": "Categories",
@@ -174,33 +304,98 @@
"aboutText": "Global categories are available for all events. You can also create event-specific categories when editing individual events. Categories help organize photos and allow guests to filter photos by type in the gallery view."
}
},
"analytics": {
"title": "Analytics Dashboard",
"titleSimple": "Analytics",
"subtitle": "Track gallery performance and visitor engagement",
"detailedSubtitle": "Detailed analytics powered by Umami",
"loadingAnalytics": "Loading analytics...",
"showSummaryView": "Show Summary View",
"fullDashboard": "Full Dashboard",
"refresh": "Refresh",
"last7Days": "Last 7 days",
"last30Days": "Last 30 days",
"last90Days": "Last 90 days",
"pageViews": "Page Views",
"uniqueVisitors": "Unique Visitors",
"totalDownloads": "Total Downloads",
"topGallery": "Top Gallery",
"topPages": "Top Pages",
"views": "views",
"visitors": "unique visitors",
"topDownloadsByGallery": "Top Downloads by Gallery",
"deviceBreakdown": "Device Breakdown",
"desktop": "Desktop",
"mobile": "Mobile",
"tablet": "Tablet",
"storageUsage": "Storage Usage",
"used": "Used",
"of": "of",
"totalPhotos": "Total Photos",
"activeEvents": "Active Events",
"notConfigured": "Umami Analytics Not Configured",
"configureInstructions": "To see real analytics data, configure Umami in your environment variables and admin panel settings.",
"noData": "No data available",
"percentChange": "{{percent}}% from last period"
},
"branding": {
"title": "Branding & Customization",
"title": "Branding & Themes",
"titleFull": "Branding & Customization",
"subtitle": "Customize the look and feel of your galleries",
"loadingBranding": "Loading branding settings...",
"companyInfo": "Company Information",
"companyName": "Company Name",
"companyNameHelp": "Displayed in gallery headers and emails",
"companyTagline": "Company Tagline",
"companyTaglineHelp": "A short description of your business",
"supportEmail": "Support Email",
"supportEmailHelp": "Contact email for guest support",
"footerText": "Footer Text",
"footerTextHelp": "Displayed at the bottom of galleries",
"logo": "Logo",
"currentLogo": "Current logo",
"uploadLogo": "Upload Logo",
"removeLogo": "Remove Logo",
"logoHelp": "Recommended size: 200x60px, PNG or JPEG",
"favicon": "Favicon",
"currentFavicon": "Current favicon",
"uploadFavicon": "Upload Favicon",
"removeFavicon": "Remove Favicon",
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
"watermark": "Watermark",
"watermarkSettings": "Watermark Settings",
"enableWatermark": "Enable watermark on photos",
"enableWatermarks": "Enable Watermarks",
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
"watermarkLogo": "Watermark Logo",
"currentWatermark": "Current watermark",
"uploadWatermarkLogo": "Upload Watermark Logo",
"watermarkPosition": "Watermark Position",
"topLeft": "Top Left",
"topRight": "Top Right",
"center": "Center",
"bottomLeft": "Bottom Left",
"bottomRight": "Bottom Right",
"watermarkOpacity": "Watermark Opacity",
"watermarkSize": "Watermark Size",
"theme": "Theme",
"galleryTheme": "Gallery Theme",
"themeCustomization": "Theme Customization",
"selectPreset": "Select a preset theme",
"colors": "Colors",
"primaryColor": "Primary Color",
"secondaryColor": "Secondary Color",
"accentColor": "Accent Color",
"backgroundColor": "Background Color",
"textColor": "Text Color",
"customCSS": "Custom CSS",
"preview": "Preview",
"previewInNewTab": "Preview in New Tab",
"reset": "Reset",
"saveChanges": "Save Changes"
"saveChanges": "Save Changes",
"applyLivePreview": "Apply changes immediately (Live Preview)",
"eventSpecificThemes": "Event-Specific Themes",
"eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them."
},
"admin": {
"title": "Admin Panel",
@@ -214,6 +409,10 @@
"storagePercent": "{{percent}}% of {{limit}}",
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
"noNotifications": "No new notifications",
"markAsRead": "Mark as read",
"markAllAsRead": "Mark all as read",
"notificationSettings": "Notification Settings",
"changePassword": "Change Password",
"loadingDashboard": "Loading dashboard...",
"activeEvents": "Active Events",
@@ -238,6 +437,8 @@
"notFound": "Not Found",
"galleryNotFound": "Gallery Not Found",
"galleryNotFoundMessage": "This gallery does not exist or has been removed.",
"galleryArchived": "Gallery Archived",
"galleryArchivedMessage": "This gallery has been archived and is no longer accessible. Please contact the event organizer if you need access to these photos.",
"unauthorized": "Unauthorized",
"forbidden": "Forbidden",
"serverError": "Server Error",
@@ -280,5 +481,59 @@
"pageUpdated": "Page updated successfully",
"archiveRestored": "Archive restored successfully",
"archiveDeleted": "Archive deleted permanently"
},
"email": {
"title": "Email Configuration",
"subtitle": "Configure email settings for notifications",
"loadingSettings": "Loading email settings...",
"smtpConfiguration": "SMTP Configuration",
"smtpHost": "SMTP Host",
"smtpHostHelp": "Your email server hostname",
"smtpPort": "SMTP Port",
"smtpPortHelp": "Usually 587 for TLS, 465 for SSL, 25 for unencrypted",
"smtpSecure": "Use SSL/TLS",
"smtpSecureHelp": "Enable for secure email transmission",
"smtpUsername": "SMTP Username",
"smtpUsernameHelp": "Your email account username",
"smtpPassword": "SMTP Password",
"smtpPasswordHelp": "Your email account password",
"fromDetails": "From Details",
"fromEmail": "From Email",
"fromEmailHelp": "Email address that appears as sender",
"fromName": "From Name",
"fromNameHelp": "Name that appears as sender",
"testConfiguration": "Test Configuration",
"testEmail": "Test Email Address",
"testEmailHelp": "Send a test email to verify settings",
"sendTestEmail": "Send Test Email",
"saveConfiguration": "Save Configuration",
"emailTemplates": "Email Templates",
"templateVariables": "Available Variables",
"previewTemplate": "Preview Template"
},
"cms": {
"title": "CMS Pages",
"subtitle": "Manage legal and informational pages",
"loadingPages": "Loading pages...",
"pages": "Pages",
"previewLinks": "Preview Links",
"englishVersion": "English Version",
"germanVersion": "German Version",
"editPage": "Edit {{page}}",
"pageTitle": "Page Title",
"pageContent": "Page Content",
"pageTitlePlaceholder": "Enter page title...",
"saveChanges": "Save Changes",
"lastUpdated": "Last updated:",
"impressum": "Legal Notice",
"datenschutz": "Privacy Policy",
"pageUpdated": "Page updated successfully"
},
"maintenance": {
"title": "System Maintenance",
"message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.",
"expectedCompletion": "Expected completion time:",
"checkBackLater": "Please check back later",
"urgentMatters": "For urgent matters, please contact"
}
}
+32 -20
View File
@@ -5,7 +5,7 @@ import { format, differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
@@ -19,6 +19,7 @@ export const GalleryPage: React.FC = () => {
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
@@ -55,7 +56,7 @@ export const GalleryPage: React.FC = () => {
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, password);
await login(slug!, password, recaptchaToken);
// Track successful password entry
analyticsService.trackGalleryEvent('password_entry', {
@@ -78,7 +79,7 @@ export const GalleryPage: React.FC = () => {
// Show loading state
if (isLoadingInfo) {
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
@@ -88,14 +89,18 @@ export const GalleryPage: React.FC = () => {
// Show error state
if (infoError) {
// Check if it's an archived gallery error
const errorMessage = (infoError as any)?.response?.data?.error;
const isArchived = errorMessage?.includes('archived');
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -106,9 +111,11 @@ export const GalleryPage: React.FC = () => {
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
<h2 className="text-xl font-semibold mb-2">
{t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{t('errors.galleryNotFoundMessage')}
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
@@ -140,13 +147,13 @@ export const GalleryPage: React.FC = () => {
// Show expired state
if (galleryInfo?.is_expired) {
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -198,26 +205,26 @@ export const GalleryPage: React.FC = () => {
// Show login form
return (
<div className="min-h-screen bg-gradient-to-br from-neutral-50 to-sand-100">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
{settingsData?.branding_logo_url ? (
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-20 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Camera className="w-10 h-10 text-white" />
</div>
)}
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-neutral-600 text-sm">
<div className="flex items-center justify-center text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(galleryInfo!.event_date), 'MMMM d, yyyy')}
</div>
@@ -256,6 +263,11 @@ export const GalleryPage: React.FC = () => {
autoFocus
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
@@ -277,19 +289,19 @@ export const GalleryPage: React.FC = () => {
{/* Legal Links */}
<div className="text-center mt-6">
<div className="flex items-center justify-center gap-4">
<a
href="/impressum"
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</a>
</Link>
<span className="text-xs text-neutral-400">|</span>
<a
href="/datenschutz"
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</a>
</Link>
</div>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { MaintenanceMode } from '../components/MaintenanceMode';
export const MaintenancePage: React.FC = () => {
return <MaintenanceMode />;
};
+55 -14
View File
@@ -1,15 +1,17 @@
import React, { useState } from 'react';
import { Navigate } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { Button, Input, Card } from '../../components/common';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { getAuthToken } from '../../config/api';
import { getAuthToken, api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
const { isAuthenticated, login } = useAdminAuth();
const [searchParams] = useSearchParams();
const [formData, setFormData] = useState({
email: '',
@@ -19,6 +21,24 @@ export const AdminLoginPage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [loginSuccess, setLoginSuccess] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['admin-login-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Check for session expired message
useEffect(() => {
if (searchParams.get('session') === 'expired') {
toast.info('Your session has expired. Please log in again.');
}
}, [searchParams]);
// Redirect if already authenticated or login successful
if (isAuthenticated || loginSuccess) {
@@ -55,7 +75,10 @@ export const AdminLoginPage: React.FC = () => {
setErrors({});
try {
const response = await authService.adminLogin(formData);
const response = await authService.adminLogin({
...formData,
recaptchaToken
});
login(response.token, response.user);
toast.success('Login successful!');
setLoginSuccess(true);
@@ -93,15 +116,23 @@ export const AdminLoginPage: React.FC = () => {
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-neutral-100 flex items-center justify-center p-4">
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 rounded-full mb-4">
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-neutral-900">Admin Login</h1>
<p className="text-neutral-600 mt-2">Sign in to manage your photo galleries</p>
{settingsData?.branding_logo_url ? (
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Lock className="w-8 h-8 text-white" />
</div>
)}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>Admin Login</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>Sign in to manage your photo galleries</p>
</div>
{/* Login Form */}
@@ -178,6 +209,12 @@ export const AdminLoginPage: React.FC = () => {
</a>
</div>
{/* reCAPTCHA */}
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
{/* Submit Button */}
<Button
type="submit"
@@ -192,10 +229,14 @@ export const AdminLoginPage: React.FC = () => {
</Card>
{/* Footer */}
<p className="text-center text-sm text-neutral-600 mt-8">
<p className="text-center text-sm mt-8" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a href="mailto:support@example.com" className="text-primary-600 hover:text-primary-700">
support@example.com
<a
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || 'support@example.com'}
</a>
</p>
+26 -11
View File
@@ -9,26 +9,36 @@ import {
AlertCircle,
RotateCcw,
Trash2,
Eye,
ChevronLeft,
ChevronRight
} from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { format, parseISO, isValid } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { archiveService } from '../../services/archive.service';
import { useNavigate } from 'react-router-dom';
// import { useNavigate } from 'react-router-dom';
export const ArchivesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
const [currentPage, setCurrentPage] = useState(1);
const navigate = useNavigate();
// const navigate = useNavigate();
const queryClient = useQueryClient();
// Helper function to safely format dates
const formatDate = (dateString: string | null | undefined, formatStr: string): string => {
if (!dateString) return '';
try {
const date = parseISO(dateString);
return isValid(date) ? format(date, formatStr) : '';
} catch {
return '';
}
};
// Fetch archives from API
const { data: archivesData, isLoading } = useQuery({
queryKey: ['admin-archives', currentPage],
@@ -54,7 +64,9 @@ export const ArchivesPage: React.FC = () => {
return b.archiveSize - a.archiveSize;
case 'date':
default:
return new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime();
const dateA = a.archivedAt ? new Date(a.archivedAt).getTime() : 0;
const dateB = b.archivedAt ? new Date(b.archivedAt).getTime() : 0;
return dateB - dateA;
}
});
@@ -107,9 +119,10 @@ export const ArchivesPage: React.FC = () => {
}
};
const handleViewDetails = (archive: typeof archives[0]) => {
navigate(`/admin/archives/${archive.id}`);
};
// Details view not implemented yet
// const handleViewDetails = (archive: typeof archives[0]) => {
// navigate(`/admin/archives/${archive.id}`);
// };
if (isLoading) {
return (
@@ -257,7 +270,7 @@ export const ArchivesPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-neutral-900">{archive.eventName}</p>
<p className="text-xs text-neutral-500">
Event date: {format(parseISO(archive.eventDate), 'MMM d, yyyy')}
Event date: {formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A'}
</p>
</div>
</td>
@@ -266,9 +279,9 @@ export const ArchivesPage: React.FC = () => {
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
<div>
<p>{format(parseISO(archive.archivedAt), 'MMM d, yyyy')}</p>
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || 'Processing...'}</p>
<p className="text-xs text-neutral-500">
{format(parseISO(archive.archivedAt), 'h:mm a')}
{formatDate(archive.archivedAt, 'h:mm a')}
</p>
</div>
</td>
@@ -280,6 +293,7 @@ export const ArchivesPage: React.FC = () => {
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{/* Details view not implemented yet
<Button
variant="ghost"
size="sm"
@@ -288,6 +302,7 @@ export const ArchivesPage: React.FC = () => {
>
Details
</Button>
*/}
<Button
variant="ghost"
size="sm"
+64 -9
View File
@@ -14,6 +14,7 @@ import {
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
@@ -87,6 +88,12 @@ export const EmailConfigPage: React.FC = () => {
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [showPassword, setShowPassword] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({
subject: '',
htmlContent: '',
textContent: ''
});
const queryClient = useQueryClient();
// SMTP Configuration state
@@ -205,6 +212,35 @@ export const EmailConfigPage: React.FC = () => {
}
};
const handlePreviewTemplate = async () => {
if (!selectedTemplateKey || !editedTemplate) return;
// Generate sample data based on the template
const sampleData: Record<string, string> = {
event_name: 'John & Jane Wedding',
event_date: 'December 25, 2024',
password: 'wedding2024',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
expiration_date: 'January 25, 2025',
welcome_message: 'Thank you for celebrating our special day with us!',
days_remaining: '30',
admin_email: 'admin@example.com',
host_email: 'host@example.com'
};
try {
const preview = await emailService.previewTemplate(selectedTemplateKey, sampleData);
setPreviewData({
subject: preview.subject,
htmlContent: preview.body_html,
textContent: preview.body_text
});
setShowPreview(true);
} catch (error) {
toast.error('Failed to preview template');
}
};
const renderVariableHelp = () => {
const variables = editedTemplate.variables || [];
return (
@@ -479,15 +515,25 @@ export const EmailConfigPage: React.FC = () => {
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900">Edit Template</h3>
<Button
variant="primary"
size="sm"
onClick={handleSaveTemplate}
isLoading={saveTemplateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviewTemplate}
leftIcon={<Eye className="w-4 h-4" />}
>
Preview
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveTemplate}
isLoading={saveTemplateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
</div>
</div>
<div className="space-y-4">
@@ -533,6 +579,15 @@ export const EmailConfigPage: React.FC = () => {
</div>
</div>
)}
{/* Email Preview Modal */}
<EmailPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
subject={previewData.subject}
htmlContent={previewData.htmlContent}
textContent={previewData.textContent}
/>
</div>
);
};
+244 -39
View File
@@ -14,16 +14,20 @@ import {
AlertTriangle,
Copy,
CheckCircle,
Upload
Upload,
Image,
Key
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload, EventCategoryManager } from '../../components/admin';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
import { archiveService } from '../../services/archive.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -45,6 +49,17 @@ export const EventDetailsPage: React.FC = () => {
});
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
// Photo filters state
const [photoFilters, setPhotoFilters] = useState({
category_id: undefined as number | null | undefined,
search: '',
sort: 'date' as 'date' | 'name' | 'size',
order: 'desc' as 'asc' | 'desc'
});
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
@@ -61,6 +76,23 @@ export const EventDetailsPage: React.FC = () => {
retry: false,
});
// Fetch photos when on photos tab
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
queryKey: ['admin-event-photos', id, photoFilters],
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
enabled: !!id && activeTab === 'photos',
});
// Fetch categories for the event
const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id],
queryFn: async () => {
const response = await eventsService.getEventCategories(parseInt(id!));
return response || [];
},
enabled: !!id,
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: any) => eventsService.updateEvent(parseInt(id!), data),
@@ -257,8 +289,51 @@ export const EventDetailsPage: React.FC = () => {
</Card>
)}
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Tabs */}
<div className="mb-6 border-b border-neutral-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'photos'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
<Image className="w-4 h-4" />
Photos
{event.photo_count && event.photo_count > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
{event.photo_count}
</span>
)}
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'categories'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
Categories
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Details */}
<div className="lg:col-span-2 space-y-6">
{/* Event Information */}
@@ -361,34 +436,25 @@ export const EventDetailsPage: React.FC = () => {
<p className="text-sm text-neutral-600 mt-2">
Share this link with guests. They'll need the password to access the gallery.
</p>
</Card>
{/* Photo Management */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Photo Management</h2>
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(!showPhotoUpload)}
>
Upload Photos
</Button>
</div>
{showPhotoUpload && (
<div className="mb-4">
<PhotoUpload
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success('Photos uploaded successfully');
setShowPhotoUpload(false);
}}
/>
{!event.is_archived && (
<div className="mt-4 pt-4 border-t border-neutral-200">
<Button
variant="outline"
size="sm"
leftIcon={<Key className="w-4 h-4" />}
onClick={() => setShowPasswordReset(true)}
className="w-full justify-center"
>
Reset Gallery Password
</Button>
</div>
)}
</Card>
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Photo Statistics</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
@@ -403,18 +469,22 @@ export const EventDetailsPage: React.FC = () => {
</span>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
</p>
<p className="text-xs text-blue-600 mt-1">
Photos are organized by categories you define.
</p>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">Categories</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
</div>
<div className="mt-6 pt-4 border-t border-neutral-200">
<EventCategoryManager eventId={parseInt(id!)} />
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
Manage Photos
</Button>
</div>
</Card>
@@ -503,7 +573,15 @@ export const EventDetailsPage: React.FC = () => {
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={() => toast.info('Archive download coming soon')}
onClick={async () => {
try {
toast.info(`Downloading ${event.event_name} archive...`);
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download archive');
}
}}
className="w-full justify-center"
>
Download Archive
@@ -514,6 +592,133 @@ export const EventDetailsPage: React.FC = () => {
)}
</div>
</div>
)}
{/* Photos Tab */}
{activeTab === 'photos' && (
<div>
{/* Photo Upload */}
{showPhotoUpload && (
<Card padding="md" className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Upload Photos</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPhotoUpload(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
<PhotoUpload
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
toast.success('Photos uploaded successfully');
setShowPhotoUpload(false);
refetchPhotos();
}}
/>
</Card>
)}
{/* Photo Filters */}
<PhotoFilters
categories={categories}
selectedCategory={photoFilters.category_id}
searchTerm={photoFilters.search}
sortBy={photoFilters.sort}
sortOrder={photoFilters.order}
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
/>
{/* Actions Bar */}
{!showPhotoUpload && (
<div className="mb-4 flex justify-between items-center">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
Upload Photos
</Button>
</div>
)}
{/* Photo Grid */}
{photosLoading ? (
<div className="flex items-center justify-center py-12">
<Loading size="lg" text="Loading photos..." />
</div>
) : (
<AdminPhotoGrid
photos={photos}
eventId={parseInt(id!)}
onPhotoClick={(photo, index) => setSelectedPhoto({ photo, index })}
onPhotosDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
}}
/>
)}
{/* Photo Viewer */}
{selectedPhoto && (
<AdminPhotoViewer
photos={photos}
initialIndex={selectedPhoto.index}
eventId={parseInt(id!)}
onClose={() => setSelectedPhoto(null)}
onPhotoDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
setSelectedPhoto(null);
}}
categories={categories}
/>
)}
</div>
)}
{/* Categories Tab */}
{activeTab === 'categories' && (
<div>
<Card padding="md">
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-2">Photo Categories</h2>
<p className="text-sm text-neutral-600">
Organize your photos into categories. Categories help guests navigate and find specific types of photos.
</p>
</div>
<EventCategoryManager
eventId={parseInt(id!)}
/>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Tip:</strong> Categories are specific to each event. You can create custom categories like "Ceremony", "Reception", "Portraits", etc.
</p>
</div>
</Card>
</div>
)}
{/* Password Reset Modal */}
{showPasswordReset && (
<PasswordResetModal
eventName={event.event_name}
onConfirm={async (sendEmail) => {
const result = await eventsService.resetPassword(event.id, sendEmail);
return result;
}}
onClose={() => setShowPasswordReset(false)}
/>
)}
</div>
);
};
+31 -4
View File
@@ -15,6 +15,7 @@ import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import type { Event } from '../../types';
@@ -28,6 +29,7 @@ export const EventsListPage: React.FC = () => {
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
@@ -63,6 +65,25 @@ export const EventsListPage: React.FC = () => {
},
});
// Bulk archive mutation
const bulkArchiveMutation = useMutation({
mutationFn: eventsService.bulkArchiveEvents,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
setSelectedEvents([]);
setShowBulkArchiveModal(false);
if (data.results.failed.length === 0) {
toast.success(`Successfully archived ${data.results.successful.length} events`);
} else {
toast.warning(`Archived ${data.results.successful.length} events, ${data.results.failed.length} failed`);
}
},
onError: () => {
toast.error('Failed to archive events');
},
});
// Filter and search events
const filteredEvents = useMemo(() => {
if (!data?.events) return [];
@@ -237,10 +258,7 @@ export const EventsListPage: React.FC = () => {
<Button
variant="outline"
size="sm"
onClick={() => {
// Handle bulk archive
toast.info('Bulk archive coming soon');
}}
onClick={() => setShowBulkArchiveModal(true)}
>
Archive Selected
</Button>
@@ -407,6 +425,15 @@ export const EventsListPage: React.FC = () => {
</table>
</div>
</Card>
{/* Bulk Archive Modal */}
<BulkArchiveModal
isOpen={showBulkArchiveModal}
onClose={() => setShowBulkArchiveModal(false)}
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkArchiveMutation.isPending}
/>
</div>
</ErrorBoundary>
);
+6 -1
View File
@@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general');
const queryClient = useQueryClient();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Fetch settings
const { data: settings, isLoading } = useQuery({
@@ -60,6 +60,11 @@ export const SettingsPage: React.FC = () => {
React.useEffect(() => {
if (settings) {
// Set the language if it's different from current
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
i18n.changeLanguage(settings.general_default_language);
}
// Extract general settings
setGeneralSettings({
site_url: settings.general_site_url || '',
+7 -2
View File
@@ -65,10 +65,15 @@ class AnalyticsService {
this.initialized = true;
}
// Check if analytics is initialized
isInitialized() {
return this.initialized;
}
// Track custom events
track(eventName: string, eventData?: Record<string, any>) {
if (!this.initialized || !window.umami) {
console.warn('Umami Analytics not initialized');
// Silently ignore if not initialized
return;
}
@@ -79,7 +84,7 @@ class AnalyticsService {
// Track page views manually
trackPageView(url?: string, referrer?: string) {
if (!this.initialized || !window.umami) {
console.warn('Umami Analytics not initialized');
// Silently ignore if not initialized
return;
}
+5 -3
View File
@@ -3,11 +3,12 @@ import type { LoginResponse, GalleryAuthResponse } from '../types';
export const authService = {
// Admin authentication
async adminLogin(credentials: { email: string; password: string }): Promise<LoginResponse> {
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
// Backend expects 'username' field, but we accept email
const response = await api.post<LoginResponse>('/api/auth/admin/login', {
username: credentials.email,
password: credentials.password
password: credentials.password,
recaptchaToken: credentials.recaptchaToken
});
setAuthToken(response.data.token, true);
@@ -20,10 +21,11 @@ export const authService = {
},
// Gallery authentication
async verifyGalleryPassword(slug: string, password: string): Promise<GalleryAuthResponse> {
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
const response = await api.post<GalleryAuthResponse>('/api/auth/gallery/verify', {
slug,
password,
recaptchaToken
});
setAuthToken(response.data.token, false);
+26
View File
@@ -80,6 +80,20 @@ export const eventsService = {
await api.post(`/api/admin/events/${id}/archive`);
},
// Bulk archive events (admin)
async bulkArchiveEvents(eventIds: number[]): Promise<{
message: string;
results: {
successful: Array<{ id: number; name: string }>;
failed: Array<{ id: number; name: string; error: string }>;
};
}> {
const response = await api.post('/api/admin/events/bulk-archive', {
eventIds,
});
return response.data;
},
// Extend event expiration (admin)
async extendExpiration(id: number, days: number): Promise<Event> {
const response = await api.post<Event>(`/api/events/${id}/extend`, {
@@ -87,4 +101,16 @@ export const eventsService = {
});
return response.data;
},
// Get event categories
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string }>> {
const response = await api.get(`/api/admin/categories/event/${eventId}`);
return response.data || [];
},
// Reset event password
async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
const response = await api.post(`/api/admin/events/${eventId}/reset-password`, { sendEmail });
return response.data;
},
};
+2 -1
View File
@@ -6,4 +6,5 @@ export { analyticsService } from './analytics.service';
export { archiveService } from './archive.service';
export { emailService } from './email.service';
export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service';
@@ -0,0 +1,101 @@
import { api } from '../config/api';
export interface Notification {
id: number;
type: string;
actorType: string;
actorName: string;
eventName?: string;
eventId?: number;
metadata: Record<string, any>;
createdAt: string;
readAt?: string;
isRead: boolean;
}
export interface NotificationsResponse {
notifications: Notification[];
unreadCount: number;
}
export const notificationsService = {
// Get notifications
async getNotifications(includeRead: boolean = false, limit: number = 20): Promise<NotificationsResponse> {
const response = await api.get('/api/admin/notifications', {
params: { includeRead, limit }
});
return response.data;
},
// Mark single notification as read
async markAsRead(notificationId: number): Promise<void> {
await api.put(`/api/admin/notifications/${notificationId}/read`);
},
// Mark all notifications as read
async markAllAsRead(): Promise<void> {
await api.put('/api/admin/notifications/read-all');
},
// Clear old notifications
async clearOldNotifications(): Promise<{ deletedCount: number }> {
const response = await api.delete('/api/admin/notifications/clear-old');
return response.data;
},
// Format notification message
formatNotificationMessage(notification: Notification): string {
switch (notification.type) {
case 'event_created':
return `New event "${notification.eventName}" was created`;
case 'event_archived':
return `Event "${notification.eventName}" was archived`;
case 'photos_uploaded':
return `${notification.metadata.count || 0} photos uploaded to "${notification.eventName}"`;
case 'event_expiring':
return `Event "${notification.eventName}" expires in ${notification.metadata.days || 0} days`;
case 'event_expired':
return `Event "${notification.eventName}" has expired`;
case 'password_changed':
return `Password changed by ${notification.actorName}`;
case 'settings_updated':
return `${notification.metadata.type || 'System'} settings updated`;
case 'email_template_updated':
return `Email template "${notification.metadata.template}" updated`;
case 'bulk_download':
return `${notification.metadata.count || 0} photos downloaded from "${notification.eventName}"`;
case 'storage_warning':
return `Storage usage at ${notification.metadata.percentage || 0}%`;
default:
return notification.metadata.message || 'System notification';
}
},
// Get notification icon and color
getNotificationStyle(type: string): { icon: string; color: string } {
switch (type) {
case 'event_created':
return { icon: 'Calendar', color: 'text-blue-600' };
case 'event_archived':
return { icon: 'Archive', color: 'text-green-600' };
case 'photos_uploaded':
return { icon: 'Image', color: 'text-purple-600' };
case 'event_expiring':
return { icon: 'AlertCircle', color: 'text-amber-600' };
case 'event_expired':
return { icon: 'Clock', color: 'text-red-600' };
case 'password_changed':
return { icon: 'Lock', color: 'text-indigo-600' };
case 'settings_updated':
return { icon: 'Settings', color: 'text-gray-600' };
case 'email_template_updated':
return { icon: 'Mail', color: 'text-teal-600' };
case 'bulk_download':
return { icon: 'Download', color: 'text-cyan-600' };
case 'storage_warning':
return { icon: 'Database', color: 'text-orange-600' };
default:
return { icon: 'Bell', color: 'text-gray-600' };
}
}
};
+93
View File
@@ -0,0 +1,93 @@
import { api } from '../config/api';
export interface AdminPhoto {
id: number;
filename: string;
path: string;
url: string;
thumbnail_url: string | null;
type: string;
category_id: number | null;
category_name: string | null;
category_slug: string | null;
size: number;
uploaded_at: string;
view_count?: number;
download_count?: number;
}
export interface PhotoFilters {
category_id?: number | null;
type?: string;
search?: string;
sort?: 'date' | 'name' | 'size';
order?: 'asc' | 'desc';
}
class PhotosService {
async getEventPhotos(eventId: number, filters?: PhotoFilters): Promise<AdminPhoto[]> {
const params = new URLSearchParams();
if (filters) {
if (filters.category_id !== undefined) {
params.append('category_id', filters.category_id?.toString() || '');
}
if (filters.type) params.append('type', filters.type);
if (filters.search) params.append('search', filters.search);
if (filters.sort) params.append('sort', filters.sort);
if (filters.order) params.append('order', filters.order);
}
const queryString = params.toString();
const url = `/api/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url);
// Return photos as-is, URLs are already relative API paths
return response.data.photos;
}
async deletePhoto(eventId: number, photoId: number): Promise<void> {
await api.delete(`/api/admin/events/${eventId}/photos/${photoId}`);
}
async deletePhotos(eventId: number, photoIds: number[]): Promise<void> {
await api.post(`/api/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
}
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
await api.patch(`/api/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
}
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
await api.post(`/api/admin/events/${eventId}/photos/bulk-update`, {
photoIds,
updates: { category_id: categoryId }
});
}
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
const response = await api.get(`/api/admin/events/${eventId}/photos/${photoId}/download`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
export const photosService = new PhotosService();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Test maintenance mode functionality
echo "Testing maintenance mode implementation..."
# First, let's check the current maintenance mode status
echo -e "\n1. Checking current maintenance mode status:"
curl -s http://localhost:3002/api/public/settings | jq '.general_maintenance_mode'
# Test a public gallery endpoint
echo -e "\n2. Testing public gallery endpoint (should get 503 if maintenance is on):"
curl -s -o /dev/null -w "%{http_code}" http://localhost:3002/api/gallery/test-gallery/info
# Test admin login (should always work)
echo -e "\n\n3. Testing admin login endpoint (should always work):"
curl -s -o /dev/null -w "%{http_code}" http://localhost:3002/api/admin/login
echo -e "\n\nDone!"