diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 276c270..d2f81cd 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -7,6 +7,7 @@ const archiveRoutes = require('./adminArchives'); const emailRoutes = require('./adminEmail'); const settingsRoutes = require('./adminSettings'); const eventsRoutes = require('./adminEvents'); +const photosRoutes = require('./adminPhotos'); // Mount sub-routers router.use('/dashboard', dashboardRoutes); @@ -14,5 +15,6 @@ router.use('/archives', archiveRoutes); router.use('/email', emailRoutes); router.use('/settings', settingsRoutes); router.use('/events', eventsRoutes); +router.use('/events', photosRoutes); module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 111a8bc..3924c00 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -86,7 +86,7 @@ router.post('/config', [ await logActivity('email_config_updated', { smtp_host, from_email }, null, - { type: 'admin', id: req.user.id, name: req.user.username } + { type: 'admin', id: req.admin.id, name: req.admin.username } ); res.json({ message: 'Email configuration updated successfully' }); @@ -224,8 +224,8 @@ router.put('/templates/:key', [ await db('activity_logs').insert({ activity_type: 'email_template_updated', 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({ template_key: req.params.key }) }); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index bf4580b..57f1bf7 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -284,7 +284,7 @@ router.put('/:id', adminAuth, [ await logActivity('event_updated', { changes: Object.keys(updates), eventName: event.event_name }, id, - { type: 'admin', id: req.user.id, name: req.user.username } + { type: 'admin', id: req.admin.id, name: req.admin.username } ); res.json({ message: 'Event updated successfully' }); @@ -315,7 +315,7 @@ router.delete('/:id', adminAuth, async (req, res) => { await logActivity('event_deleted', { event_name: event.event_name }, null, - { type: 'admin', id: req.user.id, name: req.user.username } + { type: 'admin', id: req.admin.id, name: req.admin.username } ); res.json({ message: 'Event deleted successfully' }); @@ -347,7 +347,7 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => { await logActivity(newStatus ? 'event_activated' : 'event_deactivated', { eventName: event.event_name }, id, - { type: 'admin', id: req.user.id, name: req.user.username } + { type: 'admin', id: req.admin.id, name: req.admin.username } ); res.json({ @@ -387,7 +387,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => { await logActivity('event_archived', { eventName: event.event_name }, id, - { type: 'admin', id: req.user.id, name: req.user.username } + { type: 'admin', id: req.admin.id, name: req.admin.username } ); res.json({ message: 'Event archived successfully' }); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js new file mode 100644 index 0000000..880bccd --- /dev/null +++ b/backend/src/routes/adminPhotos.js @@ -0,0 +1,217 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs').promises; +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { generateThumbnail } = require('../services/imageProcessor'); +const router = express.Router(); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + const { eventId } = req.params; + const { type = 'individual' } = req.body; + + try { + // Get event details + const event = await db('events').where({ id: eventId }).first(); + if (!event) { + return cb(new Error('Event not found')); + } + + // Create destination path + const photoType = type === 'collage' ? 'collages' : 'individual'; + const destPath = path.join(getStoragePath(), 'events/active', event.slug, photoType); + + // Ensure directory exists + await fs.mkdir(destPath, { recursive: true }); + + cb(null, destPath); + } catch (error) { + cb(error); + } + }, + filename: (req, file, cb) => { + // Generate unique filename + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const ext = path.extname(file.originalname); + const name = path.basename(file.originalname, ext); + cb(null, `${name}-${uniqueSuffix}${ext}`); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 50 * 1024 * 1024, // 50MB limit + }, + fileFilter: (req, file, cb) => { + // Accept images only + const allowedTypes = /jpeg|jpg|png|webp/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (mimetype && extname) { + return cb(null, true); + } else { + cb(new Error('Only JPEG, PNG and WebP images are allowed')); + } + } +}); + +// Upload photos for an event +router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (req, res) => { + try { + const { eventId } = req.params; + const { type = 'individual' } = req.body; + + // Verify event exists and admin has access + const event = await db('events').where({ id: eventId }).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const uploadedPhotos = []; + + // Process each uploaded file + for (const file of req.files) { + try { + // Generate thumbnail + const thumbnailPath = await generateThumbnail(file.path); + + // Calculate relative paths + const storagePath = getStoragePath(); + const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path); + const relativeThumbPath = thumbnailPath ? path.relative(path.join(storagePath, 'events/active'), thumbnailPath) : null; + + // Add to database + const [photoId] = await db('photos').insert({ + event_id: eventId, + filename: file.filename, + path: relativePath, + thumbnail_path: relativeThumbPath, + type: type === 'collage' ? 'collage' : 'individual', + size_bytes: file.size + }); + + uploadedPhotos.push({ + id: photoId, + filename: file.filename, + size: file.size, + type + }); + } catch (error) { + console.error(`Error processing file ${file.filename}:`, error); + // Continue with other files + } + } + + // Log activity + await logActivity('photos_uploaded', + { count: uploadedPhotos.length, eventName: event.event_name }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Successfully uploaded ${uploadedPhotos.length} photos`, + photos: uploadedPhotos + }); + } catch (error) { + console.error('Error uploading photos:', error); + res.status(500).json({ error: 'Failed to upload photos' }); + } +}); + +// Delete a photo +router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + // Get photo details + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Delete physical files + const storagePath = getStoragePath(); + 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 exists + if (photo.thumbnail_path) { + const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path); + try { + await fs.unlink(thumbPath); + } catch (error) { + console.error('Error deleting thumbnail:', error); + } + } + + // Remove from database + await db('photos').where({ id: photoId }).delete(); + + // Log activity + const event = await db('events').where({ id: eventId }).first(); + await logActivity('photo_deleted', + { filename: photo.filename, eventName: event.event_name }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Photo deleted successfully' }); + } catch (error) { + console.error('Error deleting photo:', error); + res.status(500).json({ error: 'Failed to delete photo' }); + } +}); + +// Get all photos for an event +router.get('/:eventId/photos', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + const { type } = req.query; + + let query = db('photos').where({ event_id: eventId }); + + if (type) { + query = query.where({ type }); + } + + const photos = await query.orderBy('uploaded_at', 'desc'); + + res.json({ + photos: photos.map(photo => ({ + id: photo.id, + filename: photo.filename, + url: `/photos/${photo.path}`, + thumbnail_url: photo.thumbnail_path ? `/photos/${photo.thumbnail_path}` : null, + type: photo.type, + size: photo.size_bytes, + uploaded_at: photo.uploaded_at + })) + }); + } catch (error) { + console.error('Error fetching photos:', error); + res.status(500).json({ error: 'Failed to fetch photos' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 3c8e97b..d0b83b9 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -118,8 +118,8 @@ router.put('/branding', adminAuth, async (req, res) => { await db('activity_logs').insert({ activity_type: 'branding_updated', 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({ company_name }) }); @@ -215,8 +215,8 @@ router.put('/theme', adminAuth, async (req, res) => { await db('activity_logs').insert({ activity_type: 'theme_updated', 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({ theme_name: themeSettings.name || 'custom' }) }); @@ -252,8 +252,8 @@ router.put('/general', adminAuth, async (req, res) => { await db('activity_logs').insert({ activity_type: 'general_settings_updated', 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({ settings_count: Object.keys(settings).length }) }); @@ -289,8 +289,8 @@ router.put('/security', adminAuth, async (req, res) => { await db('activity_logs').insert({ activity_type: 'security_settings_updated', 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({ settings_count: Object.keys(settings).length }) }); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 210f337..5a7ef6b 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -30,23 +30,63 @@ async function verifyGalleryAccess(req, res, next) { } } -// Get gallery info -router.get('/:slug/info', async (req, res) => { +// Verify share token +router.get('/:slug/verify-token/:token', async (req, res) => { try { - const { slug } = req.params; + const { slug, token } = req.params; const event = await db('events') - .where({ slug }) - .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active') + .where({ slug, is_active: true }) + .select('id', 'share_link') .first(); if (!event) { return res.status(404).json({ error: 'Gallery not found' }); } + // Extract token from share link and verify + const expectedToken = event.share_link.split('/').pop(); + if (token !== expectedToken) { + return res.status(404).json({ error: 'Invalid gallery link' }); + } + + res.json({ valid: true }); + } catch (error) { + res.status(500).json({ error: 'Failed to verify token' }); + } +}); + +// Get gallery info (with optional token verification) +router.get('/:slug/info', async (req, res) => { + try { + const { slug } = req.params; + const { token } = req.query; + + const event = await db('events') + .where({ slug }) + .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'share_link') + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + // If token provided, verify it matches the share link + if (token) { + const expectedToken = event.share_link.split('/').pop(); + if (token !== expectedToken) { + return res.status(404).json({ error: 'Invalid gallery link' }); + } + } + res.json({ - ...event, - is_expired: !event.is_active || new Date(event.expires_at) < new Date() + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + expires_at: event.expires_at, + is_active: event.is_active, + is_expired: !event.is_active || new Date(event.expires_at) < new Date(), + requires_password: true }); } catch (error) { res.status(500).json({ error: 'Failed to fetch gallery info' }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e740ac..86653f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,9 +14,9 @@ "date-fns": "^2.29.3", "js-cookie": "^3.0.5", "lucide-react": "^0.292.0", - "react": "^19.1.0", + "react": "^18.3.1", "react-countdown": "^2.3.5", - "react-dom": "^19.1.0", + "react-dom": "^18.3.1", "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.8.0", @@ -26,8 +26,8 @@ "devDependencies": { "@eslint/js": "^9.29.0", "@types/js-cookie": "^3.0.6", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.5.2", "autoprefixer": "^10.4.13", "eslint": "^9.29.0", @@ -1511,24 +1511,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "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": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", - "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "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": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", - "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^18.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -2092,9 +2100,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001726", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", - "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "dev": true, "funding": [ { @@ -2714,21 +2722,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3925,10 +3918,13 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } @@ -3947,15 +3943,16 @@ } }, "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^19.1.0" + "react": "^18.3.1" } }, "node_modules/react-image-gallery": { @@ -4182,10 +4179,13 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } }, "node_modules/semver": { "version": "6.3.1", @@ -4497,6 +4497,21 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", @@ -4716,6 +4731,21 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 55a4a8b..c1bbd24 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,9 +16,9 @@ "date-fns": "^2.29.3", "js-cookie": "^3.0.5", "lucide-react": "^0.292.0", - "react": "^19.1.0", + "react": "^18.3.1", "react-countdown": "^2.3.5", - "react-dom": "^19.1.0", + "react-dom": "^18.3.1", "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.8.0", @@ -28,8 +28,8 @@ "devDependencies": { "@eslint/js": "^9.29.0", "@types/js-cookie": "^3.0.6", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.5.2", "autoprefixer": "^10.4.13", "eslint": "^9.29.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25cfa94..6cfd90c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { analyticsService } from './services/analytics.service'; -import { GalleryAuthProvider, AdminAuthProvider } from './contexts'; +import { GalleryAuthProvider } from './contexts'; import { ThemeProvider } from './contexts/ThemeContext'; import { GalleryPage } from './pages/GalleryPage'; import { @@ -20,7 +20,7 @@ import { BrandingPage, SettingsPage } from './pages/admin'; -import { AdminLayout } from './components/admin'; +import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { PageErrorBoundary, OfflineIndicator, SkipLink } from './components/common'; // Create a client @@ -57,32 +57,28 @@ function App() { {/* Public gallery routes */} - } /> - {/* Admin routes */} - - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - } /> + {/* Admin routes - wrap with AdminAuthProvider */} + }> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Default redirect */} } /> diff --git a/frontend/src/components/admin/AdminAuthWrapper.tsx b/frontend/src/components/admin/AdminAuthWrapper.tsx new file mode 100644 index 0000000..4647fff --- /dev/null +++ b/frontend/src/components/admin/AdminAuthWrapper.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { AdminAuthProvider } from '../../contexts'; + +export const AdminAuthWrapper: React.FC = () => { + return ( + + + + ); +}; + +AdminAuthWrapper.displayName = 'AdminAuthWrapper'; \ No newline at end of file diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx new file mode 100644 index 0000000..51eb65e --- /dev/null +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -0,0 +1,200 @@ +import React, { useState, useRef } from 'react'; +import { Upload, X, Image, Loader2 } from 'lucide-react'; +import { Button } from '../common'; +import { clsx } from 'clsx'; + +interface PhotoUploadProps { + eventId: number; + onUploadComplete?: () => void; +} + +export const PhotoUpload: React.FC = ({ eventId, onUploadComplete }) => { + const [isUploading, setIsUploading] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploadProgress, setUploadProgress] = useState(0); + const [photoType, setPhotoType] = useState<'individual' | 'collage'>('individual'); + const fileInputRef = useRef(null); + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const imageFiles = files.filter(file => + ['image/jpeg', 'image/png', 'image/webp'].includes(file.type) + ); + setSelectedFiles(prev => [...prev, ...imageFiles]); + }; + + const removeFile = (index: number) => { + setSelectedFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const handleUpload = async () => { + if (selectedFiles.length === 0) return; + + setIsUploading(true); + setUploadProgress(0); + + const formData = new FormData(); + selectedFiles.forEach(file => { + formData.append('photos', file); + }); + formData.append('type', photoType); + + try { + const response = await fetch(`/api/admin/events/${eventId}/upload`, { + method: 'POST', + body: formData, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const result = await response.json(); + console.log('Upload result:', result); + + // Clear selected files + setSelectedFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + + // Call callback + if (onUploadComplete) { + onUploadComplete(); + } + } catch (error) { + console.error('Upload error:', error); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + + return ( +
+ {/* Photo Type Selection */} +
+ +
+ + +
+
+ + {/* File Input Area */} +
0 ? "border-primary-400 bg-primary-50/30" : "border-neutral-300" + )} + onClick={() => fileInputRef.current?.click()} + > + +

+ Click to upload or drag and drop +

+

+ JPEG, PNG or WebP (max 50MB per file) +

+ +
+ + {/* Selected Files */} + {selectedFiles.length > 0 && ( +
+

+ Selected files ({selectedFiles.length}) +

+
+ {selectedFiles.map((file, index) => ( +
+
+ +
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
+
+ +
+ ))} +
+
+ )} + + {/* Upload Button */} +
+ +
+ + {/* Progress Bar */} + {isUploading && uploadProgress > 0 && ( +
+
+
+ )} +
+ ); +}; + +PhotoUpload.displayName = 'PhotoUpload'; \ No newline at end of file diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index c469868..c6278c2 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -2,4 +2,6 @@ export { AdminLayout } from './AdminLayout'; export { AdminSidebar } from './AdminSidebar'; export { AdminHeader } from './AdminHeader'; export { ThemeCustomizer } from './ThemeCustomizer'; -export { PasswordChangeModal } from './PasswordChangeModal'; \ No newline at end of file +export { PasswordChangeModal } from './PasswordChangeModal'; +export { AdminAuthWrapper } from './AdminAuthWrapper'; +export { PhotoUpload } from './PhotoUpload'; \ No newline at end of file diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index 19f8eb8..223f078 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -46,8 +46,18 @@ export const GalleryAuthProvider: React.FC = ({ childr // Check if user has a valid token on mount const token = getAuthToken(false); if (token) { - // TODO: Validate token with backend - setIsAuthenticated(true); + // Try to restore event data from localStorage + const storedEvent = localStorage.getItem('gallery_event'); + if (storedEvent) { + try { + const eventData = JSON.parse(storedEvent); + setEvent(eventData); + setIsAuthenticated(true); + } catch (error) { + console.error('Failed to parse stored event data'); + localStorage.removeItem('gallery_event'); + } + } } setIsLoading(false); }, []); @@ -59,6 +69,9 @@ export const GalleryAuthProvider: React.FC = ({ childr const response = await authService.verifyGalleryPassword(slug, password); setEvent(response.event); setIsAuthenticated(true); + + // Store event data in localStorage + localStorage.setItem('gallery_event', JSON.stringify(response.event)); } catch (err: any) { setError(err.response?.data?.error || 'Invalid password'); throw err; @@ -71,6 +84,7 @@ export const GalleryAuthProvider: React.FC = ({ childr authService.galleryLogout(); setIsAuthenticated(false); setEvent(null); + localStorage.removeItem('gallery_event'); }; return ( diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts index 2ed610e..498855f 100644 --- a/frontend/src/hooks/useGallery.ts +++ b/frontend/src/hooks/useGallery.ts @@ -2,10 +2,10 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { galleryService } from '../services'; import { toast } from 'react-toastify'; -export const useGalleryInfo = (slug: string) => { +export const useGalleryInfo = (slug: string, token?: string) => { return useQuery({ - queryKey: ['gallery-info', slug], - queryFn: () => galleryService.getGalleryInfo(slug), + queryKey: ['gallery-info', slug, token], + queryFn: () => galleryService.getGalleryInfo(slug, token), retry: 1, staleTime: 5 * 60 * 1000, // 5 minutes }); diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 4fce709..2e7e41d 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -10,14 +10,14 @@ import { GalleryView } from '../components/gallery'; import { analyticsService } from '../services/analytics.service'; export const GalleryPage: React.FC = () => { - const { slug } = useParams<{ slug: string }>(); + const { slug, token } = useParams<{ slug: string; token?: string }>(); const { isAuthenticated, login, event } = useGalleryAuth(); const [password, setPassword] = useState(''); const [isLoggingIn, setIsLoggingIn] = useState(false); const [loginError, setLoginError] = useState(null); // Fetch gallery info (public data) - const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!); + const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token); // Calculate days until expiration const daysUntilExpiration = galleryInfo diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 3a01c24..7b1a495 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -13,12 +13,14 @@ import { X, AlertTriangle, Copy, - CheckCircle + CheckCircle, + Upload } 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 } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { galleryService } from '../../services/gallery.service'; @@ -42,6 +44,7 @@ export const EventDetailsPage: React.FC = () => { expires_at: '', }); const [copiedLink, setCopiedLink] = useState(false); + const [showPhotoUpload, setShowPhotoUpload] = useState(false); // Fetch event details const { data: event, isLoading: eventLoading } = useQuery({ @@ -50,11 +53,12 @@ export const EventDetailsPage: React.FC = () => { enabled: !!id, }); - // Fetch event statistics + // Fetch event statistics (skip if event doesn't exist or from admin context) const { data: stats } = useQuery({ queryKey: ['admin-event-stats', event?.slug], queryFn: () => galleryService.getGalleryStats(event!.slug), - enabled: !!event?.slug, + enabled: false, // Disable stats from admin panel as it requires gallery auth + retry: false, }); // Update mutation @@ -203,15 +207,17 @@ export const EventDetailsPage: React.FC = () => { )} )} - - - View Gallery - + {event.share_link && ( + + + View Gallery + + )}
@@ -357,6 +363,57 @@ export const EventDetailsPage: React.FC = () => {

+ {/* Photo Management */} + +
+

Photo Management

+ +
+ + {showPhotoUpload && ( +
+ { + queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); + toast.success('Photos uploaded successfully'); + setShowPhotoUpload(false); + }} + /> +
+ )} + +
+
+ Total Photos + {event.photo_count || 0} +
+ +
+ Total Size + + {event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'} + +
+ +
+

+ Storage Location: /storage/events/active/{event.slug}/ +

+

+ Photos can also be added by placing them in the 'individual' or 'collages' folders. +

+
+
+
+ {/* Actions */} {!event.is_archived && ( diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index 995bd9a..d468f5f 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -346,17 +346,19 @@ export const EventsListPage: React.FC = () => { View Details - setActiveDropdown(null)} - > - - View Gallery - - {!event.is_archived && ( + {event.share_link ? ( + setActiveDropdown(null)} + > + + View Gallery + + ) : null} + {!event.is_archived ? ( - )} - {event.is_archived && ( + ) : null} + {event.is_archived ? ( - )} + ) : null}