Fix general settings route and req.user references
- Update frontend settings service to use correct /api/admin/settings/general route - Fix all req.user to req.admin references in adminSettings.js - Ensures settings can be saved without authentication errors
This commit is contained in:
@@ -7,6 +7,7 @@ const archiveRoutes = require('./adminArchives');
|
|||||||
const emailRoutes = require('./adminEmail');
|
const emailRoutes = require('./adminEmail');
|
||||||
const settingsRoutes = require('./adminSettings');
|
const settingsRoutes = require('./adminSettings');
|
||||||
const eventsRoutes = require('./adminEvents');
|
const eventsRoutes = require('./adminEvents');
|
||||||
|
const photosRoutes = require('./adminPhotos');
|
||||||
|
|
||||||
// Mount sub-routers
|
// Mount sub-routers
|
||||||
router.use('/dashboard', dashboardRoutes);
|
router.use('/dashboard', dashboardRoutes);
|
||||||
@@ -14,5 +15,6 @@ router.use('/archives', archiveRoutes);
|
|||||||
router.use('/email', emailRoutes);
|
router.use('/email', emailRoutes);
|
||||||
router.use('/settings', settingsRoutes);
|
router.use('/settings', settingsRoutes);
|
||||||
router.use('/events', eventsRoutes);
|
router.use('/events', eventsRoutes);
|
||||||
|
router.use('/events', photosRoutes);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -86,7 +86,7 @@ router.post('/config', [
|
|||||||
await logActivity('email_config_updated',
|
await logActivity('email_config_updated',
|
||||||
{ smtp_host, from_email },
|
{ smtp_host, from_email },
|
||||||
null,
|
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' });
|
res.json({ message: 'Email configuration updated successfully' });
|
||||||
@@ -224,8 +224,8 @@ router.put('/templates/:key', [
|
|||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'email_template_updated',
|
activity_type: 'email_template_updated',
|
||||||
actor_type: 'admin',
|
actor_type: 'admin',
|
||||||
actor_id: req.user.id,
|
actor_id: req.admin.id,
|
||||||
actor_name: req.user.username,
|
actor_name: req.admin.username,
|
||||||
metadata: JSON.stringify({ template_key: req.params.key })
|
metadata: JSON.stringify({ template_key: req.params.key })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ router.put('/:id', adminAuth, [
|
|||||||
await logActivity('event_updated',
|
await logActivity('event_updated',
|
||||||
{ changes: Object.keys(updates), eventName: event.event_name },
|
{ changes: Object.keys(updates), eventName: event.event_name },
|
||||||
id,
|
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' });
|
res.json({ message: 'Event updated successfully' });
|
||||||
@@ -315,7 +315,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
await logActivity('event_deleted',
|
await logActivity('event_deleted',
|
||||||
{ event_name: event.event_name },
|
{ event_name: event.event_name },
|
||||||
null,
|
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' });
|
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',
|
await logActivity(newStatus ? 'event_activated' : 'event_deactivated',
|
||||||
{ eventName: event.event_name },
|
{ eventName: event.event_name },
|
||||||
id,
|
id,
|
||||||
{ type: 'admin', id: req.user.id, name: req.user.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -387,7 +387,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
|
|||||||
await logActivity('event_archived',
|
await logActivity('event_archived',
|
||||||
{ eventName: event.event_name },
|
{ eventName: event.event_name },
|
||||||
id,
|
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' });
|
res.json({ message: 'Event archived successfully' });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -118,8 +118,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'branding_updated',
|
activity_type: 'branding_updated',
|
||||||
actor_type: 'admin',
|
actor_type: 'admin',
|
||||||
actor_id: req.user.id,
|
actor_id: req.admin.id,
|
||||||
actor_name: req.user.username,
|
actor_name: req.admin.username,
|
||||||
metadata: JSON.stringify({ company_name })
|
metadata: JSON.stringify({ company_name })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,8 +215,8 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'theme_updated',
|
activity_type: 'theme_updated',
|
||||||
actor_type: 'admin',
|
actor_type: 'admin',
|
||||||
actor_id: req.user.id,
|
actor_id: req.admin.id,
|
||||||
actor_name: req.user.username,
|
actor_name: req.admin.username,
|
||||||
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,8 +252,8 @@ router.put('/general', adminAuth, async (req, res) => {
|
|||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'general_settings_updated',
|
activity_type: 'general_settings_updated',
|
||||||
actor_type: 'admin',
|
actor_type: 'admin',
|
||||||
actor_id: req.user.id,
|
actor_id: req.admin.id,
|
||||||
actor_name: req.user.username,
|
actor_name: req.admin.username,
|
||||||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
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({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'security_settings_updated',
|
activity_type: 'security_settings_updated',
|
||||||
actor_type: 'admin',
|
actor_type: 'admin',
|
||||||
actor_id: req.user.id,
|
actor_id: req.admin.id,
|
||||||
actor_name: req.user.username,
|
actor_name: req.admin.username,
|
||||||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,23 +30,63 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get gallery info
|
// Verify share token
|
||||||
router.get('/:slug/info', async (req, res) => {
|
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { slug } = req.params;
|
const { slug, token } = req.params;
|
||||||
|
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ slug })
|
.where({ slug, is_active: true })
|
||||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active')
|
.select('id', 'share_link')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
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({
|
res.json({
|
||||||
...event,
|
event_name: event.event_name,
|
||||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date()
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch gallery info' });
|
res.status(500).json({ error: 'Failed to fetch gallery info' });
|
||||||
|
|||||||
Generated
+71
-41
@@ -14,9 +14,9 @@
|
|||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.292.0",
|
"lucide-react": "^0.292.0",
|
||||||
"react": "^19.1.0",
|
"react": "^18.3.1",
|
||||||
"react-countdown": "^2.3.5",
|
"react-countdown": "^2.3.5",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-image-gallery": "^1.2.11",
|
"react-image-gallery": "^1.2.11",
|
||||||
"react-intersection-observer": "^9.4.3",
|
"react-intersection-observer": "^9.4.3",
|
||||||
"react-router-dom": "^6.8.0",
|
"react-router-dom": "^6.8.0",
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
@@ -1511,24 +1511,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.8",
|
"version": "18.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.1.6",
|
"version": "18.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
@@ -2092,9 +2100,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001726",
|
"version": "1.0.30001727",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||||
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
|
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2714,21 +2722,6 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -3925,10 +3918,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.0",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3947,15 +3943,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"loose-envify": "^1.1.0",
|
||||||
|
"scheduler": "^0.23.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.1.0"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-image-gallery": {
|
"node_modules/react-image-gallery": {
|
||||||
@@ -4182,10 +4179,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.26.0",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
@@ -4497,6 +4497,21 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"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": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.292.0",
|
"lucide-react": "^0.292.0",
|
||||||
"react": "^19.1.0",
|
"react": "^18.3.1",
|
||||||
"react-countdown": "^2.3.5",
|
"react-countdown": "^2.3.5",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-image-gallery": "^1.2.11",
|
"react-image-gallery": "^1.2.11",
|
||||||
"react-intersection-observer": "^9.4.3",
|
"react-intersection-observer": "^9.4.3",
|
||||||
"react-router-dom": "^6.8.0",
|
"react-router-dom": "^6.8.0",
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
|
|||||||
+19
-23
@@ -5,7 +5,7 @@ import { ToastContainer } from 'react-toastify';
|
|||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import { analyticsService } from './services/analytics.service';
|
import { analyticsService } from './services/analytics.service';
|
||||||
|
|
||||||
import { GalleryAuthProvider, AdminAuthProvider } from './contexts';
|
import { GalleryAuthProvider } from './contexts';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { GalleryPage } from './pages/GalleryPage';
|
import { GalleryPage } from './pages/GalleryPage';
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
BrandingPage,
|
BrandingPage,
|
||||||
SettingsPage
|
SettingsPage
|
||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
import { AdminLayout } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink } from './components/common';
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
@@ -57,32 +57,28 @@ function App() {
|
|||||||
<SkipLink />
|
<SkipLink />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public gallery routes */}
|
{/* Public gallery routes */}
|
||||||
<Route path="/gallery/:slug" element={
|
<Route path="/gallery/:slug/:token?" element={
|
||||||
<GalleryAuthProvider>
|
<GalleryAuthProvider>
|
||||||
<GalleryPage />
|
<GalleryPage />
|
||||||
</GalleryAuthProvider>
|
</GalleryAuthProvider>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes - wrap with AdminAuthProvider */}
|
||||||
<Route path="/admin/*" element={
|
<Route path="/admin" element={<AdminAuthWrapper />}>
|
||||||
<AdminAuthProvider>
|
<Route path="login" element={<AdminLoginPage />} />
|
||||||
<Routes>
|
<Route element={<AdminLayout />}>
|
||||||
<Route path="login" element={<AdminLoginPage />} />
|
<Route path="dashboard" element={<AdminDashboard />} />
|
||||||
<Route element={<AdminLayout />}>
|
<Route path="events" element={<EventsListPage />} />
|
||||||
<Route path="dashboard" element={<AdminDashboard />} />
|
<Route path="events/new" element={<CreateEventPage />} />
|
||||||
<Route path="events" element={<EventsListPage />} />
|
<Route path="events/:id" element={<EventDetailsPage />} />
|
||||||
<Route path="events/new" element={<CreateEventPage />} />
|
<Route path="archives" element={<ArchivesPage />} />
|
||||||
<Route path="events/:id" element={<EventDetailsPage />} />
|
<Route path="email" element={<EmailConfigPage />} />
|
||||||
<Route path="archives" element={<ArchivesPage />} />
|
<Route path="analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="email" element={<EmailConfigPage />} />
|
<Route path="branding" element={<BrandingPage />} />
|
||||||
<Route path="analytics" element={<AnalyticsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="branding" element={<BrandingPage />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
</Route>
|
||||||
<Route path="" element={<Navigate to="/admin/dashboard" replace />} />
|
</Route>
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</AdminAuthProvider>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Default redirect */}
|
{/* Default redirect */}
|
||||||
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { AdminAuthProvider } from '../../contexts';
|
||||||
|
|
||||||
|
export const AdminAuthWrapper: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<AdminAuthProvider>
|
||||||
|
<Outlet />
|
||||||
|
</AdminAuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AdminAuthWrapper.displayName = 'AdminAuthWrapper';
|
||||||
@@ -0,0 +1,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<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [photoType, setPhotoType] = useState<'individual' | 'collage'>('individual');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
const imageFiles = files.filter(file =>
|
||||||
|
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Photo Type Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
Photo Type
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="individual"
|
||||||
|
checked={photoType === 'individual'}
|
||||||
|
onChange={(e) => setPhotoType(e.target.value as 'individual')}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span>Individual Photos</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="collage"
|
||||||
|
checked={photoType === 'collage'}
|
||||||
|
onChange={(e) => setPhotoType(e.target.value as 'collage')}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span>Collages</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Input Area */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||||
|
"hover:border-primary-400 hover:bg-primary-50/50",
|
||||||
|
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30" : "border-neutral-300"
|
||||||
|
)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-12 h-12 mx-auto text-neutral-400 mb-4" />
|
||||||
|
<p className="text-neutral-700 font-medium mb-1">
|
||||||
|
Click to upload or drag and drop
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
JPEG, PNG or WebP (max 50MB per file)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Files */}
|
||||||
|
{selectedFiles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-700">
|
||||||
|
Selected files ({selectedFiles.length})
|
||||||
|
</p>
|
||||||
|
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||||
|
{selectedFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image className="w-5 h-5 text-neutral-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeFile(index);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-neutral-200 rounded"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={selectedFiles.length === 0 || isUploading}
|
||||||
|
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{isUploading ? 'Uploading...' : `Upload ${selectedFiles.length} Photo${selectedFiles.length !== 1 ? 's' : ''}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{isUploading && uploadProgress > 0 && (
|
||||||
|
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PhotoUpload.displayName = 'PhotoUpload';
|
||||||
@@ -2,4 +2,6 @@ export { AdminLayout } from './AdminLayout';
|
|||||||
export { AdminSidebar } from './AdminSidebar';
|
export { AdminSidebar } from './AdminSidebar';
|
||||||
export { AdminHeader } from './AdminHeader';
|
export { AdminHeader } from './AdminHeader';
|
||||||
export { ThemeCustomizer } from './ThemeCustomizer';
|
export { ThemeCustomizer } from './ThemeCustomizer';
|
||||||
export { PasswordChangeModal } from './PasswordChangeModal';
|
export { PasswordChangeModal } from './PasswordChangeModal';
|
||||||
|
export { AdminAuthWrapper } from './AdminAuthWrapper';
|
||||||
|
export { PhotoUpload } from './PhotoUpload';
|
||||||
@@ -46,8 +46,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
// Check if user has a valid token on mount
|
// Check if user has a valid token on mount
|
||||||
const token = getAuthToken(false);
|
const token = getAuthToken(false);
|
||||||
if (token) {
|
if (token) {
|
||||||
// TODO: Validate token with backend
|
// Try to restore event data from localStorage
|
||||||
setIsAuthenticated(true);
|
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);
|
setIsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -59,6 +69,9 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
const response = await authService.verifyGalleryPassword(slug, password);
|
const response = await authService.verifyGalleryPassword(slug, password);
|
||||||
setEvent(response.event);
|
setEvent(response.event);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
// Store event data in localStorage
|
||||||
|
localStorage.setItem('gallery_event', JSON.stringify(response.event));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Invalid password');
|
setError(err.response?.data?.error || 'Invalid password');
|
||||||
throw err;
|
throw err;
|
||||||
@@ -71,6 +84,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
authService.galleryLogout();
|
authService.galleryLogout();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
|
localStorage.removeItem('gallery_event');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
|||||||
import { galleryService } from '../services';
|
import { galleryService } from '../services';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
export const useGalleryInfo = (slug: string) => {
|
export const useGalleryInfo = (slug: string, token?: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['gallery-info', slug],
|
queryKey: ['gallery-info', slug, token],
|
||||||
queryFn: () => galleryService.getGalleryInfo(slug),
|
queryFn: () => galleryService.getGalleryInfo(slug, token),
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import { GalleryView } from '../components/gallery';
|
|||||||
import { analyticsService } from '../services/analytics.service';
|
import { analyticsService } from '../services/analytics.service';
|
||||||
|
|
||||||
export const GalleryPage: React.FC = () => {
|
export const GalleryPage: React.FC = () => {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||||
const { isAuthenticated, login, event } = useGalleryAuth();
|
const { isAuthenticated, login, event } = useGalleryAuth();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch gallery info (public data)
|
// 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
|
// Calculate days until expiration
|
||||||
const daysUntilExpiration = galleryInfo
|
const daysUntilExpiration = galleryInfo
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import {
|
|||||||
X,
|
X,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Copy,
|
Copy,
|
||||||
CheckCircle
|
CheckCircle,
|
||||||
|
Upload
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format, parseISO, differenceInDays } from 'date-fns';
|
import { format, parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { Button, Input, Card, Loading } from '../../components/common';
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
|
import { PhotoUpload } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
@@ -42,6 +44,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
expires_at: '',
|
expires_at: '',
|
||||||
});
|
});
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||||
|
|
||||||
// Fetch event details
|
// Fetch event details
|
||||||
const { data: event, isLoading: eventLoading } = useQuery({
|
const { data: event, isLoading: eventLoading } = useQuery({
|
||||||
@@ -50,11 +53,12 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch event statistics
|
// Fetch event statistics (skip if event doesn't exist or from admin context)
|
||||||
const { data: stats } = useQuery({
|
const { data: stats } = useQuery({
|
||||||
queryKey: ['admin-event-stats', event?.slug],
|
queryKey: ['admin-event-stats', event?.slug],
|
||||||
queryFn: () => galleryService.getGalleryStats(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
|
// Update mutation
|
||||||
@@ -203,15 +207,17 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<a
|
{event.share_link && (
|
||||||
href={event.share_link}
|
<a
|
||||||
target="_blank"
|
href={event.share_link}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
|
||||||
<ExternalLink className="w-4 h-4" />
|
>
|
||||||
View Gallery
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
View Gallery
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,6 +363,57 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||||
|
<span className="text-sm text-neutral-600">Total Photos</span>
|
||||||
|
<span className="text-sm font-medium">{event.photo_count || 0}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||||
|
<span className="text-sm text-neutral-600">Total Size</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
|
||||||
|
</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 can also be added by placing them in the 'individual' or 'collages' folders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{!event.is_archived && (
|
{!event.is_archived && (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
|
|||||||
@@ -346,17 +346,19 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
View Details
|
View Details
|
||||||
</button>
|
</button>
|
||||||
<a
|
{event.share_link ? (
|
||||||
href={event.share_link}
|
<a
|
||||||
target="_blank"
|
href={event.share_link}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
rel="noopener noreferrer"
|
||||||
onClick={() => setActiveDropdown(null)}
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
>
|
onClick={() => setActiveDropdown(null)}
|
||||||
<ExternalLink className="w-4 h-4" />
|
>
|
||||||
View Gallery
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
View Gallery
|
||||||
{!event.is_archived && (
|
</a>
|
||||||
|
) : null}
|
||||||
|
{!event.is_archived ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
archiveMutation.mutate(event.id);
|
archiveMutation.mutate(event.id);
|
||||||
@@ -367,8 +369,8 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
Archive Event
|
Archive Event
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
{event.is_archived && (
|
{event.is_archived ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toast.info('Download archive coming soon');
|
toast.info('Download archive coming soon');
|
||||||
@@ -379,7 +381,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
Download Archive
|
Download Archive
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Are you sure you want to delete this event?')) {
|
if (confirm('Are you sure you want to delete this event?')) {
|
||||||
|
|||||||
@@ -2,9 +2,16 @@ import { api } from '../config/api';
|
|||||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||||
|
|
||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
|
// Verify share token
|
||||||
|
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
|
||||||
|
const response = await api.get<{ valid: boolean }>(`/api/gallery/${slug}/verify-token/${token}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get basic gallery info (no auth required)
|
// Get basic gallery info (no auth required)
|
||||||
async getGalleryInfo(slug: string): Promise<GalleryInfo> {
|
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
||||||
const response = await api.get<GalleryInfo>(`/api/gallery/${slug}/info`);
|
const params = token ? { token } : {};
|
||||||
|
const response = await api.get<GalleryInfo>(`/api/gallery/${slug}/info`, { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const settingsService = {
|
|||||||
|
|
||||||
// Update multiple settings at once
|
// Update multiple settings at once
|
||||||
async updateSettings(settings: Record<string, any>): Promise<void> {
|
async updateSettings(settings: Record<string, any>): Promise<void> {
|
||||||
await api.put('/api/admin/settings', settings);
|
await api.put('/api/admin/settings/general', settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get storage information
|
// Get storage information
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export interface Event {
|
|||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
archive_path?: string;
|
archive_path?: string;
|
||||||
archived_at?: string;
|
archived_at?: string;
|
||||||
|
photo_count?: number;
|
||||||
|
total_size?: number;
|
||||||
|
recent_photos?: Array<{
|
||||||
|
filename: string;
|
||||||
|
type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
uploaded_at: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GalleryInfo {
|
export interface GalleryInfo {
|
||||||
@@ -25,6 +33,7 @@ export interface GalleryInfo {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_expired: boolean;
|
is_expired: boolean;
|
||||||
|
requires_password?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Photo {
|
export interface Photo {
|
||||||
|
|||||||
Reference in New Issue
Block a user