Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 141acd5736 | |||
| 5f4337a18d | |||
| fec7b687f7 | |||
| cfa29ad5cb |
@@ -1,5 +1,8 @@
|
|||||||
FROM node:18-alpine AS builder
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
# Add build argument for cache busting
|
||||||
|
ARG CACHEBUST=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Fix email_queue table by ensuring it doesn't have updated_at column
|
||||||
|
* This migration addresses the PostgreSQL error where queries are trying to update
|
||||||
|
* a non-existent updated_at column
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// First, check if the column exists
|
||||||
|
const hasUpdatedAt = await knex.schema.hasColumn('email_queue', 'updated_at');
|
||||||
|
|
||||||
|
if (hasUpdatedAt) {
|
||||||
|
console.log('Found updated_at column in email_queue table, removing it...');
|
||||||
|
await knex.schema.table('email_queue', (table) => {
|
||||||
|
table.dropColumn('updated_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also ensure the table has all required columns
|
||||||
|
const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at');
|
||||||
|
if (!hasCreatedAt) {
|
||||||
|
console.log('Adding missing created_at column to email_queue table...');
|
||||||
|
await knex.schema.table('email_queue', (table) => {
|
||||||
|
table.datetime('created_at').defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('email_queue table schema fixed');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// In the down migration, we don't add back updated_at since it shouldn't exist
|
||||||
|
// This is intentionally left minimal
|
||||||
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.46",
|
"version": "1.0.48",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.46",
|
"version": "1.0.48",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.46",
|
"version": "1.0.48",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to diagnose and fix email_queue schema issues
|
||||||
|
* This helps resolve the "column updated_at does not exist" error
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
async function checkAndFixEmailQueueSchema() {
|
||||||
|
console.log('Checking email_queue table schema...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get column information
|
||||||
|
const columns = await db('email_queue').columnInfo();
|
||||||
|
console.log('\nCurrent email_queue columns:', Object.keys(columns));
|
||||||
|
|
||||||
|
// Check for updated_at column
|
||||||
|
if (columns.updated_at) {
|
||||||
|
console.log('\n⚠️ Found unexpected updated_at column in email_queue table!');
|
||||||
|
console.log('This column should not exist and is causing errors.');
|
||||||
|
|
||||||
|
// Ask for confirmation before removing
|
||||||
|
console.log('\nRemoving updated_at column...');
|
||||||
|
await db.schema.table('email_queue', (table) => {
|
||||||
|
table.dropColumn('updated_at');
|
||||||
|
});
|
||||||
|
console.log('✅ Removed updated_at column from email_queue table');
|
||||||
|
} else {
|
||||||
|
console.log('✅ No updated_at column found (this is correct)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify required columns exist
|
||||||
|
const requiredColumns = [
|
||||||
|
'id', 'event_id', 'recipient_email', 'email_type',
|
||||||
|
'email_data', 'status', 'scheduled_at', 'sent_at',
|
||||||
|
'error_message', 'retry_count', 'created_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingColumns = requiredColumns.filter(col => !columns[col]);
|
||||||
|
if (missingColumns.length > 0) {
|
||||||
|
console.log('\n⚠️ Missing required columns:', missingColumns);
|
||||||
|
} else {
|
||||||
|
console.log('✅ All required columns are present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any database triggers
|
||||||
|
if (process.env.DATABASE_CLIENT === 'pg') {
|
||||||
|
console.log('\nChecking for PostgreSQL triggers on email_queue...');
|
||||||
|
const triggers = await db.raw(`
|
||||||
|
SELECT trigger_name, event_manipulation, action_statement
|
||||||
|
FROM information_schema.triggers
|
||||||
|
WHERE event_object_table = 'email_queue'
|
||||||
|
AND trigger_schema = current_schema()
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (triggers.rows && triggers.rows.length > 0) {
|
||||||
|
console.log('⚠️ Found triggers on email_queue table:');
|
||||||
|
triggers.rows.forEach(trigger => {
|
||||||
|
console.log(` - ${trigger.trigger_name} (${trigger.event_manipulation})`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('✅ No triggers found on email_queue table');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test update query
|
||||||
|
console.log('\nTesting update query...');
|
||||||
|
const testEmail = await db('email_queue')
|
||||||
|
.where('status', 'pending')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (testEmail) {
|
||||||
|
try {
|
||||||
|
await db('email_queue')
|
||||||
|
.where('id', testEmail.id)
|
||||||
|
.update({
|
||||||
|
retry_count: testEmail.retry_count
|
||||||
|
});
|
||||||
|
console.log('✅ Update query works correctly');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ Update query failed:', error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ No pending emails to test with');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nSchema check complete!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking schema:', error);
|
||||||
|
} finally {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the check
|
||||||
|
checkAndFixEmailQueueSchema();
|
||||||
@@ -520,11 +520,15 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
let archiveStorage = 0;
|
let archiveStorage = 0;
|
||||||
for (const archive of archives) {
|
for (const archive of archives) {
|
||||||
try {
|
if (archive.archive_path) {
|
||||||
const stats = await fs.stat(archive.archive_path);
|
try {
|
||||||
archiveStorage += stats.size;
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
} catch (error) {
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||||
console.error('Archive file not found:', archive.archive_path);
|
const stats = await fs.stat(fullArchivePath);
|
||||||
|
archiveStorage += stats.size;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Archive file not found:', archive.archive_path, error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get system version
|
// Get system version
|
||||||
@@ -75,6 +76,32 @@ router.get('/status', adminAuth, async (req, res) => {
|
|||||||
// Activity logs count
|
// Activity logs count
|
||||||
const [activityCount] = await db('activity_logs').count('* as count');
|
const [activityCount] = await db('activity_logs').count('* as count');
|
||||||
|
|
||||||
|
// Storage info
|
||||||
|
const [{ totalPhotoStorage }] = await db('photos')
|
||||||
|
.sum('size_bytes as totalPhotoStorage');
|
||||||
|
|
||||||
|
const archives = await db('events')
|
||||||
|
.where('is_archived', formatBoolean(true))
|
||||||
|
.whereNotNull('archive_path')
|
||||||
|
.select('archive_path');
|
||||||
|
|
||||||
|
let archiveStorage = 0;
|
||||||
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
for (const archive of archives) {
|
||||||
|
if (archive.archive_path) {
|
||||||
|
try {
|
||||||
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||||
|
const stats = await fs.stat(fullArchivePath);
|
||||||
|
archiveStorage += stats.size;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Archive file not found:', archive.archive_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStorage = (parseInt(totalPhotoStorage) || 0) + archiveStorage;
|
||||||
|
|
||||||
// System info
|
// System info
|
||||||
const systemInfo = {
|
const systemInfo = {
|
||||||
platform: os.platform(),
|
platform: os.platform(),
|
||||||
@@ -105,6 +132,11 @@ router.get('/status', adminAuth, async (req, res) => {
|
|||||||
activityLogs: activityCount.count
|
activityLogs: activityCount.count
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
storage: {
|
||||||
|
totalUsed: totalStorage,
|
||||||
|
photoStorage: parseInt(totalPhotoStorage) || 0,
|
||||||
|
archiveStorage: archiveStorage
|
||||||
|
},
|
||||||
emailQueue: {
|
emailQueue: {
|
||||||
pending: pendingEmails.count,
|
pending: pendingEmails.count,
|
||||||
sent: sentEmails.count,
|
sent: sentEmails.count,
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ const { db } = require('../database/db');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
let transporter = null;
|
let transporter = null;
|
||||||
|
let lastConfigHash = null;
|
||||||
|
|
||||||
|
// Generate hash from config for change detection
|
||||||
|
function generateConfigHash(config) {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
|
||||||
|
return crypto.createHash('md5').update(configString).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize transporter from database config
|
// Initialize transporter from database config
|
||||||
async function initializeTransporter() {
|
async function initializeTransporter(forceReinit = false) {
|
||||||
try {
|
try {
|
||||||
const config = await db('email_configs').first();
|
const config = await db('email_configs').first();
|
||||||
|
|
||||||
@@ -14,6 +22,16 @@ async function initializeTransporter() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if configuration has changed
|
||||||
|
const currentConfigHash = generateConfigHash(config);
|
||||||
|
if (!forceReinit && transporter && currentConfigHash === lastConfigHash) {
|
||||||
|
// Configuration hasn't changed, return existing transporter
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration has changed or first initialization
|
||||||
|
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
|
||||||
|
|
||||||
transporter = nodemailer.createTransport({
|
transporter = nodemailer.createTransport({
|
||||||
host: config.smtp_host,
|
host: config.smtp_host,
|
||||||
port: config.smtp_port,
|
port: config.smtp_port,
|
||||||
@@ -28,9 +46,14 @@ async function initializeTransporter() {
|
|||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
logger.info('Email transporter initialized successfully');
|
logger.info('Email transporter initialized successfully');
|
||||||
|
|
||||||
|
// Update the config hash
|
||||||
|
lastConfigHash = currentConfigHash;
|
||||||
|
|
||||||
return transporter;
|
return transporter;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to initialize email transporter:', error);
|
logger.error('Failed to initialize email transporter:', error);
|
||||||
|
transporter = null;
|
||||||
|
lastConfigHash = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,11 +279,10 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
// Send email using template
|
// Send email using template
|
||||||
async function sendTemplateEmail(to, templateKey, variables) {
|
async function sendTemplateEmail(to, templateKey, variables) {
|
||||||
try {
|
try {
|
||||||
|
// Always check for configuration changes before sending
|
||||||
|
transporter = await initializeTransporter();
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
transporter = await initializeTransporter();
|
throw new Error('Email service not configured');
|
||||||
if (!transporter) {
|
|
||||||
throw new Error('Email service not configured');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get email template
|
// Get email template
|
||||||
@@ -304,6 +326,16 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
|||||||
// Process email queue
|
// Process email queue
|
||||||
async function processEmailQueue() {
|
async function processEmailQueue() {
|
||||||
try {
|
try {
|
||||||
|
// Try to initialize transporter if it's null (in case it failed at startup)
|
||||||
|
if (!transporter) {
|
||||||
|
logger.info('Transporter not initialized, attempting to initialize...');
|
||||||
|
transporter = await initializeTransporter();
|
||||||
|
if (!transporter) {
|
||||||
|
logger.warn('Email transporter could not be initialized, skipping queue processing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pendingEmails = await db('email_queue')
|
const pendingEmails = await db('email_queue')
|
||||||
.where('status', 'pending')
|
.where('status', 'pending')
|
||||||
.where('retry_count', '<', 3)
|
.where('retry_count', '<', 3)
|
||||||
@@ -337,13 +369,24 @@ async function processEmailQueue() {
|
|||||||
logger.info(`Email ${email.id} sent successfully`);
|
logger.info(`Email ${email.id} sent successfully`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Increment retry count
|
// Increment retry count
|
||||||
await db('email_queue')
|
try {
|
||||||
.where('id', email.id)
|
await db('email_queue')
|
||||||
.update({
|
.where('id', email.id)
|
||||||
retry_count: email.retry_count + 1,
|
.update({
|
||||||
error_message: error.message,
|
retry_count: email.retry_count + 1,
|
||||||
updated_at: new Date()
|
error_message: error.message
|
||||||
});
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
logger.error(`Failed to update email retry count for ${email.id}:`, updateError);
|
||||||
|
// If update fails due to column issue, try without any potential auto-added fields
|
||||||
|
if (updateError.message && updateError.message.includes('updated_at')) {
|
||||||
|
logger.warn('Detected updated_at column issue, attempting raw query...');
|
||||||
|
await db.raw(
|
||||||
|
'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?',
|
||||||
|
[email.retry_count + 1, error.message, email.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.error(`Failed to send email ${email.id}:`, error);
|
logger.error(`Failed to send email ${email.id}:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.45",
|
"version": "1.0.48",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.45",
|
"version": "1.0.48",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-link": "^2.25.0",
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.45",
|
"version": "1.0.48",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
import packageJson from '../../../package.json';
|
||||||
|
|
||||||
// Frontend version from package.json
|
// Frontend version from package.json
|
||||||
const FRONTEND_VERSION = '1.0.0';
|
const FRONTEND_VERSION = packageJson.version;
|
||||||
|
|
||||||
interface SystemVersion {
|
interface SystemVersion {
|
||||||
backend: string;
|
backend: string;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{rightIcon && (
|
{rightIcon && (
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||||
<span className="text-neutral-500">{rightIcon}</span>
|
<span className="text-neutral-500">{rightIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||||
"securityAccess": "Sicherheit & Zugriff",
|
"securityAccess": "Sicherheit & Zugriff",
|
||||||
"galleryPassword": "Galerie-Passwort",
|
"galleryPassword": "Galerie-Passwort",
|
||||||
|
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
||||||
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
||||||
"confirmPassword": "Passwort bestätigen",
|
"confirmPassword": "Passwort bestätigen",
|
||||||
"showPasswords": "Passwörter anzeigen",
|
"showPasswords": "Passwörter anzeigen",
|
||||||
@@ -363,7 +364,13 @@
|
|||||||
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
||||||
"bulkArchive": "Archivieren",
|
"bulkArchive": "Archivieren",
|
||||||
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
|
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
|
||||||
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich."
|
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
|
||||||
|
"stats": {
|
||||||
|
"totalEvents": "Gesamtveranstaltungen",
|
||||||
|
"activeEvents": "Aktive Veranstaltungen",
|
||||||
|
"totalPhotos": "Gesamtfotos",
|
||||||
|
"expiringEvents": "Bald ablaufend"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Systemeinstellungen",
|
"title": "Systemeinstellungen",
|
||||||
|
|||||||
@@ -282,6 +282,7 @@
|
|||||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||||
"securityAccess": "Security & Access",
|
"securityAccess": "Security & Access",
|
||||||
"galleryPassword": "Gallery Password",
|
"galleryPassword": "Gallery Password",
|
||||||
|
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"showPasswords": "Show passwords",
|
"showPasswords": "Show passwords",
|
||||||
"gallerySettings": "Gallery Settings",
|
"gallerySettings": "Gallery Settings",
|
||||||
@@ -340,6 +341,12 @@
|
|||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"noEventsFound": "No events found",
|
"noEventsFound": "No events found",
|
||||||
|
"stats": {
|
||||||
|
"totalEvents": "Total Events",
|
||||||
|
"activeEvents": "Active Events",
|
||||||
|
"totalPhotos": "Total Photos",
|
||||||
|
"expiringEvents": "Expiring Soon"
|
||||||
|
},
|
||||||
"viewDetails": "View Details",
|
"viewDetails": "View Details",
|
||||||
"archiveEventAction": "Archive Event",
|
"archiveEventAction": "Archive Event",
|
||||||
"downloadArchiveAction": "Download Archive",
|
"downloadArchiveAction": "Download Archive",
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('archives.totalPhotos')}</p>
|
<p className="text-sm text-neutral-600">{t('archives.totalPhotos')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
{archives.reduce((sum, a) => sum + a.photoCount, 0).toLocaleString()}
|
{(() => {
|
||||||
|
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
|
||||||
|
return total === 0 ? '0' : total.toLocaleString();
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FileArchive className="w-8 h-8 text-green-600" />
|
<FileArchive className="w-8 h-8 text-green-600" />
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Edit,
|
Edit,
|
||||||
Download,
|
Download,
|
||||||
Trash2
|
Trash2,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
Image,
|
||||||
|
Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays } from 'date-fns';
|
import { parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -232,6 +236,59 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">{t('events.stats.totalEvents')}</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">{data?.events.length || 0}</p>
|
||||||
|
</div>
|
||||||
|
<Calendar className="w-8 h-8 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">{t('events.stats.activeEvents')}</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Activity className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">{t('events.stats.totalPhotos')}</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Image className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">{t('events.stats.expiringEvents')}</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{data?.events.filter(e => {
|
||||||
|
if (!e.is_active || e.is_archived) return false;
|
||||||
|
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
|
||||||
|
return days <= 7 && days > 0;
|
||||||
|
}).length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AlertTriangle className="w-8 h-8 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Filters and Search */}
|
||||||
<Card padding="sm" className="mb-6">
|
<Card padding="sm" className="mb-6">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export interface SystemStatus {
|
|||||||
activityLogs: number;
|
activityLogs: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
storage: {
|
||||||
|
totalUsed: number;
|
||||||
|
photoStorage: number;
|
||||||
|
archiveStorage: number;
|
||||||
|
};
|
||||||
emailQueue: {
|
emailQueue: {
|
||||||
pending: number;
|
pending: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"verbatimModuleSyntax": false,
|
"verbatimModuleSyntax": false,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user