diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index bd02bbb..b41c822 100644 Binary files a/backend/data/photo_sharing.db and b/backend/data/photo_sharing.db differ diff --git a/backend/migrations/011_add_user_upload_settings.js b/backend/migrations/011_add_user_upload_settings.js new file mode 100644 index 0000000..f347cca --- /dev/null +++ b/backend/migrations/011_add_user_upload_settings.js @@ -0,0 +1,23 @@ +exports.up = async function(knex) { + // Add user upload settings to events table + await knex.schema.alterTable('events', function(table) { + table.boolean('allow_user_uploads').defaultTo(false); + table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL'); + }); + + // Add uploaded_by field to photos table to track who uploaded + await knex.schema.alterTable('photos', function(table) { + table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier + }); +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('allow_user_uploads'); + table.dropColumn('upload_category_id'); + }); + + await knex.schema.alterTable('photos', function(table) { + table.dropColumn('uploaded_by'); + }); +}; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index a6ab7ea..d6ab82c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -120,6 +120,7 @@ app.use('/api/events', eventRoutes); app.use('/api/gallery', galleryRoutes); app.use('/api/admin', adminRoutes); app.use('/api/admin/auth', adminAuthRoutes); +app.use('/api/admin/system', require('./src/routes/adminSystem')); app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/images', require('./src/routes/protectedImages')); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 2b7ad97..becfefc 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -19,7 +19,9 @@ router.post('/', adminAuth, [ body('password').isLength({ min: 6 }), body('expiration_days').isInt({ min: 1, max: 365 }).optional(), body('welcome_message').optional().trim(), - body('color_theme').optional().trim() + body('color_theme').optional().trim(), + body('allow_user_uploads').optional().isBoolean(), + body('upload_category_id').optional().isInt() ], async (req, res) => { try { const errors = validationResult(req); @@ -36,7 +38,9 @@ router.post('/', adminAuth, [ password, welcome_message = '', color_theme = null, - expiration_days = 30 + expiration_days = 30, + allow_user_uploads = false, + upload_category_id = null } = req.body; // Generate unique slug @@ -79,7 +83,9 @@ router.post('/', adminAuth, [ color_theme, share_link: shareLink, expires_at: expires_at.toISOString(), - created_at: new Date().toISOString() + created_at: new Date().toISOString(), + allow_user_uploads, + upload_category_id }); // Log activity @@ -256,7 +262,9 @@ router.put('/:id', adminAuth, [ body('is_active').optional().isBoolean(), body('expires_at').optional().isISO8601(), body('welcome_message').optional().trim(), - body('color_theme').optional().trim() + body('color_theme').optional().trim(), + body('allow_user_uploads').optional().isBoolean(), + body('upload_category_id').optional().isInt() ], async (req, res) => { try { const errors = validationResult(req); diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js new file mode 100644 index 0000000..02bcfd2 --- /dev/null +++ b/backend/src/routes/adminSystem.js @@ -0,0 +1,163 @@ +const express = require('express'); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); +const router = express.Router(); + +// Get system version +router.get('/version', adminAuth, async (req, res) => { + try { + // Read backend version from package.json + const packageJson = require('../../../package.json'); + + res.json({ + backend: packageJson.version, + frontend: '1.0.0', // This will be set by frontend + node: process.version, + environment: process.env.NODE_ENV || 'production' + }); + } catch (error) { + console.error('Error fetching version:', error); + res.status(500).json({ error: 'Failed to fetch version information' }); + } +}); + +// Get comprehensive system status +router.get('/status', adminAuth, async (req, res) => { + try { + // Database size + const dbPath = path.join(__dirname, '../../data/photo_sharing.db'); + let dbSize = 0; + try { + const stats = await fs.stat(dbPath); + dbSize = stats.size; + } catch (error) { + console.error('Error getting database size:', error); + } + + // Count various entities + const [eventsCount] = await db('events').count('* as count'); + const [photosCount] = await db('photos').count('* as count'); + const [adminsCount] = await db('admin_users').count('* as count'); + const [categoriesCount] = await db('photo_categories').count('* as count'); + + // Email queue status + const [pendingEmails] = await db('email_queue').where('status', 'pending').count('* as count'); + const [sentEmails] = await db('email_queue').where('status', 'sent').count('* as count'); + const [failedEmails] = await db('email_queue').where('status', 'failed').count('* as count'); + + // Activity logs count + const [activityCount] = await db('activity_logs').count('* as count'); + + // System info + const systemInfo = { + platform: os.platform(), + arch: os.arch(), + hostname: os.hostname(), + uptime: Math.floor(process.uptime()), + nodeVersion: process.version, + memory: { + total: os.totalmem(), + free: os.freemem(), + used: os.totalmem() - os.freemem() + }, + cpu: { + model: os.cpus()[0]?.model || 'Unknown', + cores: os.cpus().length + } + }; + + // Build response + const status = { + database: { + size: dbSize, + tables: { + events: eventsCount.count, + photos: photosCount.count, + admins: adminsCount.count, + categories: categoriesCount.count, + activityLogs: activityCount.count + } + }, + emailQueue: { + pending: pendingEmails.count, + sent: sentEmails.count, + failed: failedEmails.count + }, + system: systemInfo, + services: { + fileWatcher: { status: 'active' }, // These would ideally check actual service status + expirationChecker: { status: 'active' }, + emailProcessor: { status: 'active' } + }, + timestamp: new Date() + }; + + res.json(status); + } catch (error) { + console.error('Error fetching system status:', error); + res.status(500).json({ error: 'Failed to fetch system status' }); + } +}); + +// Get database statistics +router.get('/database', adminAuth, async (req, res) => { + try { + // Get table info + const tables = [ + 'events', 'photos', 'admin_users', 'photo_categories', + 'cms_pages', 'email_templates', 'email_queue', 'activity_logs', + 'app_settings', 'email_configs', 'access_logs', 'migrations' + ]; + + const tableInfo = []; + + for (const table of tables) { + try { + const [count] = await db(table).count('* as count'); + + // Get last update time + let lastUpdate = null; + try { + const lastRow = await db(table) + .orderBy('updated_at', 'desc') + .orOrderBy('created_at', 'desc') + .orOrderBy('timestamp', 'desc') + .orOrderBy('applied_at', 'desc') + .first(); + + if (lastRow) { + lastUpdate = lastRow.updated_at || lastRow.created_at || lastRow.timestamp || lastRow.applied_at; + } + } catch (e) { + // Table might not have timestamp columns + } + + tableInfo.push({ + name: table, + rows: count.count, + lastUpdate + }); + } catch (error) { + // Table might not exist + tableInfo.push({ + name: table, + rows: 0, + error: error.message + }); + } + } + + res.json({ + tables: tableInfo, + timestamp: new Date() + }); + } catch (error) { + console.error('Error fetching database info:', error); + res.status(500).json({ error: 'Failed to fetch database information' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 7d9226c..99f6cca 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -304,4 +304,77 @@ router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => { } }); +// User photo upload endpoint +router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => { + try { + const eventId = parseInt(req.params.eventId); + + // Verify the event matches the token + if (req.event.id !== eventId) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Check if user uploads are allowed + if (!req.event.allow_user_uploads) { + return res.status(403).json({ error: 'User uploads are not allowed for this event' }); + } + + // Import multer and photo processing + const multer = require('multer'); + const upload = multer({ + dest: '/tmp/uploads/', + limits: { + fileSize: 50 * 1024 * 1024, // 50MB + files: 10 // Max 10 files at once + }, + fileFilter: (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type')); + } + } + }).array('photos', 10); + + // Handle upload + upload(req, res, async (err) => { + if (err) { + console.error('Upload error:', err); + return res.status(400).json({ error: err.message }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const { processUploadedPhotos } = require('../services/photoProcessor'); + const categoryId = req.body.category_id || req.event.upload_category_id || null; + + try { + // Process uploaded photos + const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId); + + // Clean up temp files + const fs = require('fs').promises; + for (const file of req.files) { + await fs.unlink(file.path).catch(console.error); + } + + res.json({ + message: 'Photos uploaded successfully', + count: results.length, + photos: results + }); + } catch (processError) { + console.error('Photo processing error:', processError); + res.status(500).json({ error: 'Failed to process photos' }); + } + }); + } catch (error) { + console.error('Upload route error:', error); + res.status(500).json({ error: 'Failed to upload photos' }); + } +}); + module.exports = router; diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js new file mode 100644 index 0000000..0af62e5 --- /dev/null +++ b/backend/src/services/photoProcessor.js @@ -0,0 +1,110 @@ +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); +const { generateThumbnail } = require('./imageProcessor'); +const { generatePhotoFilename } = require('../utils/filenameSanitizer'); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) { + const uploadedPhotos = []; + + // Get event details + const event = await db('events').where({ id: eventId }).first(); + if (!event) { + throw new Error('Event not found'); + } + + // Process each file + for (const file of files) { + const trx = await db.transaction(); + + try { + // Get category info if provided + let category = null; + let counter = 1; + const parsedCategoryId = categoryId ? parseInt(categoryId) : null; + + if (parsedCategoryId) { + // Get category and update counter + category = await trx('photo_categories') + .where({ id: parsedCategoryId }) + .first(); + + if (category) { + counter = (category.photo_counter || 0) + 1; + await trx('photo_categories') + .where({ id: parsedCategoryId }) + .update({ photo_counter: counter }); + } + } else { + // For uncategorized photos, count existing uncategorized photos + const uncategorizedCount = await trx('photos') + .where({ event_id: eventId }) + .whereNull('category_id') + .count('id as count') + .first(); + + counter = (uncategorizedCount.count || 0) + 1; + } + + // Generate new filename + const extension = path.extname(file.originalname); + const newFilename = generatePhotoFilename( + event.event_name, + category ? category.name : 'uncategorized', + counter, + extension + ); + + // Move file to event folder + const destPath = path.join(getStoragePath(), 'events/active', event.slug); + await fs.mkdir(destPath, { recursive: true }); + + const newPath = path.join(destPath, newFilename); + await fs.rename(file.path, newPath); + + // Generate thumbnail + const thumbnailPath = await generateThumbnail(newPath); + + // Calculate relative paths + const storagePath = getStoragePath(); + const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath); + const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root + + // Add to database with uploaded_by field + const [photoId] = await trx('photos').insert({ + event_id: eventId, + filename: newFilename, + path: relativePath, + thumbnail_path: relativeThumbPath, + category_id: parsedCategoryId || null, + type: 'individual', + size_bytes: file.size, + uploaded_by: uploadedBy + }); + + // Commit transaction + await trx.commit(); + + uploadedPhotos.push({ + id: photoId, + filename: newFilename, + size: file.size, + category_id: parsedCategoryId || null, + uploaded_by: uploadedBy + }); + } catch (error) { + console.error(`Error processing file ${file.originalname}:`, error); + if (trx) await trx.rollback(); + // Continue with other files + } + } + + return uploadedPhotos; +} + +module.exports = { + processUploadedPhotos +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 8d1e9d1..e0e0f44 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react'; -import { format, formatDistanceToNow } from 'date-fns'; import { useTranslation } from 'react-i18next'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAdminAuth } from '../../contexts'; @@ -20,6 +20,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const navigate = useNavigate(); const { user, logout } = useAdminAuth(); const { t } = useTranslation(); + const { format, formatDistanceToNow } = useLocalizedDate(); const [showUserMenu, setShowUserMenu] = useState(false); const [showNotifications, setShowNotifications] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); @@ -79,7 +80,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { {/* Desktop breadcrumb or page title could go here */}

- {format(new Date(), 'EEEE, MMMM d, yyyy')} + {format(new Date(), 'PPPP')}

diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 8b63ded..946f61d 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -15,6 +15,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { settingsService } from '../../services/settings.service'; +import { VersionInfo } from './VersionInfo'; interface AdminSidebarProps { isOpen: boolean; @@ -89,8 +90,14 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose }) = })} - {/* Storage Info */} - + {/* Bottom section - sticky to bottom */} +
+ {/* Version Info */} + + + {/* Storage Info */} + +
); diff --git a/frontend/src/components/admin/VersionInfo.tsx b/frontend/src/components/admin/VersionInfo.tsx new file mode 100644 index 0000000..53619b8 --- /dev/null +++ b/frontend/src/components/admin/VersionInfo.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Info } from 'lucide-react'; +import { api } from '../../config/api'; + +// Frontend version from package.json +const FRONTEND_VERSION = '1.0.0'; + +interface SystemVersion { + backend: string; + frontend: string; + node: string; + environment: string; +} + +async function fetchSystemVersion(): Promise { + const response = await api.get('/api/admin/system/version'); + return response.data; +} + +export const VersionInfo: React.FC = () => { + const { t } = useTranslation(); + const { data: versionInfo } = useQuery({ + queryKey: ['system-version'], + queryFn: fetchSystemVersion, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); + + return ( +
+
+ + {t('admin.version')} +
+
+
Frontend: v{FRONTEND_VERSION}
+ {versionInfo && ( +
Backend: v{versionInfo.backend}
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 59d5047..a932f57 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -11,8 +11,10 @@ import { ExpirationBanner } from './ExpirationBanner'; import { CountdownTimer } from './CountdownTimer'; import { GalleryLayout } from './GalleryLayout'; import { PhotoFilterBar } from './PhotoFilterBar'; +import { UserPhotoUpload } from './UserPhotoUpload'; import { analyticsService } from '../../services/analytics.service'; import { api } from '../../config/api'; +import { Upload } from 'lucide-react'; interface GalleryViewProps { slug: string; @@ -24,6 +26,8 @@ interface GalleryViewProps { welcome_message?: string; color_theme?: string; expires_at: string; + allow_user_uploads?: boolean; + upload_category_id?: number | null; }; } @@ -35,6 +39,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date'); const [brandingSettings, setBrandingSettings] = useState(null); + const [showUploadModal, setShowUploadModal] = useState(false); const themeAppliedRef = useRef(false); // Fetch photos @@ -221,9 +226,22 @@ export const GalleryView: React.FC = ({ slug, event }) => { onDownloadAll={handleDownloadAll} isDownloading={downloadAllMutation.isPending} headerExtra={ - daysUntilExpiration <= 1 && daysUntilExpiration > 0 ? ( - - ) : null + <> + {daysUntilExpiration <= 1 && daysUntilExpiration > 0 && ( + + )} + {event.allow_user_uploads && ( + + )} + } > {/* Expiration Banner */} @@ -251,6 +269,20 @@ export const GalleryView: React.FC = ({ slug, event }) => { + + {/* Upload Modal */} + {showUploadModal && ( + { + setShowUploadModal(false); + // Refetch photos after upload + window.location.reload(); // Simple reload for now + }} + onClose={() => setShowUploadModal(false)} + /> + )} ); }; \ No newline at end of file diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx new file mode 100644 index 0000000..bfb93b9 --- /dev/null +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -0,0 +1,224 @@ +import React, { useState } from 'react'; +import { Upload, X, CheckCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Button, Card, CardContent } from '../common'; +import { api } from '../../config/api'; + +interface UserPhotoUploadProps { + eventId: number; + categoryId: number | null | undefined; + onUploadComplete: () => void; + onClose: () => void; +} + +export const UserPhotoUpload: React.FC = ({ + eventId, + categoryId, + onUploadComplete, + onClose, +}) => { + const { t } = useTranslation(); + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({}); + + const handleFileSelect = (e: React.ChangeEvent) => { + const selectedFiles = Array.from(e.target.files || []); + + // Validate file types + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + const validFiles = selectedFiles.filter(file => { + if (!allowedTypes.includes(file.type)) { + toast.error(`Invalid file type: ${file.name}`); + return false; + } + // Check file size (50MB max) + if (file.size > 50 * 1024 * 1024) { + toast.error(`File too large: ${file.name}`); + return false; + } + return true; + }); + + setFiles(prev => [...prev, ...validFiles]); + }; + + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const handleUpload = async () => { + if (files.length === 0) return; + + setUploading(true); + let successCount = 0; + let failedCount = 0; + + for (const file of files) { + const formData = new FormData(); + formData.append('photos', file); + if (categoryId) { + formData.append('category_id', categoryId.toString()); + } + + try { + await api.post(`/api/gallery/${eventId}/upload`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); + setUploadProgress(prev => ({ + ...prev, + [file.name]: progress, + })); + } + }, + }); + successCount++; + } catch (error) { + console.error(`Failed to upload ${file.name}:`, error); + failedCount++; + } + } + + setUploading(false); + + if (successCount > 0) { + toast.success(t('toast.uploadSuccess') + ` (${successCount} ${t('common.photos')})`); + onUploadComplete(); + } + + if (failedCount > 0) { + toast.error(`${failedCount} ${t('upload.someFilesFailed')}`); + } + + if (failedCount === 0) { + onClose(); + } + }; + + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( +
+ + + {/* Header */} +
+

{t('upload.uploadPhotos')}

+ +
+ + {/* Content */} +
+ {/* Upload Area */} +
+ +
+ + {/* Selected Files */} + {files.length > 0 && ( +
+

+ {t('upload.selectedFiles')} ({files.length}) +

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

+ {file.name} +

+

+ {formatBytes(file.size)} +

+
+ {uploadProgress[file.name] !== undefined ? ( +
+ {uploadProgress[file.name] === 100 ? ( + + ) : ( +
+
+
+
+
+ )} +
+ ) : ( + + )} +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + +
+ + +
+ ); +}; + +UserPhotoUpload.displayName = 'UserPhotoUpload'; \ No newline at end of file diff --git a/frontend/src/components/gallery/index.ts b/frontend/src/components/gallery/index.ts index dbd7c76..17dbce4 100644 --- a/frontend/src/components/gallery/index.ts +++ b/frontend/src/components/gallery/index.ts @@ -4,4 +4,5 @@ export { PhotoLightbox } from './PhotoLightbox'; export { ExpirationBanner } from './ExpirationBanner'; export { CountdownTimer } from './CountdownTimer'; export { GalleryLayout } from './GalleryLayout'; -export { PhotoFilterBar } from './PhotoFilterBar'; \ No newline at end of file +export { PhotoFilterBar } from './PhotoFilterBar'; +export { UserPhotoUpload } from './UserPhotoUpload'; \ No newline at end of file diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 0000000..62e44dc --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useSessionTimeout'; +export * from './useOnClickOutside'; +export * from './useLocalizedDate'; \ No newline at end of file diff --git a/frontend/src/hooks/useLocalizedDate.ts b/frontend/src/hooks/useLocalizedDate.ts new file mode 100644 index 0000000..81c8e1f --- /dev/null +++ b/frontend/src/hooks/useLocalizedDate.ts @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next'; +import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns'; +import { de, enUS } from 'date-fns/locale'; + +export const useLocalizedDate = () => { + const { i18n } = useTranslation(); + + const getLocale = () => { + return i18n.language === 'de' ? de : enUS; + }; + + const format = (date: Date | string, formatStr: string) => { + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateFnsFormat(dateObj, formatStr, { locale: getLocale() }); + }; + + const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => { + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateFnsFormatDistanceToNow(dateObj, { ...options, locale: getLocale() }); + }; + + return { + format, + formatDistanceToNow, + locale: getLocale() + }; +}; \ No newline at end of file diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 23ff8e4..eccdee5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -40,7 +40,8 @@ "uploading": "Wird hochgeladen...", "uploadComplete": "Upload abgeschlossen!", "uploadFailed": "Upload fehlgeschlagen", - "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden" + "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", + "uploadPhotos": "Fotos hochladen" }, "navigation": { "dashboard": "Dashboard", @@ -224,6 +225,13 @@ "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.", + "userUploads": "Benutzer-Upload-Einstellungen", + "allowUserUploads": "Gästen erlauben, Fotos hochzuladen", + "allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen", + "uploadCategory": "Upload-Kategorie", + "selectCategory": "Wählen Sie eine Kategorie für Benutzer-Uploads", + "uploadCategoryHelp": "Alle von Benutzern hochgeladenen Fotos werden dieser Kategorie hinzugefügt", + "userUploadWarning": "Benutzer-Uploads werden moderiert und können jederzeit von Administratoren entfernt werden.", "processingRequest": "Ihre Anfrage wird verarbeitet...", "eventTypeWedding": "Hochzeit", "eventTypeBirthday": "Geburtstag", @@ -337,6 +345,33 @@ "title": "Kategorien", "about": "Über Fotokategorien", "aboutText": "Globale Kategorien sind für alle Veranstaltungen verfügbar. Sie können auch veranstaltungsspezifische Kategorien erstellen, wenn Sie einzelne Veranstaltungen bearbeiten. Kategorien helfen beim Organisieren von Fotos und ermöglichen es Gästen, Fotos nach Typ in der Galerieansicht zu filtern." + }, + "systemStatus": { + "title": "Systemstatus", + "storageOverview": "Speicherübersicht", + "systemInfo": "Systeminformationen", + "databaseInfo": "Datenbankinformationen", + "platform": "Plattform", + "nodeVersion": "Node-Version", + "uptime": "Betriebszeit", + "cpuCores": "CPU-Kerne", + "memoryUsage": "Speichernutzung", + "memoryUsed": "Speicher verwendet", + "photos": "Fotos", + "admins": "Administratoren", + "dbSize": "Datenbankgröße", + "services": "Hintergrunddienste", + "fileWatcher": "Dateiüberwachung", + "fileWatcherDesc": "Überwacht neue Fotos", + "expirationChecker": "Ablaufprüfung", + "expirationCheckerDesc": "Archiviert abgelaufene Galerien", + "emailProcessor": "E-Mail-Prozessor", + "emailProcessorDesc": "Sendet E-Mails aus der Warteschlange", + "emailQueue": "E-Mail-Warteschlangenstatus", + "pending": "Ausstehend", + "sent": "Gesendet", + "failed": "Fehlgeschlagen", + "lastUpdate": "Letzte Aktualisierung" } }, "branding": { @@ -408,6 +443,7 @@ "storageUsed": "Speicher verwendet", "totalPhotos": "Gesamte Fotos", "storagePercent": "{{percent}}% von {{limit}}", + "version": "Version", "notifications": "Benachrichtigungen", "viewAllNotifications": "Alle Benachrichtigungen anzeigen", "noNotifications": "Keine neuen Benachrichtigungen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ae015d6..f87e56f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -40,7 +40,8 @@ "uploading": "Uploading...", "uploadComplete": "Upload complete!", "uploadFailed": "Upload failed", - "someFilesFailed": "Some files failed to upload" + "someFilesFailed": "Some files failed to upload", + "uploadPhotos": "Upload Photos" }, "navigation": { "dashboard": "Dashboard", @@ -227,6 +228,13 @@ "galleryExpiresIn": "Gallery Expires In", "galleryWillExpireOn": "Gallery will expire on {{date}}", "expirationWarning": "Guests will receive a warning email 7 days before expiration.", + "userUploads": "User Upload Settings", + "allowUserUploads": "Allow guests to upload photos", + "allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery", + "uploadCategory": "Upload Category", + "selectCategory": "Select a category for user uploads", + "uploadCategoryHelp": "All user-uploaded photos will be added to this category", + "userUploadWarning": "User uploads will be moderated and can be removed by admins at any time.", "processingRequest": "Processing your request...", "eventTypeWedding": "Wedding", "eventTypeBirthday": "Birthday", @@ -362,6 +370,33 @@ "title": "Categories", "about": "About Photo Categories", "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." + }, + "systemStatus": { + "title": "System Status", + "storageOverview": "Storage Overview", + "systemInfo": "System Information", + "databaseInfo": "Database Information", + "platform": "Platform", + "nodeVersion": "Node Version", + "uptime": "Uptime", + "cpuCores": "CPU Cores", + "memoryUsage": "Memory Usage", + "memoryUsed": "Memory Used", + "photos": "Photos", + "admins": "Admins", + "dbSize": "Database Size", + "services": "Background Services", + "fileWatcher": "File Watcher", + "fileWatcherDesc": "Monitors for new photos", + "expirationChecker": "Expiration Checker", + "expirationCheckerDesc": "Archives expired galleries", + "emailProcessor": "Email Processor", + "emailProcessorDesc": "Sends queued emails", + "emailQueue": "Email Queue Status", + "pending": "Pending", + "sent": "Sent", + "failed": "Failed", + "lastUpdate": "Last update" } }, "analytics": { @@ -467,6 +502,7 @@ "storageUsed": "Storage Used", "totalPhotos": "Total Photos", "storagePercent": "{{percent}}% of {{limit}}", + "version": "Version", "notifications": "Notifications", "viewAllNotifications": "View all notifications", "noNotifications": "No new notifications", diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 3c8d4ee..b40433f 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -2,10 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { Calendar, - Users, - Archive, AlertTriangle, - TrendingUp, Download, Eye, Clock, @@ -13,8 +10,9 @@ import { HardDrive, Image } from 'lucide-react'; -import { format, differenceInDays, parseISO, formatDistanceToNow } from 'date-fns'; +import { differenceInDays, parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Card, Loading } from '../../components/common'; import { useQuery } from '@tanstack/react-query'; @@ -32,6 +30,7 @@ interface StatCard { export const AdminDashboard: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const { format, formatDistanceToNow } = useLocalizedDate(); // Fetch dashboard statistics const { data: dashboardStats, isLoading: statsLoading } = useQuery({ @@ -187,7 +186,7 @@ export const AdminDashboard: React.FC = () => {

{event.event_name}

- {format(parseISO(event.event_date), 'MMM d, yyyy')} + {format(parseISO(event.event_date), 'PP')}

@@ -195,7 +194,7 @@ export const AdminDashboard: React.FC = () => { {t('admin.daysLeft', { count: daysLeft })}

- {t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')} + {t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}

@@ -273,44 +272,6 @@ export const AdminDashboard: React.FC = () => { - {/* Quick Actions */} - -

{t('admin.quickActions')}

-
- - - - -
-
); }; diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 7b1116d..5419de4 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -6,14 +6,17 @@ import { Lock, Clock, ArrowLeft, - Info + Info, + Upload } from 'lucide-react'; import { format, addDays } from 'date-fns'; import { toast } from 'react-toastify'; import { Button, Input, Card } from '../../components/common'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; +import { categoriesService } from '../../services/categories.service'; +import { useTranslation } from 'react-i18next'; interface FormData { event_type: string; @@ -26,6 +29,8 @@ interface FormData { welcome_message: string; color_theme: string; expires_in_days: number; + allow_user_uploads: boolean; + upload_category_id: number | null; } const EVENT_TYPES = [ @@ -100,6 +105,7 @@ const COLOR_THEMES = [ export const CreateEventPage: React.FC = () => { const navigate = useNavigate(); + const { t } = useTranslation(); const isMountedRef = useRef(true); useEffect(() => { @@ -119,11 +125,19 @@ export const CreateEventPage: React.FC = () => { welcome_message: '', color_theme: 'default', expires_in_days: 30, + allow_user_uploads: false, + upload_category_id: null, }); const [errors, setErrors] = useState>>({}); const [showPassword, setShowPassword] = useState(false); + // Fetch categories for user upload selection + const { data: categories } = useQuery({ + queryKey: ['categories', 'global'], + queryFn: () => categoriesService.getGlobalCategories() + }); + const createMutation = useMutation({ mutationFn: eventsService.createEvent, onSuccess: (data) => { @@ -215,6 +229,8 @@ export const CreateEventPage: React.FC = () => { welcome_message: formData.welcome_message || '', color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined, expiration_days: formData.expires_in_days, + allow_user_uploads: formData.allow_user_uploads, + upload_category_id: formData.upload_category_id, }); }; @@ -482,6 +498,66 @@ export const CreateEventPage: React.FC = () => { + {/* User Upload Settings */} + +

{t('events.userUploads')}

+ +
+ {/* Allow User Uploads */} +
+ +

+ {t('events.allowUserUploadsHelp')} +

+
+ + {/* Upload Category Selection */} + {formData.allow_user_uploads && ( +
+ + +

+ {t('events.uploadCategoryHelp')} +

+
+ )} + + {formData.allow_user_uploads && ( +
+ +
+

{t('events.userUploadWarning')}

+
+
+ )} +
+
+ {/* Submit Buttons */}
+ +
+ +

+ {t('events.allowUserUploadsHelp')} +

+
+ + {editForm.allow_user_uploads && ( +
+ + +

+ {t('events.uploadCategoryHelp')} +

+
+ )} ) : (
@@ -410,6 +457,28 @@ export const EventDetailsPage: React.FC = () => { + +
+
{t('events.userUploads')}
+
+ {event.allow_user_uploads ? ( +
+ + {t('common.yes')} + + {event.upload_category_id && ( +

+ {t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'} +

+ )} +
+ ) : ( + + {t('common.no')} + + )} +
+
)} diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 7f3cf5a..336d274 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -5,7 +5,12 @@ import { Globe, Key, AlertCircle, - Image + Image, + Server, + CheckCircle, + Clock, + HardDrive, + Activity } from 'lucide-react'; import { toast } from 'react-toastify'; @@ -16,7 +21,7 @@ import { settingsService } from '../../services/settings.service'; import { useTranslation } from 'react-i18next'; export const SettingsPage: React.FC = () => { - const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories'>('general'); const queryClient = useQueryClient(); const { t, i18n } = useTranslation(); @@ -30,7 +35,15 @@ export const SettingsPage: React.FC = () => { const { data: storageInfo } = useQuery({ queryKey: ['admin-storage-info'], queryFn: () => settingsService.getStorageInfo(), - enabled: activeTab === 'storage' + enabled: activeTab === 'status' + }); + + // Fetch system status + const { data: systemStatus } = useQuery({ + queryKey: ['system-status'], + queryFn: () => settingsService.getSystemStatus(), + enabled: activeTab === 'status', + refetchInterval: 30000 // Refresh every 30 seconds }); // General settings state @@ -158,14 +171,14 @@ export const SettingsPage: React.FC = () => { {t('settings.general.title')}