diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index d5e4d44..f5bd38e 100644 Binary files a/backend/data/photo_sharing.db and b/backend/data/photo_sharing.db differ diff --git a/backend/database.db b/backend/database.db new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/004_add_categories_and_cms.js b/backend/migrations/004_add_categories_and_cms.js new file mode 100644 index 0000000..e77a8d5 --- /dev/null +++ b/backend/migrations/004_add_categories_and_cms.js @@ -0,0 +1,101 @@ +const { db } = require('../src/database/db'); + +async function up() { + console.log('Adding photo categories and CMS tables...'); + + // Create photo_categories table + await db.schema.createTable('photo_categories', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + table.string('slug', 100).notNullable(); + table.boolean('is_global').defaultTo(true); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(db.fn.now()); + + // Unique constraint for slug within event scope + table.unique(['slug', 'event_id']); + }); + + // Create cms_pages table + await db.schema.createTable('cms_pages', (table) => { + table.increments('id').primary(); + table.string('slug', 100).unique().notNullable(); + table.text('title_en'); + table.text('title_de'); + table.text('content_en'); + table.text('content_de'); + table.timestamp('updated_at').defaultTo(db.fn.now()); + }); + + // Add category_id to photos table + await db.schema.alterTable('photos', (table) => { + table.integer('category_id').references('id').inTable('photo_categories'); + }); + + // Add language preference to admin_users + await db.schema.alterTable('admin_users', (table) => { + table.string('language', 2).defaultTo('en'); + }); + + // Add language preference to app_settings for global default + await db('app_settings').insert({ + setting_key: 'default_language', + setting_value: 'en', + setting_type: 'general', + updated_at: new Date() + }); + + // Insert default global categories + const defaultCategories = [ + { name: 'Ceremony', slug: 'ceremony', is_global: true }, + { name: 'Reception', slug: 'reception', is_global: true }, + { name: 'Portraits', slug: 'portraits', is_global: true }, + { name: 'Group Photos', slug: 'group-photos', is_global: true }, + { name: 'Details', slug: 'details', is_global: true }, + { name: 'Party', slug: 'party', is_global: true } + ]; + + await db('photo_categories').insert(defaultCategories); + + // Insert default legal pages + await db('cms_pages').insert([ + { + slug: 'impressum', + title_en: 'Legal Notice', + title_de: 'Impressum', + content_en: '

Legal Notice

Please edit this content in the admin panel.

', + content_de: '

Impressum

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date() + }, + { + slug: 'datenschutz', + title_en: 'Privacy Policy', + title_de: 'Datenschutzerklärung', + content_en: '

Privacy Policy

Please edit this content in the admin panel.

', + content_de: '

Datenschutzerklärung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date() + } + ]); + + console.log('Photo categories and CMS tables created successfully'); +} + +async function down() { + // Remove language from app_settings + await db('app_settings').where('setting_key', 'default_language').delete(); + + // Drop columns + await db.schema.alterTable('admin_users', (table) => { + table.dropColumn('language'); + }); + + await db.schema.alterTable('photos', (table) => { + table.dropColumn('category_id'); + }); + + // Drop tables + await db.schema.dropTableIfExists('cms_pages'); + await db.schema.dropTableIfExists('photo_categories'); +} + +module.exports = { up, down }; \ No newline at end of file diff --git a/backend/migrations/006_add_photo_counter_to_categories.js b/backend/migrations/006_add_photo_counter_to_categories.js new file mode 100644 index 0000000..6f378aa --- /dev/null +++ b/backend/migrations/006_add_photo_counter_to_categories.js @@ -0,0 +1,28 @@ +exports.up = async function(knex) { + // Add photo_counter column to photo_categories table + await knex.schema.alterTable('photo_categories', function(table) { + table.integer('photo_counter').defaultTo(0).notNullable(); + }); + + // Initialize counters based on existing photos + const categories = await knex('photo_categories').select('id'); + + for (const category of categories) { + const photoCount = await knex('photos') + .where('category_id', category.id) + .count('id as count') + .first(); + + if (photoCount && photoCount.count > 0) { + await knex('photo_categories') + .where('id', category.id) + .update({ photo_counter: photoCount.count }); + } + } +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('photo_categories', function(table) { + table.dropColumn('photo_counter'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/run-migrations.js b/backend/migrations/run-migrations.js new file mode 100644 index 0000000..a7e0075 --- /dev/null +++ b/backend/migrations/run-migrations.js @@ -0,0 +1,91 @@ +const fs = require('fs').promises; +const path = require('path'); +const { db } = require('../src/database/db'); + +// Create migrations table if it doesn't exist +async function createMigrationsTable() { + const tableExists = await db.schema.hasTable('migrations'); + if (!tableExists) { + await db.schema.createTable('migrations', (table) => { + table.increments('id').primary(); + table.string('filename').unique().notNullable(); + table.timestamp('applied_at').defaultTo(db.fn.now()); + }); + console.log('Created migrations table'); + } +} + +// Get list of applied migrations +async function getAppliedMigrations() { + const migrations = await db('migrations').select('filename'); + return migrations.map(m => m.filename); +} + +// Run a single migration +async function runMigration(filename) { + const migrationPath = path.join(__dirname, filename); + const migration = require(migrationPath); + + if (migration.up) { + console.log(`Running migration: ${filename}`); + await migration.up(); + await db('migrations').insert({ filename }); + console.log(`Migration ${filename} completed`); + } +} + +// Main migration runner +async function runMigrations() { + try { + console.log('Starting database migrations...'); + + // First run the init.js if it exists + const initPath = path.join(__dirname, 'init.js'); + if (require.resolve(initPath)) { + console.log('Running initial setup...'); + require(initPath); + // Wait a bit for init to complete + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // Create migrations table + await createMigrationsTable(); + + // Get all migration files + const files = await fs.readdir(__dirname); + const migrationFiles = files + .filter(f => f.match(/^\d{3}_.*\.js$/)) + .sort(); + + // Get applied migrations + const appliedMigrations = await getAppliedMigrations(); + + // Run pending migrations + let pendingCount = 0; + for (const file of migrationFiles) { + if (!appliedMigrations.includes(file)) { + await runMigration(file); + pendingCount++; + } + } + + if (pendingCount === 0) { + console.log('No pending migrations'); + } else { + console.log(`Applied ${pendingCount} migration(s)`); + } + + console.log('All migrations completed successfully'); + process.exit(0); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } +} + +// Only run if called directly +if (require.main === module) { + runMigrations(); +} + +module.exports = { runMigrations }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index bdcb58f..71f38cb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,14 +17,19 @@ "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", "helmet": "^7.0.0", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "joi": "^17.9.1", "jsonwebtoken": "^9.0.0", "knex": "^2.4.2", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.2", "nodemailer": "^6.9.1", + "react-i18next": "^15.6.0", "sharp": "^0.32.0", "sqlite3": "^5.1.6", + "uuid": "^11.1.0", "winston": "^3.8.2" }, "devDependencies": { @@ -509,6 +514,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -2777,6 +2791,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4034,6 +4057,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -4105,6 +4137,55 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", + "integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5926,6 +6007,15 @@ "node": ">=6.0.0" } }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6762,6 +6852,42 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-i18next": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", + "integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8014,12 +8140,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -8055,6 +8185,15 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index de99fd4..db5514e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,34 +6,39 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", - "migrate": "node migrations/init.js", + "migrate": "node migrations/run-migrations.js", "test": "jest", "lint": "eslint src/" }, "dependencies": { - "express": "^4.18.2", - "express-rate-limit": "^6.7.0", - "helmet": "^7.0.0", + "archiver": "^5.3.1", + "bcrypt": "^5.1.0", + "chokidar": "^3.5.3", "cors": "^2.8.5", "dotenv": "^16.0.3", - "bcrypt": "^5.1.0", + "express": "^4.18.2", + "express-rate-limit": "^6.7.0", + "express-validator": "^7.0.1", + "helmet": "^7.0.0", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", + "joi": "^17.9.1", "jsonwebtoken": "^9.0.0", + "knex": "^2.4.2", "multer": "^1.4.5-lts.1", - "sharp": "^0.32.0", - "chokidar": "^3.5.3", "node-cron": "^3.0.2", "nodemailer": "^6.9.1", - "archiver": "^5.3.1", + "react-i18next": "^15.6.0", + "sharp": "^0.32.0", "sqlite3": "^5.1.6", - "knex": "^2.4.2", - "joi": "^17.9.1", - "winston": "^3.8.2", - "express-validator": "^7.0.1" + "uuid": "^11.1.0", + "winston": "^3.8.2" }, "devDependencies": { - "nodemon": "^2.0.22", + "eslint": "^8.40.0", "jest": "^29.5.0", - "supertest": "^6.3.3", - "eslint": "^8.40.0" + "nodemon": "^2.0.22", + "supertest": "^6.3.3" } } diff --git a/backend/scripts/create-test-event.js b/backend/scripts/create-test-event.js new file mode 100644 index 0000000..df022eb --- /dev/null +++ b/backend/scripts/create-test-event.js @@ -0,0 +1,57 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../.env') }); +const { db } = require('../src/database/db'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { v4: uuidv4 } = require('uuid'); + +async function createTestEvent() { + try { + console.log('Creating test event...'); + + // Hash a simple password + const passwordHash = await bcrypt.hash('test123', 10); + + // Generate share token + const shareToken = uuidv4().replace(/-/g, ''); + const shareLink = `http://localhost:3005/gallery/wedding-test123-2025-07-07/${shareToken}`; + + // Create event + const eventData = { + slug: 'wedding-test123-2025-07-07', + event_type: 'wedding', + event_name: 'Test Wedding', + event_date: '2025-07-07', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: passwordHash, + welcome_message: 'Welcome to our test wedding gallery!', + color_theme: null, // Use global theme + is_active: 1, + expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + share_link: shareLink + }; + + // Delete existing event if it exists + await db('events').where('slug', eventData.slug).delete(); + + // Insert new event + const [eventId] = await db('events').insert(eventData); + console.log('Event created with ID:', eventId); + + console.log('\nTest event created successfully!'); + console.log('Event details:'); + console.log('- Name:', eventData.event_name); + console.log('- Slug:', eventData.slug); + console.log('- Password:', 'test123'); + console.log('- Share link:', shareLink); + console.log('\nYou can now access the gallery at the share link above'); + + process.exit(0); + } catch (error) { + console.error('Error creating test event:', error); + process.exit(1); + } +} + +createTestEvent(); \ No newline at end of file diff --git a/backend/scripts/fix-default-themes.js b/backend/scripts/fix-default-themes.js new file mode 100644 index 0000000..8afd8eb --- /dev/null +++ b/backend/scripts/fix-default-themes.js @@ -0,0 +1,37 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../.env') }); +const { db } = require('../src/database/db'); + +async function fixDefaultThemes() { + try { + console.log('Fixing events with "default" theme...'); + + // Find all events with "default" as color_theme + const eventsToFix = await db('events') + .where('color_theme', 'default') + .select('id', 'event_name'); + + console.log(`Found ${eventsToFix.length} events to fix`); + + if (eventsToFix.length > 0) { + // Update them to null so they use the global theme + await db('events') + .where('color_theme', 'default') + .update({ color_theme: null }); + + console.log('Updated events to use global theme'); + + eventsToFix.forEach(event => { + console.log(`- Fixed event: ${event.event_name} (ID: ${event.id})`); + }); + } + + console.log('Theme fix completed successfully'); + process.exit(0); + } catch (error) { + console.error('Error fixing themes:', error); + process.exit(1); + } +} + +fixDefaultThemes(); \ No newline at end of file diff --git a/backend/scripts/seed-categories.js b/backend/scripts/seed-categories.js new file mode 100644 index 0000000..92f1b91 --- /dev/null +++ b/backend/scripts/seed-categories.js @@ -0,0 +1,47 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../.env') }); +const { db } = require('../src/database/db'); + +async function seedCategories() { + try { + console.log('Seeding default categories...'); + + const defaultCategories = [ + { name: 'Portraits', slug: 'portraits' }, + { name: 'Group Photos', slug: 'group-photos' }, + { name: 'Ceremony', slug: 'ceremony' }, + { name: 'Reception', slug: 'reception' }, + { name: 'Dancing', slug: 'dancing' }, + { name: 'Candids', slug: 'candids' }, + { name: 'Details', slug: 'details' }, + { name: 'Getting Ready', slug: 'getting-ready' } + ]; + + for (const category of defaultCategories) { + // Check if category already exists + const existing = await db('photo_categories') + .where({ slug: category.slug, is_global: true }) + .first(); + + if (!existing) { + await db('photo_categories').insert({ + name: category.name, + slug: category.slug, + is_global: true, + event_id: null + }); + console.log(`Created category: ${category.name}`); + } else { + console.log(`Category already exists: ${category.name}`); + } + } + + console.log('Default categories seeded successfully'); + process.exit(0); + } catch (error) { + console.error('Error seeding categories:', error); + process.exit(1); + } +} + +seedCategories(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 3d35699..127cdba 100644 --- a/backend/server.js +++ b/backend/server.js @@ -79,8 +79,22 @@ app.use('/api/auth', authLimiter); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +// Middleware to set CORS headers for static files +const setCorsHeaders = (req, res, next) => { + res.header('Access-Control-Allow-Origin', req.headers.origin || '*'); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Cross-Origin-Resource-Policy', 'cross-origin'); + next(); +}; + // Static file serving for photos (protected) -app.use('/photos', require('./src/middleware/photoAuth'), express.static(path.join(__dirname, 'storage/events/active'))); +app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, express.static(path.join(__dirname, 'storage/events/active'))); + +// Static file serving for thumbnails (protected) +app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, express.static(path.join(__dirname, 'storage/thumbnails'))); + +// Static file serving for uploads (public - logos, favicons) +app.use('/uploads', setCorsHeaders, express.static(path.join(__dirname, 'storage/uploads'))); // Health check endpoint app.get('/api/health', (req, res) => { @@ -94,6 +108,8 @@ app.use('/api/gallery', galleryRoutes); app.use('/api/admin', adminRoutes); app.use('/api/admin/auth', adminAuthRoutes); 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')); // Error handling middleware app.use((err, req, res, next) => { diff --git a/backend/src/config/storage.js b/backend/src/config/storage.js new file mode 100644 index 0000000..1f2603f --- /dev/null +++ b/backend/src/config/storage.js @@ -0,0 +1,8 @@ +const path = require('path'); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +module.exports = { + getStoragePath +}; \ No newline at end of file diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js new file mode 100644 index 0000000..b07be1d --- /dev/null +++ b/backend/src/middleware/gallery.js @@ -0,0 +1,29 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); + +// Middleware to verify gallery access +async function verifyGalleryAccess(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const event = await db('events').where({ id: decoded.eventId, is_active: true }).first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found or expired' }); + } + + req.event = event; + next(); + } catch (error) { + console.error('Error verifying gallery access:', error); + res.status(401).json({ error: 'Invalid token', details: error.message }); + } +} + +module.exports = { + verifyGalleryAccess +}; \ No newline at end of file diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index 950d852..17058c8 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -1,13 +1,45 @@ const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); async function photoAuth(req, res, next) { try { const eventSlug = req.path.split('/')[1]; + + // First check for JWT token (from gallery access) + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.replace('Bearer ', ''); + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Check if it's a gallery token for this event + if (decoded.type === 'gallery' && decoded.eventSlug === eventSlug) { + const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); + if (event) { + req.event = event; + return next(); + } + } + + // Check if it's an admin token (admins can view all photos) + if (decoded.type === 'admin') { + const event = await db('events').where({ slug: eventSlug }).first(); + if (event) { + req.event = event; + return next(); + } + } + } catch (err) { + // Token invalid, fall through to password check + } + } + + // Check for password header (legacy support) const password = req.headers['x-gallery-password']; - if (!password) { - return res.status(401).json({ error: 'Password required' }); + if (!password && !authHeader) { + return res.status(401).json({ error: 'Authentication required' }); } const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); @@ -15,20 +47,26 @@ async function photoAuth(req, res, next) { return res.status(404).json({ error: 'Gallery not found' }); } - const validPassword = await bcrypt.compare(password, event.password_hash); - if (!validPassword) { - await db('access_logs').insert({ - event_id: event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'login_fail' - }); - return res.status(401).json({ error: 'Invalid password' }); + if (password) { + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await db('access_logs').insert({ + event_id: event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid password' }); + } + } else { + // No valid authentication + return res.status(401).json({ error: 'Invalid authentication' }); } req.event = event; next(); } catch (error) { + console.error('Photo auth error:', error); res.status(500).json({ error: 'Authentication error' }); } } diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index d2f81cd..c00a940 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -8,6 +8,8 @@ const emailRoutes = require('./adminEmail'); const settingsRoutes = require('./adminSettings'); const eventsRoutes = require('./adminEvents'); const photosRoutes = require('./adminPhotos'); +const categoriesRoutes = require('./adminCategories'); +const cmsRoutes = require('./adminCMS'); // Mount sub-routers router.use('/dashboard', dashboardRoutes); @@ -16,5 +18,7 @@ router.use('/email', emailRoutes); router.use('/settings', settingsRoutes); router.use('/events', eventsRoutes); router.use('/events', photosRoutes); +router.use('/categories', categoriesRoutes); +router.use('/cms', cmsRoutes); module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js new file mode 100644 index 0000000..b5ecb1b --- /dev/null +++ b/backend/src/routes/adminCMS.js @@ -0,0 +1,83 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const router = express.Router(); + +// Get all CMS pages +router.get('/pages', adminAuth, async (req, res) => { + try { + const pages = await db('cms_pages').select('*').orderBy('slug', 'asc'); + res.json(pages); + } catch (error) { + console.error('Error fetching CMS pages:', error); + res.status(500).json({ error: 'Failed to fetch pages' }); + } +}); + +// Get a single CMS page +router.get('/pages/:slug', adminAuth, async (req, res) => { + try { + const { slug } = req.params; + const page = await db('cms_pages').where('slug', slug).first(); + + if (!page) { + return res.status(404).json({ error: 'Page not found' }); + } + + res.json(page); + } catch (error) { + console.error('Error fetching CMS page:', error); + res.status(500).json({ error: 'Failed to fetch page' }); + } +}); + +// Update a CMS page +router.put('/pages/:slug', adminAuth, [ + body('title_en').optional().isString(), + body('title_de').optional().isString(), + body('content_en').optional().isString(), + body('content_de').optional().isString() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slug } = req.params; + const { title_en, title_de, content_en, content_de } = req.body; + + const page = await db('cms_pages').where('slug', slug).first(); + if (!page) { + return res.status(404).json({ error: 'Page not found' }); + } + + // Update the page + await db('cms_pages') + .where('slug', slug) + .update({ + title_en, + title_de, + content_en, + content_de, + updated_at: new Date() + }); + + const updated = await db('cms_pages').where('slug', slug).first(); + + // Log activity + await logActivity('cms_page_updated', + { page: slug }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(updated); + } catch (error) { + console.error('Error updating CMS page:', error); + res.status(500).json({ error: 'Failed to update page' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js new file mode 100644 index 0000000..724f808 --- /dev/null +++ b/backend/src/routes/adminCategories.js @@ -0,0 +1,182 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const router = express.Router(); + +// Get all global categories +router.get('/global', adminAuth, async (req, res) => { + try { + const categories = await db('photo_categories') + .where('is_global', true) + .orderBy('name', 'asc'); + + res.json(categories); + } catch (error) { + console.error('Error fetching categories:', error); + res.status(500).json({ error: 'Failed to fetch categories' }); + } +}); + +// Get categories for a specific event (global + event-specific) +router.get('/event/:eventId', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + + const categories = await db('photo_categories') + .where(function() { + this.where('is_global', true) + .orWhere('event_id', eventId); + }) + .orderBy('is_global', 'desc') + .orderBy('name', 'asc'); + + res.json(categories); + } catch (error) { + console.error('Error fetching event categories:', error); + res.status(500).json({ error: 'Failed to fetch categories' }); + } +}); + +// Create a new category +router.post('/', adminAuth, [ + body('name').notEmpty().withMessage('Category name is required'), + body('slug').optional(), + body('is_global').optional().isBoolean(), + body('event_id').optional().isInt() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { name, slug, is_global = true, event_id = null } = req.body; + + // Generate slug if not provided + const categorySlug = slug || name.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); + + // Check if slug already exists for this scope + const existing = await db('photo_categories') + .where('slug', categorySlug) + .where(function() { + if (is_global) { + this.where('is_global', true); + } else { + this.where('event_id', event_id); + } + }) + .first(); + + if (existing) { + return res.status(400).json({ error: 'Category with this slug already exists' }); + } + + // Create category + const [categoryId] = await db('photo_categories').insert({ + name, + slug: categorySlug, + is_global, + event_id: is_global ? null : event_id + }); + + const category = await db('photo_categories').where('id', categoryId).first(); + + // Log activity + await logActivity('category_created', + { categoryName: name, isGlobal: is_global }, + event_id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(category); + } catch (error) { + console.error('Error creating category:', error); + res.status(500).json({ error: 'Failed to create category' }); + } +}); + +// Update a category +router.put('/:id', adminAuth, [ + body('name').notEmpty().withMessage('Category name is required') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const { name } = req.body; + + const category = await db('photo_categories').where('id', id).first(); + if (!category) { + return res.status(404).json({ error: 'Category not found' }); + } + + await db('photo_categories') + .where('id', id) + .update({ + name, + slug: name.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim() + }); + + const updated = await db('photo_categories').where('id', id).first(); + + // Log activity + await logActivity('category_updated', + { categoryName: name }, + category.event_id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(updated); + } catch (error) { + console.error('Error updating category:', error); + res.status(500).json({ error: 'Failed to update category' }); + } +}); + +// Delete a category +router.delete('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const category = await db('photo_categories').where('id', id).first(); + if (!category) { + return res.status(404).json({ error: 'Category not found' }); + } + + // Check if category has photos + const photoCount = await db('photos').where('category_id', id).count('id as count').first(); + if (photoCount.count > 0) { + return res.status(400).json({ + error: 'Cannot delete category with photos. Please reassign photos first.' + }); + } + + await db('photo_categories').where('id', id).delete(); + + // Log activity + await logActivity('category_deleted', + { categoryName: category.name }, + category.event_id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Category deleted successfully' }); + } catch (error) { + console.error('Error deleting category:', error); + res.status(500).json({ error: 'Failed to delete category' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 57f1bf7..37b4a48 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -34,7 +34,7 @@ router.post('/', adminAuth, [ admin_email, password, welcome_message = '', - color_theme = 'default', + color_theme = null, expiration_days = 30 } = req.body; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 880bccd..937a0fd 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -5,6 +5,7 @@ const fs = require('fs').promises; const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { generateThumbnail } = require('../services/imageProcessor'); +const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const router = express.Router(); // Get storage path from environment or default @@ -14,7 +15,6 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '. const storage = multer.diskStorage({ destination: async (req, file, cb) => { const { eventId } = req.params; - const { type = 'individual' } = req.body; try { // Get event details @@ -23,9 +23,11 @@ const storage = multer.diskStorage({ 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); + // Store event in request for use in filename generation + req.eventData = event; + + // Create destination path - now just event folder, no type subfolder + const destPath = path.join(getStoragePath(), 'events/active', event.slug); // Ensure directory exists await fs.mkdir(destPath, { recursive: true }); @@ -35,12 +37,14 @@ const storage = multer.diskStorage({ 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}`); + filename: async (req, file, cb) => { + try { + // Use temporary filename for now, will rename after getting category info + const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`; + cb(null, tempName); + } catch (error) { + cb(error); + } } }); @@ -67,7 +71,12 @@ const upload = multer({ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (req, res) => { try { const { eventId } = req.params; - const { type = 'individual' } = req.body; + const { category_id } = req.body; + + console.log('Upload request received:'); + console.log('Body:', req.body); + console.log('Files:', req.files ? req.files.length : 'none'); + console.log('Headers:', req.headers); // Verify event exists and admin has access const event = await db('events').where({ id: eventId }).first(); @@ -76,40 +85,103 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re } if (!req.files || req.files.length === 0) { + console.log('No files in request. req.files:', req.files); return res.status(400).json({ error: 'No files uploaded' }); } + // Get category details if provided + let category = null; + if (category_id) { + category = await db('photo_categories').where({ id: category_id }).first(); + if (!category) { + return res.status(400).json({ error: 'Invalid category' }); + } + } + const uploadedPhotos = []; // Process each uploaded file for (const file of req.files) { + let trx; try { - // Generate thumbnail + // Start transaction for atomic counter update + trx = await db.transaction(); + + // Get and increment the counter for this category + let counter = 1; + if (category) { + // Lock the category row and get current counter + const categoryData = await trx('photo_categories') + .where({ id: category_id }) + .forUpdate() + .first(); + + counter = (categoryData.photo_counter || 0) + 1; + + // Update counter + await trx('photo_categories') + .where({ id: category_id }) + .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 + ); + + // Rename the file + const oldPath = file.path; + const newPath = path.join(path.dirname(oldPath), newFilename); + await fs.rename(oldPath, newPath); + + // Update file object + file.filename = newFilename; + file.path = newPath; + + // Generate thumbnail with new filename 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; + const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root // Add to database - const [photoId] = await db('photos').insert({ + const [photoId] = await trx('photos').insert({ event_id: eventId, filename: file.filename, path: relativePath, thumbnail_path: relativeThumbPath, - type: type === 'collage' ? 'collage' : 'individual', + category_id: category_id || null, + type: 'individual', // Keep for backwards compatibility size_bytes: file.size }); + // Commit transaction + await trx.commit(); + uploadedPhotos.push({ id: photoId, filename: file.filename, size: file.size, - type + category_id: category_id || null }); } catch (error) { console.error(`Error processing file ${file.filename}:`, error); + if (trx) await trx.rollback(); // Continue with other files } } @@ -187,23 +259,38 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { router.get('/:eventId/photos', adminAuth, async (req, res) => { try { const { eventId } = req.params; - const { type } = req.query; + const { category_id, type } = req.query; - let query = db('photos').where({ event_id: eventId }); + let query = db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where({ 'photos.event_id': eventId }) + .select( + 'photos.*', + 'photo_categories.name as category_name', + 'photo_categories.slug as category_slug' + ); - if (type) { - query = query.where({ type }); + if (category_id) { + query = query.where({ 'photos.category_id': category_id }); } - const photos = await query.orderBy('uploaded_at', 'desc'); + // Keep type filter for backwards compatibility + if (type) { + query = query.where({ 'photos.type': type }); + } + + const photos = await query.orderBy('photos.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, + thumbnail_url: photo.thumbnail_path ? `/thumbnails/${photo.thumbnail_path}` : null, type: photo.type, + category_id: photo.category_id, + category_name: photo.category_name, + category_slug: photo.category_slug, size: photo.size_bytes, uploaded_at: photo.uploaded_at })) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index d0b83b9..7670fec 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -3,7 +3,7 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs').promises; const { body, validationResult } = require('express-validator'); -const { db } = require('../database/db'); +const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const router = express.Router(); @@ -36,6 +36,32 @@ const upload = multer({ } }); +// Configure multer for favicon uploads +const faviconStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(__dirname, '../../storage/uploads/favicons'); + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `favicon-${Date.now()}${ext}`); + } +}); + +const faviconUpload = multer({ + storage: faviconStorage, + limits: { fileSize: 1 * 1024 * 1024 }, // 1MB + fileFilter: (req, file, cb) => { + const allowedTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Favicon must be PNG or ICO format')); + } + } +}); + // Get all settings router.get('/', adminAuth, async (req, res) => { try { @@ -44,9 +70,17 @@ router.get('/', adminAuth, async (req, res) => { // Convert to object format const settingsObject = {}; settings.forEach(setting => { - settingsObject[setting.setting_key] = setting.setting_value - ? JSON.parse(setting.setting_value) - : null; + if (setting.setting_value) { + try { + // Try to parse as JSON first + settingsObject[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + settingsObject[setting.setting_key] = setting.setting_value; + } + } else { + settingsObject[setting.setting_key] = null; + } }); res.json(settingsObject); @@ -67,9 +101,17 @@ router.get('/:type', adminAuth, async (req, res) => { // Convert to object format const settingsObject = {}; settings.forEach(setting => { - settingsObject[setting.setting_key] = setting.setting_value - ? JSON.parse(setting.setting_value) - : null; + if (setting.setting_value) { + try { + // Try to parse as JSON first + settingsObject[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + settingsObject[setting.setting_key] = setting.setting_value; + } + } else { + settingsObject[setting.setting_key] = null; + } }); res.json(settingsObject); @@ -87,7 +129,10 @@ router.put('/branding', adminAuth, async (req, res) => { company_tagline, support_email, footer_text, - watermark_enabled + watermark_enabled, + watermark_position, + watermark_opacity, + watermark_size } = req.body; const brandingSettings = { @@ -95,7 +140,10 @@ router.put('/branding', adminAuth, async (req, res) => { company_tagline, support_email, footer_text, - watermark_enabled + watermark_enabled, + watermark_position, + watermark_opacity, + watermark_size }; // Update or insert each setting @@ -172,19 +220,19 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => { await db('app_settings') .insert({ setting_key: 'branding_logo_url', - setting_value: JSON.stringify(publicPath), + setting_value: publicPath, setting_type: 'branding', updated_at: new Date() }) .onConflict('setting_key') .merge({ - setting_value: JSON.stringify(publicPath), + setting_value: publicPath, updated_at: new Date() }); res.json({ message: 'Logo uploaded successfully', - logo_url: publicPath + logoUrl: publicPath }); } catch (error) { console.error('Logo upload error:', error); @@ -192,6 +240,68 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => { } }); +// Upload watermark logo +router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + // Delete old watermark logo if exists + const oldWatermarkLogoSetting = await db('app_settings') + .where('setting_key', 'branding_watermark_logo_path') + .first(); + + if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) { + const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value); + try { + await fs.unlink(oldPath); + } catch (error) { + console.error('Failed to delete old watermark logo:', error); + } + } + + // Save new watermark logo path + const logoPath = req.file.path; + const publicPath = `/uploads/logos/${req.file.filename}`; + + await db('app_settings') + .insert({ + setting_key: 'branding_watermark_logo_path', + setting_value: JSON.stringify(logoPath), + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(logoPath), + updated_at: new Date() + }); + + // Save public URL + await db('app_settings') + .insert({ + setting_key: 'branding_watermark_logo_url', + setting_value: publicPath, + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: publicPath, + updated_at: new Date() + }); + + res.json({ + message: 'Watermark logo uploaded successfully', + watermarkLogoUrl: publicPath + }); + } catch (error) { + console.error('Watermark logo upload error:', error); + res.status(500).json({ error: 'Failed to upload watermark logo' }); + } +}); + // Update theme settings router.put('/theme', adminAuth, async (req, res) => { try { @@ -346,4 +456,42 @@ router.get('/storage/info', adminAuth, async (req, res) => { } }); +// Upload favicon endpoint +router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No favicon file provided' }); + } + + // The file is already in the correct location from multer + const faviconUrl = `/uploads/favicons/${req.file.filename}`; + + // Save to database + await db('app_settings') + .insert({ + setting_key: 'branding_favicon_url', + setting_value: faviconUrl, + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: faviconUrl, + updated_at: new Date() + }); + + // Log activity + await logActivity('favicon_uploaded', + { faviconUrl }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ faviconUrl }); + } catch (error) { + console.error('Error uploading favicon:', error); + res.status(500).json({ error: 'Failed to upload favicon' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index bfc85d5..c68b127 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -87,7 +87,11 @@ router.post('/gallery/verify', [ }); // Generate session token - const token = jwt.sign({ eventId: event.id }, process.env.JWT_SECRET, { expiresIn: '24h' }); + const token = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery' + }, process.env.JWT_SECRET, { expiresIn: '24h' }); res.json({ token, diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 5a7ef6b..b559ad2 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -4,6 +4,7 @@ const { db } = require('../database/db'); const archiver = require('archiver'); const path = require('path'); const router = express.Router(); +const watermarkService = require('../services/watermarkService'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -26,7 +27,8 @@ async function verifyGalleryAccess(req, res, next) { req.event = event; next(); } catch (error) { - res.status(401).json({ error: 'Invalid token' }); + console.error('Error verifying gallery access:', error); + res.status(401).json({ error: 'Invalid token', details: error.message }); } } @@ -52,7 +54,8 @@ router.get('/:slug/verify-token/:token', async (req, res) => { res.json({ valid: true }); } catch (error) { - res.status(500).json({ error: 'Failed to verify token' }); + console.error('Error verifying token:', error); + res.status(500).json({ error: 'Failed to verify token', details: error.message }); } }); @@ -89,7 +92,8 @@ router.get('/:slug/info', async (req, res) => { requires_password: true }); } catch (error) { - res.status(500).json({ error: 'Failed to fetch gallery info' }); + console.error('Error fetching gallery info:', error); + res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message }); } }); @@ -97,8 +101,23 @@ router.get('/:slug/info', async (req, res) => { router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { try { const photos = await db('photos') - .where('event_id', req.event.id) - .orderBy('uploaded_at', 'desc'); + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', req.event.id) + .select( + 'photos.*', + 'photo_categories.name as category_name', + 'photo_categories.slug as category_slug' + ) + .orderBy('photos.uploaded_at', 'desc'); + + // Get all categories for this event + const categories = await db('photo_categories') + .where(function() { + this.where('is_global', true) + .orWhere('event_id', req.event.id); + }) + .orderBy('is_global', 'desc') + .orderBy('name', 'asc'); // Log view await db('access_logs').insert({ @@ -118,18 +137,28 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { color_theme: req.event.color_theme, expires_at: req.event.expires_at }, + categories: categories.map(cat => ({ + id: cat.id, + name: cat.name, + slug: cat.slug, + is_global: cat.is_global + })), photos: photos.map(photo => ({ id: photo.id, filename: photo.filename, - url: `/photos/${req.event.slug}/${photo.path}`, - thumbnail_url: photo.thumbnail_path ? `/photos/${photo.thumbnail_path}` : null, + url: `/photos/${photo.path}`, + thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null, type: photo.type, + category_id: photo.category_id, + category_name: photo.category_name, + category_slug: photo.category_slug, size: photo.size_bytes, uploaded_at: photo.uploaded_at })) }); } catch (error) { - res.status(500).json({ error: 'Failed to fetch photos' }); + console.error('Error fetching photos:', error); + res.status(500).json({ error: 'Failed to fetch photos', details: error.message }); } }); @@ -159,7 +188,25 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => }); const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); - res.download(filePath, photo.filename); + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + if (watermarkSettings && watermarkSettings.enabled) { + // Apply watermark and send + const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); + + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Disposition': `attachment; filename="${photo.filename}"`, + 'Content-Length': watermarkedBuffer.length + }); + + res.send(watermarkedBuffer); + } else { + // Send original file + res.download(filePath, photo.filename); + } } catch (error) { res.status(500).json({ error: 'Failed to download photo' }); } @@ -184,10 +231,21 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { archive.pipe(res); + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + // Add photos to archive for (const photo of photos) { const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); - archive.file(filePath, { name: photo.path }); + + if (watermarkSettings && watermarkSettings.enabled) { + // Apply watermark + const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); + archive.append(watermarkedBuffer, { name: photo.path }); + } else { + // Add original file + archive.file(filePath, { name: photo.path }); + } } await archive.finalize(); diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js new file mode 100644 index 0000000..d294ac1 --- /dev/null +++ b/backend/src/routes/protectedImages.js @@ -0,0 +1,189 @@ +const express = require('express'); +const path = require('path'); +const { db } = require('../database/db'); +const { verifyGalleryAccess } = require('../middleware/gallery'); +const watermarkService = require('../services/watermarkService'); +const { getStoragePath } = require('../config/storage'); +const crypto = require('crypto'); + +const router = express.Router(); + +/** + * Generate a signed URL token for image access + */ +function generateImageToken(photoId, expiresIn = 3600) { + const secret = process.env.JWT_SECRET || 'your-secret-key'; + const expires = Date.now() + (expiresIn * 1000); + const data = `${photoId}:${expires}`; + const signature = crypto.createHmac('sha256', secret).update(data).digest('hex'); + return `${Buffer.from(data).toString('base64')}.${signature}`; +} + +/** + * Verify image token + */ +function verifyImageToken(token) { + try { + const secret = process.env.JWT_SECRET || 'your-secret-key'; + const [data, signature] = token.split('.'); + const decoded = Buffer.from(data, 'base64').toString(); + const [photoId, expires] = decoded.split(':'); + + // Verify signature + const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex'); + if (signature !== expectedSignature) { + return null; + } + + // Check expiration + if (Date.now() > parseInt(expires)) { + return null; + } + + return { photoId: parseInt(photoId), expires: parseInt(expires) }; + } catch (error) { + return null; + } +} + +/** + * Serve watermarked image + */ +router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + + // Get photo details + const photo = await db('photos') + .where({ + id: photoId, + event_id: req.event.id + }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + // Build full path to photo + const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); + + // Apply watermark if enabled + const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings); + + // Set appropriate headers + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'private, max-age=3600', + 'X-Content-Type-Options': 'nosniff' + }); + + // Send the watermarked image + res.send(imageBuffer); + + } catch (error) { + console.error('Error serving watermarked image:', error); + res.status(500).json({ error: 'Failed to serve image' }); + } +}); + +/** + * Generate signed URL for image access + */ +router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + + // Verify photo belongs to this event + const photo = await db('photos') + .where({ + id: photoId, + event_id: req.event.id + }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Generate signed token + const token = generateImageToken(photoId); + const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`; + + res.json({ + url: signedUrl, + expiresIn: 3600 // 1 hour + }); + + } catch (error) { + console.error('Error generating signed URL:', error); + res.status(500).json({ error: 'Failed to generate URL' }); + } +}); + +/** + * Serve image with signed URL (no gallery auth required, token is the auth) + */ +router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => { + try { + const { slug, photoId, token } = req.params; + + // Verify token + const tokenData = verifyImageToken(token); + if (!tokenData || tokenData.photoId !== parseInt(photoId)) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } + + // Get event + const event = await db('events') + .where({ slug }) + .where('is_active', true) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Get photo + const photo = await db('photos') + .where({ + id: photoId, + event_id: event.id + }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + // Build full path to photo + const photoPath = path.join(getStoragePath(), 'events/active', event.slug, photo.path); + + // Apply watermark if enabled + const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings); + + // Set appropriate headers + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'private, max-age=3600', + 'X-Content-Type-Options': 'nosniff' + }); + + // Send the watermarked image + res.send(imageBuffer); + + } catch (error) { + console.error('Error serving signed image:', error); + res.status(500).json({ error: 'Failed to serve image' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/publicCMS.js b/backend/src/routes/publicCMS.js new file mode 100644 index 0000000..8a69f27 --- /dev/null +++ b/backend/src/routes/publicCMS.js @@ -0,0 +1,33 @@ +const express = require('express'); +const { db } = require('../database/db'); +const router = express.Router(); + +// Get public CMS page +router.get('/pages/:slug', async (req, res) => { + try { + const { slug } = req.params; + const { lang = 'en' } = req.query; + + const page = await db('cms_pages').where('slug', slug).first(); + + if (!page) { + return res.status(404).json({ error: 'Page not found' }); + } + + // Return the appropriate language version + const title = lang === 'de' ? page.title_de : page.title_en; + const content = lang === 'de' ? page.content_de : page.content_en; + + res.json({ + title, + content, + slug: page.slug, + updated_at: page.updated_at + }); + } catch (error) { + console.error('Error fetching public CMS page:', error); + res.status(500).json({ error: 'Failed to fetch page' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 2c2ddb0..8df6be8 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -5,9 +5,9 @@ const router = express.Router(); // Get public settings (branding and theme) router.get('/', async (req, res) => { try { - // Fetch branding and theme settings + // Fetch branding, theme, and general settings const settings = await db('app_settings') - .whereIn('setting_type', ['branding', 'theme']) + .whereIn('setting_type', ['branding', 'theme', 'general']) .select('setting_key', 'setting_value'); // Convert to object format @@ -30,7 +30,14 @@ router.get('/', async (req, res) => { branding_support_email: settingsObject.branding_support_email || '', branding_footer_text: settingsObject.branding_footer_text || '', branding_watermark_enabled: settingsObject.branding_watermark_enabled || false, - theme_config: settingsObject.theme_config || null + branding_watermark_logo_url: settingsObject.branding_watermark_logo_url || '', + branding_watermark_position: settingsObject.branding_watermark_position || 'bottom-right', + branding_watermark_opacity: settingsObject.branding_watermark_opacity || 50, + branding_watermark_size: settingsObject.branding_watermark_size || 15, + branding_favicon_url: settingsObject.branding_favicon_url || '', + branding_logo_url: settingsObject.branding_logo_url || '', + theme_config: settingsObject.theme_config || null, + default_language: settingsObject.general_default_language || 'en' }; res.json(publicSettings); diff --git a/backend/src/services/fileWatcher.js b/backend/src/services/fileWatcher.js index a7dfa36..62ba370 100644 --- a/backend/src/services/fileWatcher.js +++ b/backend/src/services/fileWatcher.js @@ -60,12 +60,15 @@ async function processNewPhoto(filePath) { // Generate thumbnail const thumbnailPath = await generateThumbnail(filePath); + // Calculate relative thumbnail path + const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root + // Add to database await db('photos').insert({ event_id: event.id, filename: path.basename(filePath), path: relativePath, - thumbnail_path: thumbnailPath, + thumbnail_path: relativeThumbPath, type: photoType, size_bytes: stats.size }); diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 030e438..7045d32 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -3,15 +3,17 @@ const path = require('path'); const fs = require('fs').promises; const THUMBNAIL_WIDTH = 300; -const THUMBNAIL_PATH = path.join(__dirname, '../../../storage/thumbnails'); +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails'); async function generateThumbnail(imagePath) { const filename = path.basename(imagePath); const thumbnailFilename = `thumb_${filename}`; - const thumbnailPath = path.join(THUMBNAIL_PATH, thumbnailFilename); + const thumbnailDir = getThumbnailPath(); + const thumbnailPath = path.join(thumbnailDir, thumbnailFilename); // Ensure thumbnail directory exists - await fs.mkdir(THUMBNAIL_PATH, { recursive: true }); + await fs.mkdir(thumbnailDir, { recursive: true }); // Generate thumbnail await sharp(imagePath) @@ -22,7 +24,7 @@ async function generateThumbnail(imagePath) { .jpeg({ quality: 80 }) .toFile(thumbnailPath); - return path.relative(path.join(__dirname, '../../../storage'), thumbnailPath); + return path.relative(getStoragePath(), thumbnailPath); } module.exports = { generateThumbnail }; diff --git a/backend/src/services/watermarkService.js b/backend/src/services/watermarkService.js new file mode 100644 index 0000000..613b415 --- /dev/null +++ b/backend/src/services/watermarkService.js @@ -0,0 +1,226 @@ +const sharp = require('sharp'); +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); + +class WatermarkService { + constructor() { + this.cache = new Map(); + this.cacheMaxAge = 3600000; // 1 hour in milliseconds + } + + /** + * Get watermark settings from database + */ + async getWatermarkSettings() { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'branding_watermark_enabled', + 'branding_watermark_logo_path', + 'branding_watermark_position', + 'branding_watermark_opacity', + 'branding_watermark_size', + 'branding_company_name' + ]) + .select('setting_key', 'setting_value'); + + const settingsObj = {}; + settings.forEach(setting => { + try { + settingsObj[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + settingsObj[setting.setting_key] = setting.setting_value; + } + }); + + return { + enabled: settingsObj.branding_watermark_enabled || false, + logoPath: settingsObj.branding_watermark_logo_path || null, + position: settingsObj.branding_watermark_position || 'bottom-right', + opacity: parseInt(settingsObj.branding_watermark_opacity || 50), + size: parseInt(settingsObj.branding_watermark_size || 15), + companyName: settingsObj.branding_company_name || 'Photo Gallery' + }; + } catch (error) { + console.error('Error fetching watermark settings:', error); + return null; + } + } + + /** + * Calculate position coordinates based on position string + */ + getPositionCoordinates(imageWidth, imageHeight, watermarkWidth, watermarkHeight, position) { + const padding = 20; + let left, top; + + switch (position) { + case 'top-left': + left = padding; + top = padding; + break; + case 'top-right': + left = imageWidth - watermarkWidth - padding; + top = padding; + break; + case 'bottom-left': + left = padding; + top = imageHeight - watermarkHeight - padding; + break; + case 'bottom-right': + left = imageWidth - watermarkWidth - padding; + top = imageHeight - watermarkHeight - padding; + break; + case 'center': + left = Math.floor((imageWidth - watermarkWidth) / 2); + top = Math.floor((imageHeight - watermarkHeight) / 2); + break; + default: + // Default to bottom-right + left = imageWidth - watermarkWidth - padding; + top = imageHeight - watermarkHeight - padding; + } + + return { left: Math.max(0, left), top: Math.max(0, top) }; + } + + /** + * Apply watermark to an image + */ + async applyWatermark(imagePath, settings) { + try { + if (!settings || !settings.enabled) { + // Return original image if watermarking is disabled + return await fs.readFile(imagePath); + } + + // Check cache first + const cacheKey = `${imagePath}_${JSON.stringify(settings)}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) { + return cached.buffer; + } + + // Load the main image + const image = sharp(imagePath); + const metadata = await image.metadata(); + + let watermarkBuffer; + let watermarkMetadata; + + // Try to use logo watermark first + if (settings.logoPath) { + try { + const watermarkImage = sharp(settings.logoPath); + watermarkMetadata = await watermarkImage.metadata(); + + // Calculate watermark size based on percentage of main image + const scaleFactor = settings.size / 100; + const targetWidth = Math.floor(metadata.width * scaleFactor); + const targetHeight = Math.floor(watermarkMetadata.height * (targetWidth / watermarkMetadata.width)); + + // Resize watermark and apply opacity + watermarkBuffer = await watermarkImage + .resize(targetWidth, targetHeight, { fit: 'inside' }) + .composite([{ + input: Buffer.from([255, 255, 255, Math.floor(255 * (settings.opacity / 100))]), + raw: { + width: 1, + height: 1, + channels: 4 + }, + tile: true, + blend: 'dest-in' + }]) + .toBuffer(); + + watermarkMetadata = { width: targetWidth, height: targetHeight }; + } catch (error) { + console.error('Error processing watermark logo:', error); + watermarkBuffer = null; + } + } + + // If no logo or logo failed, create text watermark + if (!watermarkBuffer) { + const fontSize = Math.max(16, Math.floor(metadata.width * 0.03)); + const padding = 10; + + // Create SVG text watermark + const svg = ` + + + + ${settings.companyName} + + + `; + + watermarkBuffer = Buffer.from(svg); + watermarkMetadata = { + width: settings.companyName.length * fontSize * 0.6 + padding * 2, + height: fontSize + padding * 2 + }; + } + + // Calculate position + const position = this.getPositionCoordinates( + metadata.width, + metadata.height, + watermarkMetadata.width, + watermarkMetadata.height, + settings.position + ); + + // Apply watermark + const watermarkedBuffer = await image + .composite([{ + input: watermarkBuffer, + top: position.top, + left: position.left + }]) + .toBuffer(); + + // Cache the result + this.cache.set(cacheKey, { + buffer: watermarkedBuffer, + timestamp: Date.now() + }); + + // Clean old cache entries + this.cleanCache(); + + return watermarkedBuffer; + } catch (error) { + console.error('Error applying watermark:', error); + // Return original image on error + return await fs.readFile(imagePath); + } + } + + /** + * Clean old cache entries + */ + cleanCache() { + const now = Date.now(); + for (const [key, value] of this.cache.entries()) { + if (now - value.timestamp > this.cacheMaxAge) { + this.cache.delete(key); + } + } + } + + /** + * Clear entire cache + */ + clearCache() { + this.cache.clear(); + } +} + +module.exports = new WatermarkService(); \ No newline at end of file diff --git a/backend/src/utils/filenameSanitizer.js b/backend/src/utils/filenameSanitizer.js new file mode 100644 index 0000000..f93232a --- /dev/null +++ b/backend/src/utils/filenameSanitizer.js @@ -0,0 +1,57 @@ +/** + * Sanitize a string to be used as a filename component + * @param {string} str - The string to sanitize + * @param {number} maxLength - Maximum length of the sanitized string + * @returns {string} - Sanitized string + */ +function sanitizeFilename(str, maxLength = 50) { + if (!str) return 'unnamed'; + + // Convert to string and trim + let sanitized = String(str).trim(); + + // Replace spaces with underscores + sanitized = sanitized.replace(/\s+/g, '_'); + + // Remove special characters except hyphens, underscores, and dots + sanitized = sanitized.replace(/[^a-zA-Z0-9_\-\.]/g, ''); + + // Remove multiple consecutive underscores or hyphens + sanitized = sanitized.replace(/[_\-]{2,}/g, '_'); + + // Remove leading/trailing underscores or hyphens + sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, ''); + + // Limit length + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength); + } + + // If empty after sanitization, use default + if (!sanitized) { + sanitized = 'unnamed'; + } + + return sanitized; +} + +/** + * Generate a photo filename based on event name, category, and counter + * @param {string} eventName - The event name + * @param {string} categoryName - The category name + * @param {number} counter - The photo counter + * @param {string} extension - The file extension (including dot) + * @returns {string} - Generated filename + */ +function generatePhotoFilename(eventName, categoryName, counter, extension) { + const sanitizedEvent = sanitizeFilename(eventName, 30); + const sanitizedCategory = sanitizeFilename(categoryName || 'uncategorized', 20); + const paddedCounter = String(counter).padStart(4, '0'); + + return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`; +} + +module.exports = { + sanitizeFilename, + generatePhotoFilename +}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index eeeb512..0451ba8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,13 @@ services: context: ./backend dockerfile: Dockerfile ports: - - "3000:3000" + - "3001:3000" environment: - NODE_ENV=development - PORT=3000 - JWT_SECRET=dev-secret-key - - ADMIN_URL=http://localhost:3000 - - FRONTEND_URL=http://localhost:3001 + - ADMIN_URL=http://localhost:3001 + - FRONTEND_URL=http://localhost:3005 - SMTP_HOST=mailhog - SMTP_PORT=1025 - SMTP_SECURE=false @@ -28,7 +28,7 @@ services: - ./logs:/app/logs depends_on: - mailhog - command: npm run dev + command: node server.js frontend: build: @@ -36,13 +36,13 @@ services: dockerfile: Dockerfile target: builder ports: - - "3001:3000" + - "3005:5173" environment: - - REACT_APP_API_URL=http://localhost:3000 + - REACT_APP_API_URL=http://localhost:3001 volumes: - ./frontend:/app - /app/node_modules - command: npm start + command: npm run dev mailhog: image: mailhog/mailhog:latest diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 88e09f3..13a61b3 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:18-alpine AS builder +FROM node:20-alpine AS builder # Set working directory WORKDIR /app diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86653f5..ef3187a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,14 +9,21 @@ "version": "1.0.0", "dependencies": { "@tanstack/react-query": "^5.0.0", + "@tiptap/extension-link": "^2.25.0", + "@tiptap/react": "^2.25.0", + "@tiptap/starter-kit": "^2.25.0", "axios": "^1.3.2", "clsx": "^2.0.0", "date-fns": "^2.29.3", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "js-cookie": "^3.0.5", "lucide-react": "^0.292.0", "react": "^18.3.1", "react-countdown": "^2.3.5", "react-dom": "^18.3.1", + "react-i18next": "^15.6.0", "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.8.0", @@ -1123,6 +1130,22 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -1445,6 +1468,408 @@ "react": "^18 || ^19" } }, + "node_modules/@tiptap/core": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.25.0.tgz", + "integrity": "sha512-pTLV0+g+SBL49/Y5A9ii7oHwlzIzpgroJVI3AcBk7/SeR7554ZzjxxtJmZkQ9/NxJO+k1jQp9grXaqqOLqC7cA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.25.0.tgz", + "integrity": "sha512-W+sVPlV9XmaNPUkxV2BinNEbk2hr4zw8VgKjqKQS9O0k2YIVRCfQch+4DudSAwBVMrVW97zVAKRNfictGFQ8vQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.25.0.tgz", + "integrity": "sha512-3cBX2EtdFR3+EDTkIshhpQpXoZQbFUzxf6u86Qm0qD49JnVOjX9iexnUp8MydXPZA6NVsKeEfMhf18gV7oxTEw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.25.0.tgz", + "integrity": "sha512-BnbfQWRXJDDy9/x/0Atu2Nka5ZAMyXLDFqzSLMAXqXSQcG6CZRTSNRgOCnjpda6Hq2yCtq7l/YEoXkbHT1ZZdQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.25.0.tgz", + "integrity": "sha512-KD+q/q6KIU2anedjtjG8vELkL5rYFdNHWc5XcUJgQoxbOCK3/sBuOgcn9mnFA2eAS6UkraN9Yx0BXEDbXX2HOw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.25.0.tgz", + "integrity": "sha512-rRp6X2aNNnvo7Fbqc3olZ0vLb52FlCPPfetr9gy6/M9uQdVYDhJcFOPuRuXtZ8M8X+WpCZBV29BvZFeDqfw8bw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.25.0.tgz", + "integrity": "sha512-T4kXbZNZ/NyklzQ/FWmUnjD4hgmJPrIBazzCZ/E/rF/Ag2IvUsztBT0PN3vTa+DAZ+IbM61TjlIpyJs1R7OdbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.25.0.tgz", + "integrity": "sha512-3gEZlQKUSIRrC6Az8QS7SJi4CvhMWrA7RBChM1aRl9vMNN8Ul7dZZk5StYJGPjL/koTiceMqx9pNmTCBprsbvQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.25.0.tgz", + "integrity": "sha512-eSHqp+iUI2mGVwvIyENP02hi5TSyQ+bdwNwIck6bdzjRvXakm72+8uPfVSLGxRKAQZ0RFtmux8ISazgUqF/oSw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.25.0.tgz", + "integrity": "sha512-hPZ5SNpI14smTz4GpWQXTnxmeICINYiABSgXcsU5V66tik9OtxKwoCSR/gpU35esaAFUVRdjW7+sGkACLZD5AQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.25.0.tgz", + "integrity": "sha512-s/3WDbgkvLac88h5iYJLPJCDw8tMhlss1hk9GAo+zzP4h0xfazYie09KrA0CBdfaSOFyeJK3wedzjKZBtdgX4w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.25.0.tgz", + "integrity": "sha512-h8be5Zdtsl5GQHxRXvYlGfIJsLvdbexflSTr12gr4kvcQqTdtrsqyu2eksfAK+p2szbiwP2G4VZlH0LNS47UXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.25.0.tgz", + "integrity": "sha512-IrRKRRr7Bhpnq5aue1v5/e5N/eNdVV/THsgqqpLZO48pgN8Wv+TweOZe1Ntg/v8L4QSBC8iGMxxhiJZT8AzSkA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.25.0.tgz", + "integrity": "sha512-y3uJkJv+UngDaDYfcVJ4kx8ivc3Etk5ow6N+47AMCRjUUweQ/CLiJwJ2C7nL7L82zOzVbb/NoR/B3UeE4ts/wQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.25.0.tgz", + "integrity": "sha512-bZovyhdOexB3Cv9ddUogWT+cd3KbnenMIZKhgrJ+R0J27rlOtzeUD9TeIjn4V8Of9mTxm3XDKUZGLgPiriN8Ww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.25.0.tgz", + "integrity": "sha512-FZHmNqvWJ5SHYlUi+Qg3b2C0ZBt82DUDUqM+bqcQqSQu6B0c4IEc3+VHhjAJwEUIO9wX7xk/PsdM4Z5Ex4Lr3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.25.0.tgz", + "integrity": "sha512-jNd+1Fd7wiIbxlS51weBzyDtBEBSVzW0cgzdwOzBYQtPJueRyXNNVERksyinDuVgcfvEWgmNZUylgzu7mehnEg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.25.0.tgz", + "integrity": "sha512-HLstO/R+dNjIFMXN15bANc8i/+CDpEgtEQhZNHqvSUJH9xQ5op0S05m5VvFI10qnwXNjwwXdhxUYwwjIDCiAgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.25.0.tgz", + "integrity": "sha512-Hlid16nQdDFOGOx6mJT+zPEae2t1dGlJ18pqCqaVMuDnIpNIWmQutJk5QYxGVxr9awd2SpHTpQtdBTqcufbHtw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.25.0.tgz", + "integrity": "sha512-53gpWMPedkWVDp3u/1sLt6vnr3BWz4vArGCmmabLucCI2Yl4R6S/AQ9yj/+jOHvWbXCroCbKtmmwxJl32uGN2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.25.0.tgz", + "integrity": "sha512-Z5YBKnv4N6MMD1LEo9XbmWnmdXavZKOOJt/OkXYFZ3KgzB52Z3q3DDfH+NyeCtKKSWqWVxbBHKLnsojDerSf2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.25.0.tgz", + "integrity": "sha512-HlZL86rihpP/R8+dqRrvzSRmiPpx6ctlAKM9PnWT/WRMeI4Y1AUq6PSHLz74wtYO1LH4PXys1ws3n+pLP4Mo6g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.25.0.tgz", + "integrity": "sha512-MKAXqDATEbuFEB1SeeAFy2VbefUMJ9jxQyybpaHjDX+Ik0Ddu+aYuJP/njvLuejXCqhrkS/AorxzmHUC4HNPbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.25.0.tgz", + "integrity": "sha512-vuzU0pLGQyHqtikAssHn9V61aXLSQERQtn3MUtaJ36fScQg7RClAK5gnIbBt3Ul3VFof8o4xYmcidARc0X/E5A==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.25.0.tgz", + "integrity": "sha512-Fc7uj/+goEhvJkH2vYJxXLH1GsUkOcsIR3kUyL0vejNRvpzzd87CI/EiSD2ESJO43czQcsJkiYzY4EC+p8NF9w==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.25.0", + "@tiptap/extension-floating-menu": "^2.25.0", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.25.0.tgz", + "integrity": "sha512-MWt6gEdQ2LPuCqbvNGmS0uA+6rtMGRh3vC0WBNp6rJPAvwS8OPcpraLz61cWjgzeKZBUKODpNA5IZ6gDRyH9LQ==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.25.0", + "@tiptap/extension-blockquote": "^2.25.0", + "@tiptap/extension-bold": "^2.25.0", + "@tiptap/extension-bullet-list": "^2.25.0", + "@tiptap/extension-code": "^2.25.0", + "@tiptap/extension-code-block": "^2.25.0", + "@tiptap/extension-document": "^2.25.0", + "@tiptap/extension-dropcursor": "^2.25.0", + "@tiptap/extension-gapcursor": "^2.25.0", + "@tiptap/extension-hard-break": "^2.25.0", + "@tiptap/extension-heading": "^2.25.0", + "@tiptap/extension-history": "^2.25.0", + "@tiptap/extension-horizontal-rule": "^2.25.0", + "@tiptap/extension-italic": "^2.25.0", + "@tiptap/extension-list-item": "^2.25.0", + "@tiptap/extension-ordered-list": "^2.25.0", + "@tiptap/extension-paragraph": "^2.25.0", + "@tiptap/extension-strike": "^2.25.0", + "@tiptap/extension-text": "^2.25.0", + "@tiptap/extension-text-style": "^2.25.0", + "@tiptap/pm": "^2.25.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1511,6 +1936,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "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", @@ -1539,6 +1986,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", @@ -1931,7 +2384,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/asynckit": { @@ -2240,6 +2692,21 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2374,6 +2841,18 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2474,7 +2953,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2665,7 +3143,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3065,6 +3542,64 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", + "integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3326,6 +3861,21 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz", + "integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3380,6 +3930,23 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3389,6 +3956,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3502,6 +4075,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -3566,6 +4159,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3880,6 +4479,201 @@ "react-is": "^16.13.1" } }, + "node_modules/prosemirror-changeset": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", + "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz", + "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", + "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz", + "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", + "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", + "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz", + "integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", + "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz", + "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.25.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.10.3", + "prosemirror-view": "^1.39.1" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", + "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.0.tgz", + "integrity": "sha512-2G3svX0Cr1sJjkD/DYWSe3cfV5VPVTBOxI9XQEGWJDFEpsZb/gh4MV29ctv+OJx2RFX4BLt09i+6zaGM/ldkCw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3896,6 +4690,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3955,6 +4758,32 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", + "integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-image-gallery": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/react-image-gallery/-/react-image-gallery-1.4.0.tgz", @@ -4154,6 +4983,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4525,6 +5360,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4538,6 +5382,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4608,6 +5458,12 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -4649,6 +5505,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4759,6 +5624,37 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c1bbd24..4f5b21d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,14 +11,21 @@ }, "dependencies": { "@tanstack/react-query": "^5.0.0", + "@tiptap/extension-link": "^2.25.0", + "@tiptap/react": "^2.25.0", + "@tiptap/starter-kit": "^2.25.0", "axios": "^1.3.2", "clsx": "^2.0.0", "date-fns": "^2.29.3", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "js-cookie": "^3.0.5", "lucide-react": "^0.292.0", "react": "^18.3.1", "react-countdown": "^2.3.5", "react-dom": "^18.3.1", + "react-i18next": "^15.6.0", "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.8.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6cfd90c..6478a1e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import { analyticsService } from './services/analytics.service'; import { GalleryAuthProvider } from './contexts'; import { ThemeProvider } from './contexts/ThemeContext'; import { GalleryPage } from './pages/GalleryPage'; +import { PreviewPage } from './pages/gallery/PreviewPage'; +import { LegalPage } from './pages/public/LegalPage'; import { AdminLoginPage, AdminDashboard, @@ -18,10 +20,11 @@ import { ArchivesPage, AnalyticsPage, BrandingPage, - SettingsPage + SettingsPage, + CMSPage } from './pages/admin'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; -import { PageErrorBoundary, OfflineIndicator, SkipLink } from './components/common'; +import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common'; // Create a client const queryClient = new QueryClient({ @@ -53,10 +56,12 @@ function App() { + {/* Public gallery routes */} + } /> @@ -76,10 +81,16 @@ function App() { } /> } /> } /> + } /> } /> + {/* Public legal pages */} + } /> + } /> + } /> + {/* Default redirect */} } /> diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 33aeab3..910076a 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -2,10 +2,12 @@ import React, { useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react'; import { format } from 'date-fns'; +import { useTranslation } from 'react-i18next'; import { useAdminAuth } from '../../contexts'; import { useOnClickOutside } from '../../hooks/useOnClickOutside'; import { PasswordChangeModal } from './PasswordChangeModal'; +import { LanguageSelector } from '../common'; interface AdminHeaderProps { onMenuClick: () => void; @@ -14,6 +16,7 @@ interface AdminHeaderProps { export const AdminHeader: React.FC = ({ onMenuClick }) => { const navigate = useNavigate(); const { user, logout } = useAdminAuth(); + const { t } = useTranslation(); const [showUserMenu, setShowUserMenu] = useState(false); const [showNotifications, setShowNotifications] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); @@ -66,6 +69,9 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { {/* Right side actions */}
+ {/* Language Selector */} + + {/* Notifications */}
@@ -136,7 +142,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3" > - Settings + {t('navigation.settings')} )} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index b2609ad..8b63ded 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -9,9 +9,11 @@ import { Settings, Camera, X, - Palette + Palette, + FileText } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { settingsService } from '../../services/settings.service'; interface AdminSidebarProps { @@ -20,23 +22,25 @@ interface AdminSidebarProps { } interface NavItem { - name: string; + nameKey: string; href: string; icon: React.ComponentType<{ className?: string }>; } const navigation: NavItem[] = [ - { name: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, - { name: 'Events', href: '/admin/events', icon: Calendar }, - { name: 'Archives', href: '/admin/archives', icon: Archive }, - { name: 'Analytics', href: '/admin/analytics', icon: BarChart3 }, - { name: 'Email Settings', href: '/admin/email', icon: Mail }, - { name: 'Branding', href: '/admin/branding', icon: Palette }, - { name: 'Settings', href: '/admin/settings', icon: Settings }, + { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, + { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar }, + { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive }, + { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 }, + { nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail }, + { nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette }, + { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings }, + { nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText }, ]; export const AdminSidebar: React.FC = ({ isOpen, onClose }) => { const location = useLocation(); + const { t } = useTranslation(); return (
= ({ isOpen, onClose }) =
- Photo Admin + {t('admin.title')}
+ ); + + return ( +
+ {/* Toolbar */} +
+ editor.chain().focus().toggleHeading({ level: 1 }).run()} + active={editor.isActive('heading', { level: 1 })} + title="Heading 1" + > + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + active={editor.isActive('heading', { level: 2 })} + title="Heading 2" + > + + + +
+ + editor.chain().focus().toggleBold().run()} + active={editor.isActive('bold')} + title="Bold" + > + + + + editor.chain().focus().toggleItalic().run()} + active={editor.isActive('italic')} + title="Italic" + > + + + +
+ + editor.chain().focus().toggleBulletList().run()} + active={editor.isActive('bulletList')} + title="Bullet List" + > + + + + editor.chain().focus().toggleOrderedList().run()} + active={editor.isActive('orderedList')} + title="Ordered List" + > + + + +
+ + setShowLinkDialog(true)} + active={editor.isActive('link')} + title="Add Link" + > + + + +
+ + editor.chain().focus().undo().run()} + title="Undo" + > + + + + editor.chain().focus().redo().run()} + title="Redo" + > + + +
+ + {/* Link Dialog */} + {showLinkDialog && ( +
+ setLinkUrl(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && addLink()} + placeholder="Enter URL..." + className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
+ )} + + {/* Editor */} + +
+ ); +}; + +CMSEditor.displayName = 'CMSEditor'; \ No newline at end of file diff --git a/frontend/src/components/admin/CategoryManager.tsx b/frontend/src/components/admin/CategoryManager.tsx new file mode 100644 index 0000000..ccc5b65 --- /dev/null +++ b/frontend/src/components/admin/CategoryManager.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { categoriesService, type PhotoCategory } from '../../services/categories.service'; +import { Button } from '../common'; + +export const CategoryManager: React.FC = () => { + const queryClient = useQueryClient(); + const [isAdding, setIsAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + const [newCategoryName, setNewCategoryName] = useState(''); + const [editingName, setEditingName] = useState(''); + + // Fetch global categories + const { data: categories = [], isLoading } = useQuery({ + queryKey: ['global-categories'], + queryFn: categoriesService.getGlobalCategories, + }); + + // Create category mutation + const createMutation = useMutation({ + mutationFn: (name: string) => + categoriesService.createCategory({ name, is_global: true }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['global-categories'] }); + toast.success('Category created successfully'); + setNewCategoryName(''); + setIsAdding(false); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to create category'); + }, + }); + + // Update category mutation + const updateMutation = useMutation({ + mutationFn: ({ id, name }: { id: number; name: string }) => + categoriesService.updateCategory(id, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['global-categories'] }); + toast.success('Category updated successfully'); + setEditingId(null); + setEditingName(''); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to update category'); + }, + }); + + // Delete category mutation + const deleteMutation = useMutation({ + mutationFn: categoriesService.deleteCategory, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['global-categories'] }); + toast.success('Category deleted successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to delete category'); + }, + }); + + const handleCreate = () => { + if (newCategoryName.trim()) { + createMutation.mutate(newCategoryName.trim()); + } + }; + + const handleUpdate = (id: number) => { + if (editingName.trim()) { + updateMutation.mutate({ id, name: editingName.trim() }); + } + }; + + const handleDelete = (category: PhotoCategory) => { + if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) { + deleteMutation.mutate(category.id); + } + }; + + const startEdit = (category: PhotoCategory) => { + setEditingId(category.id); + setEditingName(category.name); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditingName(''); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Photo Categories

+ {!isAdding && ( + + )} +
+ + {/* Add new category form */} + {isAdding && ( +
+ setNewCategoryName(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleCreate()} + placeholder="Category name" + className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
+ )} + + {/* Categories list */} +
+ {categories.length === 0 ? ( +

+ No categories yet. Create your first category to organize photos. +

+ ) : ( + categories.map((category) => ( +
+ {editingId === category.id ? ( +
+ setEditingName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') handleUpdate(category.id); + if (e.key === 'Escape') cancelEdit(); + }} + className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
+ ) : ( + <> +
+

{category.name}

+

/{category.slug}

+
+
+ + +
+ + )} +
+ )) + )} +
+
+ ); +}; + +CategoryManager.displayName = 'CategoryManager'; \ No newline at end of file diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx new file mode 100644 index 0000000..1c4077a --- /dev/null +++ b/frontend/src/components/admin/EventCategoryManager.tsx @@ -0,0 +1,177 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, X, Loader2 } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { categoriesService, type PhotoCategory } from '../../services/categories.service'; +import { Button } from '../common'; + +interface EventCategoryManagerProps { + eventId: number; +} + +export const EventCategoryManager: React.FC = ({ eventId }) => { + const queryClient = useQueryClient(); + const [isAdding, setIsAdding] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(''); + + // Fetch categories for this event + const { data: categories = [], isLoading } = useQuery({ + queryKey: ['event-categories', eventId], + queryFn: () => categoriesService.getEventCategories(eventId), + }); + + // Filter to show only event-specific categories + const eventCategories = categories.filter(cat => !cat.is_global); + + // Create category mutation + const createMutation = useMutation({ + mutationFn: (name: string) => + categoriesService.createCategory({ + name, + is_global: false, + event_id: eventId + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); + toast.success('Category created successfully'); + setNewCategoryName(''); + setIsAdding(false); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to create category'); + }, + }); + + // Delete category mutation + const deleteMutation = useMutation({ + mutationFn: categoriesService.deleteCategory, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); + toast.success('Category deleted successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to delete category'); + }, + }); + + const handleCreate = () => { + if (newCategoryName.trim()) { + createMutation.mutate(newCategoryName.trim()); + } + }; + + const handleDelete = (category: PhotoCategory) => { + if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) { + deleteMutation.mutate(category.id); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Event-Specific Categories

+ {!isAdding && ( + + )} +
+ + {/* Add new category form */} + {isAdding && ( +
+ setNewCategoryName(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleCreate()} + placeholder="Category name" + className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
+ )} + + {/* Event categories list */} + {eventCategories.length === 0 ? ( +

+ No event-specific categories. Global categories are available by default. +

+ ) : ( +
+ {eventCategories.map((category) => ( +
+ {category.name} + +
+ ))} +
+ )} + + {/* Show available global categories */} +
+

Global Categories (always available):

+
+ {categories + .filter(cat => cat.is_global) + .map(cat => ( + + {cat.name} + + ))} +
+
+
+ ); +}; + +EventCategoryManager.displayName = 'EventCategoryManager'; \ No newline at end of file diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index 1283278..06144c9 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -4,6 +4,8 @@ import { Button } from '../common'; import { clsx } from 'clsx'; import { api } from '../../config/api'; import { toast } from 'react-toastify'; +import { useQuery } from '@tanstack/react-query'; +import { categoriesService } from '../../services/categories.service'; interface PhotoUploadProps { eventId: number; @@ -14,8 +16,14 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl const [isUploading, setIsUploading] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); - const [photoType, setPhotoType] = useState<'individual' | 'collage'>('individual'); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); const fileInputRef = useRef(null); + + // Fetch categories for this event + const { data: categories = [] } = useQuery({ + queryKey: ['event-categories', eventId], + queryFn: () => categoriesService.getEventCategories(eventId), + }); const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); @@ -36,10 +44,20 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl setUploadProgress(0); const formData = new FormData(); - selectedFiles.forEach(file => { + selectedFiles.forEach((file, index) => { + console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`); formData.append('photos', file); }); - formData.append('type', photoType); + + if (selectedCategoryId) { + formData.append('category_id', selectedCategoryId.toString()); + } + + // Debug: Log FormData contents + console.log('FormData entries:'); + for (let pair of formData.entries()) { + console.log(pair[0], pair[1]); + } try { const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, { @@ -86,33 +104,23 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl return (
- {/* Photo Type Selection */} + {/* Category Selection */}
-
- - -
+
{/* File Input Area */} diff --git a/frontend/src/components/admin/ThemeCustomizer.tsx b/frontend/src/components/admin/ThemeCustomizer.tsx index 0b935a7..952024d 100644 --- a/frontend/src/components/admin/ThemeCustomizer.tsx +++ b/frontend/src/components/admin/ThemeCustomizer.tsx @@ -1,31 +1,39 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Palette, RotateCcw, Check, Upload } from 'lucide-react'; import { Button, Card, Input } from '../common'; import { PRESET_THEMES, type ThemeConfig } from '../../contexts/ThemeContext'; +import { settingsService } from '../../services/settings.service'; +import { toast } from 'react-toastify'; interface ThemeCustomizerProps { value: ThemeConfig; onChange: (theme: ThemeConfig) => void; presetName?: string; onPresetChange?: (presetName: string) => void; + isPreviewMode?: boolean; } export const ThemeCustomizer: React.FC = ({ value, onChange, presetName = 'default', - onPresetChange + onPresetChange, + isPreviewMode = false }) => { const [localTheme, setLocalTheme] = useState(value); - const [isPreviewMode, setIsPreviewMode] = useState(false); const [selectedPreset, setSelectedPreset] = useState(presetName); const [customCss, setCustomCss] = useState(value.customCss || ''); + const logoInputRef = useRef(null); useEffect(() => { setLocalTheme(value); setCustomCss(value.customCss || ''); }, [value]); + useEffect(() => { + setSelectedPreset(presetName); + }, [presetName]); + const handleChange = (key: keyof ThemeConfig, newValue: any) => { const updated = { ...localTheme, [key]: newValue }; setLocalTheme(updated); @@ -55,18 +63,31 @@ export const ThemeCustomizer: React.FC = ({ }; const handleReset = () => { - handlePresetSelect('default'); + const defaultPreset = PRESET_THEMES['default']; + if (defaultPreset) { + setSelectedPreset('default'); + setLocalTheme(defaultPreset.config); + setCustomCss(''); + onChange(defaultPreset.config); + if (onPresetChange) { + onPresetChange('default'); + } + } }; - const handleLogoUpload = (e: React.ChangeEvent) => { + const handleLogoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - handleChange('logoUrl', dataUrl); - }; - reader.readAsDataURL(file); + try { + // Upload to server + const logoUrl = await settingsService.uploadLogo(file); + // Update theme with the server URL + handleChange('logoUrl', logoUrl); + toast.success('Logo uploaded successfully'); + } catch (error) { + console.error('Failed to upload logo:', error); + toast.error('Failed to upload logo'); + } } }; @@ -252,33 +273,33 @@ export const ThemeCustomizer: React.FC = ({
{localTheme.logoUrl && ( Custom logo )} - + + {localTheme.logoUrl && ( @@ -303,34 +324,21 @@ export const ThemeCustomizer: React.FC = ({ {/* Actions */} -
-
- -
-
- - -
+
+ +
); diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index c6278c2..cf5cb64 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -4,4 +4,7 @@ export { AdminHeader } from './AdminHeader'; export { ThemeCustomizer } from './ThemeCustomizer'; export { PasswordChangeModal } from './PasswordChangeModal'; export { AdminAuthWrapper } from './AdminAuthWrapper'; -export { PhotoUpload } from './PhotoUpload'; \ No newline at end of file +export { PhotoUpload } from './PhotoUpload'; +export { CategoryManager } from './CategoryManager'; +export { EventCategoryManager } from './EventCategoryManager'; +export { CMSEditor } from './CMSEditor'; \ No newline at end of file diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx new file mode 100644 index 0000000..b3047a7 --- /dev/null +++ b/frontend/src/components/common/AuthenticatedImage.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; +import { getAuthToken } from '../../config/api'; + +interface AuthenticatedImageProps extends React.ImgHTMLAttributes { + src: string; + fallbackSrc?: string; + useWatermark?: boolean; +} + +export const AuthenticatedImage: React.FC = ({ + src, + fallbackSrc, + alt, + useWatermark = false, + ...props +}) => { + const [imageSrc, setImageSrc] = useState(''); + const [error, setError] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let objectUrl: string | null = null; + + const token = getAuthToken(); + + if (!src) { + setImageSrc(fallbackSrc || ''); + setIsLoading(false); + return; + } + + if (!token) { + console.warn('No auth token found for image:', src); + setImageSrc(fallbackSrc || ''); + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(false); + + // Create a new URL with auth header + const fetchImage = async () => { + try { + // If watermark is requested and this is a gallery photo, use the protected images endpoint + let imageUrl = src; + if (useWatermark && src.includes('/photos/')) { + // Extract gallery slug and photo ID from the URL + // URL format: /photos/events/active/{slug}/photos/{photoId}.jpg + const match = src.match(/\/photos\/events\/active\/([^\/]+)\/photos\/(\d+)\./); + if (match) { + const [, slug, photoId] = match; + imageUrl = `/api/images/${slug}/photo/${photoId}/view`; + } + } + + console.log('Fetching authenticated image:', imageUrl); + const response = await fetch(imageUrl, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + setImageSrc(objectUrl); + setIsLoading(false); + } catch (err) { + console.error('Failed to load image:', src, err); + setError(true); + setImageSrc(fallbackSrc || ''); + setIsLoading(false); + } + }; + + fetchImage(); + + // Cleanup function + return () => { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [src, fallbackSrc, useWatermark]); + + if (isLoading) { + return ( +
+ {/* Show a placeholder while loading */} +
+ ); + } + + if (error && fallbackSrc) { + return {alt}; + } + + if (!imageSrc) { + return null; + } + + return {alt}; +}; \ No newline at end of file diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx new file mode 100644 index 0000000..628f8dc --- /dev/null +++ b/frontend/src/components/common/DynamicFavicon.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +export const DynamicFavicon: React.FC = () => { + const { data: settings } = useQuery({ + queryKey: ['public-settings'], + queryFn: async () => { + try { + const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`); + if (response.ok) { + return response.json(); + } + return null; + } catch { + return null; + } + }, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + useEffect(() => { + if (settings?.branding_favicon_url) { + // Remove existing favicon links + const existingFavicons = document.querySelectorAll("link[rel*='icon']"); + existingFavicons.forEach(favicon => favicon.remove()); + + // Create new favicon link + const link = document.createElement('link'); + link.rel = 'icon'; + link.type = 'image/png'; + link.href = settings.branding_favicon_url.startsWith('http') + ? settings.branding_favicon_url + : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_favicon_url}`; + + document.head.appendChild(link); + } + }, [settings?.branding_favicon_url]); + + return null; +}; \ No newline at end of file diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx index 218dc83..33d8f32 100644 --- a/frontend/src/components/common/ErrorBoundary.tsx +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import type { ReactNode } from 'react'; import { AlertTriangle, RefreshCw } from 'lucide-react'; import { Button } from './Button'; +import i18n from '../../i18n/config'; interface Props { children: ReactNode; @@ -46,16 +47,16 @@ export class ErrorBoundary extends Component {

- Something went wrong + {i18n.t('errors.somethingWentWrong')}

- {this.state.error?.message || 'An unexpected error occurred. Please try refreshing the page.'} + {this.state.error?.message || i18n.t('errors.tryAgainLater')}

@@ -93,10 +94,10 @@ export class PageErrorBoundary extends Component {

- Oops! Something went wrong + {i18n.t('errors.oopsSomethingWentWrong')}

- We encountered an unexpected error. Don't worry, your data is safe. + {i18n.t('errors.unexpectedError')}

{import.meta.env.DEV && this.state.error && (
- Error Details + {i18n.t('errors.errorDetails')}
                   {this.state.error.stack}
diff --git a/frontend/src/components/common/LanguageSelector.tsx b/frontend/src/components/common/LanguageSelector.tsx
new file mode 100644
index 0000000..36a9d97
--- /dev/null
+++ b/frontend/src/components/common/LanguageSelector.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Globe } from 'lucide-react';
+
+const languages = [
+  { code: 'en', name: 'English', flag: '🇬🇧' },
+  { code: 'de', name: 'Deutsch', flag: '🇩🇪' },
+];
+
+export const LanguageSelector: React.FC = () => {
+  const { i18n } = useTranslation();
+  const [isOpen, setIsOpen] = React.useState(false);
+
+  const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0];
+
+  const handleLanguageChange = (languageCode: string) => {
+    i18n.changeLanguage(languageCode);
+    setIsOpen(false);
+  };
+
+  return (
+    
+ + + {isOpen && ( +
+ {languages.map((language) => ( + + ))} +
+ )} +
+ ); +}; + +LanguageSelector.displayName = 'LanguageSelector'; \ No newline at end of file diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 5fb45f4..cb81a95 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -12,4 +12,7 @@ export { SkeletonList } from './Skeleton'; export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator'; -export { SkipLink } from './SkipLink'; \ No newline at end of file +export { SkipLink } from './SkipLink'; +export { DynamicFavicon } from './DynamicFavicon'; +export { LanguageSelector } from './LanguageSelector'; +export { AuthenticatedImage } from './AuthenticatedImage'; \ No newline at end of file diff --git a/frontend/src/components/gallery/CountdownTimer.tsx b/frontend/src/components/gallery/CountdownTimer.tsx index 14ecc76..d4e8113 100644 --- a/frontend/src/components/gallery/CountdownTimer.tsx +++ b/frontend/src/components/gallery/CountdownTimer.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Clock, AlertCircle } from 'lucide-react'; import { differenceInSeconds } from 'date-fns'; +import { useTranslation } from 'react-i18next'; interface CountdownTimerProps { expiresAt: string; @@ -8,6 +9,7 @@ interface CountdownTimerProps { } export const CountdownTimer: React.FC = ({ expiresAt, className = '' }) => { + const { t } = useTranslation(); const [timeLeft, setTimeLeft] = useState<{ hours: number; minutes: number; @@ -43,7 +45,7 @@ export const CountdownTimer: React.FC = ({ expiresAt, class return (
- Gallery Expired + {t('gallery.expired')}
); } @@ -69,7 +71,7 @@ export const CountdownTimer: React.FC = ({ expiresAt, class {String(timeLeft.seconds).padStart(2, '0')}
- remaining + {t('gallery.remaining')}
); }; \ No newline at end of file diff --git a/frontend/src/components/gallery/ExpirationBanner.tsx b/frontend/src/components/gallery/ExpirationBanner.tsx index da8eb21..320dc5c 100644 --- a/frontend/src/components/gallery/ExpirationBanner.tsx +++ b/frontend/src/components/gallery/ExpirationBanner.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { AlertTriangle, Download } from 'lucide-react'; import Countdown from 'react-countdown'; import { parseISO } from 'date-fns'; +import { useTranslation } from 'react-i18next'; interface ExpirationBannerProps { daysRemaining: number; @@ -12,11 +13,12 @@ export const ExpirationBanner: React.FC = ({ daysRemaining, expiresAt }) => { + const { t } = useTranslation(); const expirationDate = parseISO(expiresAt); const countdownRenderer = ({ days, hours, minutes, completed }: any) => { if (completed) { - return Gallery has expired; + return {t('gallery.expired')}; } else { return ( @@ -39,12 +41,12 @@ export const ExpirationBanner: React.FC = ({
- Gallery expires in + {t('gallery.expiresIn', { count: daysRemaining })}
- Download your photos now! + {t('gallery.downloadBefore')}
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx new file mode 100644 index 0000000..1b642f2 --- /dev/null +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Calendar, Clock, Download, LogOut } from 'lucide-react'; +import { format, parseISO } from 'date-fns'; +import { useTranslation } from 'react-i18next'; +import { Button, LanguageSelector } from '../common'; +import { DynamicFavicon } from '../common/DynamicFavicon'; + +interface GalleryLayoutProps { + event: { + event_name: string; + event_type?: string; + event_date?: string; + expires_at?: string; + }; + brandingSettings?: { + company_name?: string; + company_tagline?: string; + support_email?: string; + footer_text?: string; + favicon_url?: string; + logo_url?: string; + }; + showLogout?: boolean; + onLogout?: () => void; + showDownloadAll?: boolean; + onDownloadAll?: () => void; + isDownloading?: boolean; + headerExtra?: React.ReactNode; + children: React.ReactNode; +} + +export const GalleryLayout: React.FC = ({ + event, + brandingSettings, + showLogout = false, + onLogout, + showDownloadAll = false, + onDownloadAll, + isDownloading = false, + headerExtra, + children, +}) => { + const { t } = useTranslation(); + return ( +
+ {/* Dynamic Favicon */} + + + {/* Header */} +
+
+
+
+ {/* Company logo */} + {brandingSettings?.logo_url && ( +
+ {brandingSettings.company_name +
+ )} + {/* Company branding */} + {!brandingSettings?.logo_url && brandingSettings?.company_name && ( +
+

{brandingSettings.company_name}

+ {brandingSettings.company_tagline && ( +

{brandingSettings.company_tagline}

+ )} +
+ )} +
+

{event.event_name}

+ {(event.event_date || event.expires_at) && ( +
+ {event.event_date && ( + + + {format(parseISO(event.event_date), 'MMMM d, yyyy')} + + )} + {event.expires_at && ( + + + {t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')} + + )} +
+ )} +
+
+ +
+ {headerExtra} + + {showDownloadAll && onDownloadAll && ( + + )} + {showLogout && onLogout && ( + + )} +
+
+
+
+ + {/* Main Content */} +
{children}
+ + {/* Footer */} +
+
+ {brandingSettings?.support_email && ( +

+ {t('gallery.needHelp')}{' '} + + {brandingSettings.support_email} + +

+ )} +

+ {brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} +

+ {brandingSettings?.company_name && brandingSettings?.company_tagline && ( +

+ {brandingSettings.company_name} - {brandingSettings.company_tagline} +

+ )} + {/* Legal Links */} +
+ + {t('legal.impressum')} + + | + + {t('legal.datenschutz')} + +
+
+
+
+ ); +}; + +GalleryLayout.displayName = 'GalleryLayout'; \ No newline at end of file diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 18ffd93..bd8a3ec 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -1,14 +1,16 @@ -import React, { useState, useMemo, useEffect } from 'react'; -import { Download, Grid, Square, LogOut, Calendar, Clock, Search, SortAsc } from 'lucide-react'; -import { format, differenceInDays, parseISO } from 'date-fns'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; +import { differenceInDays, parseISO } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; -import { Button, Input, SkeletonGalleryGrid, Skeleton } from '../common'; +import { Button, SkeletonGalleryGrid, Skeleton } from '../common'; import { useGalleryAuth, useTheme } from '../../contexts'; import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery'; import { PhotoGrid } from './PhotoGrid'; import { ExpirationBanner } from './ExpirationBanner'; import { CountdownTimer } from './CountdownTimer'; +import { GalleryLayout } from './GalleryLayout'; +import { PhotoFilterBar } from './PhotoFilterBar'; import { analyticsService } from '../../services/analytics.service'; import { api } from '../../config/api'; @@ -26,13 +28,14 @@ interface GalleryViewProps { } export const GalleryView: React.FC = ({ slug, event }) => { + const { t } = useTranslation(); const { logout } = useGalleryAuth(); const { setTheme } = useTheme(); - const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all'); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date'); - const [showSortMenu, setShowSortMenu] = useState(false); const [brandingSettings, setBrandingSettings] = useState(null); + const themeAppliedRef = useRef(false); // Fetch photos const { data, isLoading, error } = useGalleryPhotos(slug); @@ -48,40 +51,56 @@ export const GalleryView: React.FC = ({ slug, event }) => { staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); - // Apply theme and branding settings + // Apply branding settings useEffect(() => { if (settingsData) { - // Apply branding settings setBrandingSettings({ company_name: settingsData.branding_company_name || '', company_tagline: settingsData.branding_company_tagline || '', support_email: settingsData.branding_support_email || '', footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.', watermark_enabled: settingsData.branding_watermark_enabled || false, + logo_url: settingsData.branding_logo_url || null, }); - - // Apply theme settings - if (settingsData.theme_config) { - setTheme(settingsData.theme_config); - } } - }, [settingsData, setTheme]); + }, [settingsData]); - // Apply event-specific theme if available + // Apply theme only once when component mounts and settings are loaded useEffect(() => { - if (event.color_theme) { - try { - const eventTheme = JSON.parse(event.color_theme); - console.log('Applying event-specific theme:', eventTheme); - setTheme(eventTheme); - } catch (e) { - console.error('Failed to parse event theme:', e); + if (!themeAppliedRef.current && settingsData) { + let themeToApply = null; + + if (event.color_theme) { + try { + // Check if it's a valid JSON string + if (event.color_theme.startsWith('{')) { + const eventTheme = JSON.parse(event.color_theme); + themeToApply = eventTheme; + } else { + // Handle legacy theme names - use global theme + if (settingsData.theme_config) { + themeToApply = settingsData.theme_config; + } + } + } catch (e) { + console.error('Failed to parse event theme:', e); + // Fall back to global theme + if (settingsData.theme_config) { + themeToApply = settingsData.theme_config; + } + } + } else if (settingsData.theme_config) { + // No event theme, use global theme + themeToApply = settingsData.theme_config; + } + + // Apply theme only once + if (themeToApply) { + themeAppliedRef.current = true; + setTheme(themeToApply); } - } else if (settingsData?.theme_config) { - // Fall back to global theme if no event-specific theme - console.log('No event theme, using global theme'); } - }, [event.color_theme, setTheme, settingsData]); + }, [settingsData]); // Only depend on settingsData, not setTheme or event // Calculate days until expiration const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date()); @@ -93,11 +112,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { let photos = [...data.photos]; - // Apply view mode filter - if (viewMode === 'collages') { - photos = photos.filter(photo => photo.type === 'collage'); - } else if (viewMode === 'individual') { - photos = photos.filter(photo => photo.type === 'individual'); + // Apply category filter + if (selectedCategoryId) { + photos = photos.filter(photo => photo.category_id === selectedCategoryId); } // Apply search filter @@ -122,7 +139,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { }); return photos; - }, [data?.photos, viewMode, searchTerm, sortBy]); + }, [data?.photos, selectedCategoryId, searchTerm, sortBy]); const handleDownloadAll = () => { downloadAllMutation.mutate(slug); @@ -185,9 +202,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { return (
-

Failed to load photos

+

{t('gallery.failedToLoad')}

@@ -195,71 +212,28 @@ export const GalleryView: React.FC = ({ slug, event }) => { } return ( -
+ 0 ? ( + + ) : null + } + > {/* Expiration Banner */} {showUrgentWarning && ( )} - {/* Header */} -
-
-
-
- {/* Company branding */} - {brandingSettings?.company_name && ( -
-

{brandingSettings.company_name}

- {brandingSettings.company_tagline && ( -

{brandingSettings.company_tagline}

- )} -
- )} -
-

{event.event_name}

-
- - - {format(parseISO(event.event_date), 'MMMM d, yyyy')} - - - - Expires {format(parseISO(event.expires_at), 'MMM d')} - -
-
-
- -
- {daysUntilExpiration <= 1 && daysUntilExpiration > 0 && ( - - )} - - -
-
-
-
- {/* Welcome Message */} {event.welcome_message && ( -
+

{event.welcome_message}

@@ -267,125 +241,24 @@ export const GalleryView: React.FC = ({ slug, event }) => { )} {/* Search and Filters */} -
-
- {/* Search Bar */} -
- } - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - /> -
- - {/* Sort Dropdown */} -
- - - {showSortMenu && ( -
- - - -
- )} -
-
- - {/* View Mode Toggle */} -
-
- - - -
- -

- {filteredPhotos.length} {filteredPhotos.length === 1 ? 'photo' : 'photos'} -

-
+
+ {/* Photo Grid */} - -
- - {/* Footer */} -
-
- {brandingSettings?.support_email && ( -

- Need help? Contact us at{' '} - - {brandingSettings.support_email} - -

- )} -

- {brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} -

- {brandingSettings?.company_name && brandingSettings?.company_tagline && ( -

- {brandingSettings.company_name} - {brandingSettings.company_tagline} -

- )} +
+
-
-
+
+ ); }; \ No newline at end of file diff --git a/frontend/src/components/gallery/PhotoFilterBar.tsx b/frontend/src/components/gallery/PhotoFilterBar.tsx new file mode 100644 index 0000000..6413a7e --- /dev/null +++ b/frontend/src/components/gallery/PhotoFilterBar.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import { Search, SortAsc, Grid } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Input } from '../common'; + +interface PhotoCategory { + id: number; + name: string; + slug: string; + is_global: boolean; +} + +interface Photo { + id: number; + category_id?: number; +} + +interface PhotoFilterBarProps { + categories?: PhotoCategory[]; + photos: Photo[]; + selectedCategoryId: number | null; + onCategoryChange: (categoryId: number | null) => void; + searchTerm: string; + onSearchChange: (term: string) => void; + sortBy: 'date' | 'name' | 'size'; + onSortChange: (sort: 'date' | 'name' | 'size') => void; + photoCount: number; +} + +export const PhotoFilterBar: React.FC = ({ + categories = [], + photos, + selectedCategoryId, + onCategoryChange, + searchTerm, + onSearchChange, + sortBy, + onSortChange, + photoCount, +}) => { + const { t } = useTranslation(); + const [showSortMenu, setShowSortMenu] = useState(false); + + return ( +
+ {/* Search and Sort */} +
+ {/* Search Bar */} +
+ } + value={searchTerm} + onChange={(e) => onSearchChange(e.target.value)} + /> +
+ + {/* Sort Dropdown */} +
+ + + {showSortMenu && ( +
+ + + +
+ )} +
+
+ + {/* Category Filter */} + {categories && categories.length > 0 && ( +
+
+ + {categories.map((category) => { + const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length; + if (categoryPhotoCount === 0) return null; + + return ( + + ); + })} +
+ +

+ {photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')} +

+
+ )} +
+ ); +}; + +PhotoFilterBar.displayName = 'PhotoFilterBar'; \ No newline at end of file diff --git a/frontend/src/components/gallery/PhotoGrid.tsx b/frontend/src/components/gallery/PhotoGrid.tsx index 8169a4e..336764c 100644 --- a/frontend/src/components/gallery/PhotoGrid.tsx +++ b/frontend/src/components/gallery/PhotoGrid.tsx @@ -1,28 +1,48 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Download, Maximize2, Check, Package } from 'lucide-react'; import { useInView } from 'react-intersection-observer'; -import { toast } from 'react-toastify'; +import { toast as toastify } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; import type { Photo } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; import { PhotoLightbox } from './PhotoLightbox'; -import { Button } from '../common'; +import { Button, AuthenticatedImage } from '../common'; import { galleryService } from '../../services/gallery.service'; import { analyticsService } from '../../services/analytics.service'; interface PhotoGridProps { photos: Photo[]; slug: string; + categoryId?: number | null; } -export const PhotoGrid: React.FC = ({ photos, slug }) => { +export const PhotoGrid: React.FC = ({ photos, slug, categoryId }) => { + const { t } = useTranslation(); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); const downloadPhotoMutation = useDownloadPhoto(); - const handlePhotoClick = (index: number) => { - if (isSelectionMode) { + // Clear selection when category changes + useEffect(() => { + setSelectedPhotos(new Set()); + }, [categoryId]); + + const handlePhotoClick = (index: number, e?: React.MouseEvent) => { + // Check for ctrl/cmd+click for quick selection + if (e && (e.ctrlKey || e.metaKey)) { + if (!isSelectionMode) { + setIsSelectionMode(true); + } + const newSelected = new Set(selectedPhotos); + if (newSelected.has(photos[index].id)) { + newSelected.delete(photos[index].id); + } else { + newSelected.add(photos[index].id); + } + setSelectedPhotos(newSelected); + } else if (isSelectionMode) { const newSelected = new Set(selectedPhotos); if (newSelected.has(photos[index].id)) { newSelected.delete(photos[index].id); @@ -66,7 +86,7 @@ export const PhotoGrid: React.FC = ({ photos, slug }) => { const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id)); - toast.info(`Downloading ${selectedPhotos.size} photos...`); + toastify.info(t('gallery.downloading', { count: selectedPhotos.size })); // Download each selected photo const downloadPromises = selectedPhotosList.map(photo => @@ -79,7 +99,7 @@ export const PhotoGrid: React.FC = ({ photos, slug }) => { try { await Promise.all(downloadPromises); - toast.success(`Downloaded ${selectedPhotos.size} photos!`); + toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size })); // Track bulk download analyticsService.trackGalleryEvent('bulk_download', { @@ -91,14 +111,14 @@ export const PhotoGrid: React.FC = ({ photos, slug }) => { setSelectedPhotos(new Set()); setIsSelectionMode(false); } catch (error) { - toast.error('Some photos failed to download'); + toastify.error(t('gallery.downloadError')); } }; if (photos.length === 0) { return (
-

No photos found

+

{t('gallery.noPhotosFound')}

); } @@ -108,24 +128,39 @@ export const PhotoGrid: React.FC = ({ photos, slug }) => { {/* Selection Mode Controls */} {photos.length > 1 && (
- +
+ + {!isSelectionMode && ( + + )} +
{isSelectionMode && (
- {selectedPhotos.size} selected + {t('gallery.photosSelected', { count: selectedPhotos.size })} {selectedPhotos.size > 0 && ( )}
@@ -150,7 +185,7 @@ export const PhotoGrid: React.FC = ({ photos, slug }) => { photo={photo} isSelected={selectedPhotos.has(photo.id)} isSelectionMode={isSelectionMode} - onClick={() => handlePhotoClick(index)} + onClick={(e) => handlePhotoClick(index, e)} onDownload={(e) => handleDownload(photo, e)} /> ))} @@ -173,7 +208,7 @@ interface PhotoThumbnailProps { photo: Photo; isSelected: boolean; isSelectionMode: boolean; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void; } @@ -192,12 +227,12 @@ const PhotoThumbnail: React.FC = ({ return (
onClick(e)} > {inView ? ( <> - {photo.filename} = ({ className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors" onClick={(e) => { e.stopPropagation(); - onClick(); + onClick(e); }} aria-label="View full size" > @@ -232,7 +267,7 @@ const PhotoThumbnail: React.FC = ({ {/* Selection checkbox */} {isSelectionMode && (
-
+
{isSelected && }
diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index d6edcd2..ef73c26 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react'; import type { Photo } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; +import { AuthenticatedImage } from '../common'; interface PhotoLightboxProps { photos: Photo[]; @@ -241,7 +242,7 @@ export const PhotoLightbox: React.FC = ({ onTouchEnd={handleTouchEnd} style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }} > - {currentPhoto.filename} = ({ transition: isDragging ? 'none' : 'transform 0.2s', }} draggable={false} + useWatermark={true} />
diff --git a/frontend/src/components/gallery/index.ts b/frontend/src/components/gallery/index.ts index 91c74ec..dbd7c76 100644 --- a/frontend/src/components/gallery/index.ts +++ b/frontend/src/components/gallery/index.ts @@ -2,4 +2,6 @@ export { GalleryView } from './GalleryView'; export { PhotoGrid } from './PhotoGrid'; export { PhotoLightbox } from './PhotoLightbox'; export { ExpirationBanner } from './ExpirationBanner'; -export { CountdownTimer } from './CountdownTimer'; \ No newline at end of file +export { CountdownTimer } from './CountdownTimer'; +export { GalleryLayout } from './GalleryLayout'; +export { PhotoFilterBar } from './PhotoFilterBar'; \ No newline at end of file diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 4acd2b1..9ef29f3 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -26,6 +26,11 @@ api.interceptors.request.use( config.headers.Authorization = `Bearer ${token}`; } + // Don't set Content-Type for FormData - let browser set it with boundary + if (config.data instanceof FormData) { + delete config.headers['Content-Type']; + } + return config; }, (error) => { diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index a07c2a8..a77966c 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; import type { ReactNode } from 'react'; export interface ThemeConfig { @@ -107,7 +107,7 @@ export const ThemeProvider: React.FC = ({ const [theme, setTheme] = useState(initialTheme); const [themeName, setThemeName] = useState(initialThemeName); - const applyTheme = (themeConfig: ThemeConfig) => { + const applyTheme = useCallback((themeConfig: ThemeConfig) => { const root = document.documentElement; // Apply CSS variables @@ -154,56 +154,76 @@ export const ThemeProvider: React.FC = ({ } styleElement.textContent = themeConfig.customCss; } - }; + }, []); - const setThemeByName = (name: string) => { + const setThemeConfig = useCallback((newTheme: ThemeConfig) => { + setTheme(newTheme); + applyTheme(newTheme); + }, [applyTheme]); + + const setThemeByName = useCallback((name: string) => { const presetTheme = PRESET_THEMES[name]; if (presetTheme) { setThemeName(name); setTheme(presetTheme.config); applyTheme(presetTheme.config); } - }; + }, [applyTheme]); - const resetTheme = () => { + const resetTheme = useCallback(() => { setThemeByName('default'); - }; + }, [setThemeByName]); + // Apply theme when it changes, but skip if it's the same useEffect(() => { - applyTheme(theme); - }, [theme]); + const root = document.documentElement; + const currentPrimary = root.style.getPropertyValue('--color-primary'); + + // Only apply if the theme has actually changed + if (currentPrimary !== theme.primaryColor) { + applyTheme(theme); + } + }, [theme, applyTheme]); - // Load theme from localStorage on mount + // Load theme from localStorage on mount (skip if in gallery view) useEffect(() => { - const savedTheme = localStorage.getItem('gallery-theme'); - if (savedTheme) { - try { - const parsed = JSON.parse(savedTheme); - setTheme(parsed.config); - setThemeName(parsed.name); - } catch (e) { - console.error('Failed to load saved theme:', e); + // Check if we're in a gallery view by looking at the URL + const isGalleryView = window.location.pathname.includes('/gallery/'); + if (!isGalleryView) { + const savedTheme = localStorage.getItem('gallery-theme'); + if (savedTheme) { + try { + const parsed = JSON.parse(savedTheme); + setTheme(parsed.config); + setThemeName(parsed.name); + } catch (e) { + console.error('Failed to load saved theme:', e); + } } } }, []); // Save theme to localStorage when it changes useEffect(() => { - localStorage.setItem('gallery-theme', JSON.stringify({ name: themeName, config: theme })); + // Only save if theme has actually changed + const currentSaved = localStorage.getItem('gallery-theme'); + const newValue = JSON.stringify({ name: themeName, config: theme }); + if (currentSaved !== newValue) { + localStorage.setItem('gallery-theme', newValue); + } }, [theme, themeName]); + const contextValue = useMemo(() => ({ + theme, + themeName, + setTheme: setThemeConfig, + setThemeByName, + applyTheme, + resetTheme + }), [theme, themeName, setThemeConfig, setThemeByName, applyTheme, resetTheme]); + return ( - { - setTheme(newTheme); - applyTheme(newTheme); - }, - setThemeByName, - applyTheme, - resetTheme - }}> + {children} ); diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts new file mode 100644 index 0000000..4f4c945 --- /dev/null +++ b/frontend/src/i18n/config.ts @@ -0,0 +1,36 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import HttpBackend from 'i18next-http-backend'; + +import enTranslations from './locales/en.json'; +import deTranslations from './locales/de.json'; + +i18n + .use(HttpBackend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: false, + + resources: { + en: { + translation: enTranslations, + }, + de: { + translation: deTranslations, + }, + }, + + interpolation: { + escapeValue: false, + }, + + detection: { + order: ['localStorage', 'cookie', 'navigator', 'htmlTag'], + caches: ['localStorage', 'cookie'], + }, + }); + +export default i18n; \ No newline at end of file diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json new file mode 100644 index 0000000..bbe5a4e --- /dev/null +++ b/frontend/src/i18n/locales/de.json @@ -0,0 +1,284 @@ +{ + "common": { + "loading": "Wird geladen...", + "error": "Fehler", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "add": "Hinzufügen", + "search": "Suchen", + "filter": "Filtern", + "sortBy": "Sortieren nach", + "yes": "Ja", + "no": "Nein", + "back": "Zurück", + "next": "Weiter", + "previous": "Zurück", + "close": "Schließen", + "logout": "Abmelden", + "download": "Herunterladen", + "downloadAll": "Alle herunterladen", + "uploading": "Wird hochgeladen...", + "uploaded": "Hochgeladen", + "photo": "Foto", + "photos": "Fotos" + }, + "navigation": { + "dashboard": "Dashboard", + "events": "Veranstaltungen", + "archives": "Archive", + "settings": "Einstellungen", + "branding": "Branding", + "analytics": "Analytik", + "emailSettings": "E-Mail-Einstellungen", + "cmsPages": "CMS-Seiten" + }, + "auth": { + "login": "Anmelden", + "password": "Passwort", + "enterPassword": "Galerie-Passwort eingeben", + "passwordPlaceholder": "Geben Sie das Galerie-Passwort ein", + "invalidPassword": "Ungültiges Passwort", + "sessionExpired": "Sitzung abgelaufen", + "pleaseEnterPassword": "Bitte geben Sie ein Passwort ein", + "passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben." + }, + "gallery": { + "title": "Fotogalerie", + "welcomeMessage": "Willkommensnachricht", + "expiresOn": "Läuft ab am", + "expires": "Läuft ab", + "expired": "Abgelaufen", + "daysRemaining": "{{days}} Tage verbleibend", + "dayRemaining": "1 Tag verbleibend", + "hoursRemaining": "{{hours}} Stunden verbleibend", + "expiredMessage": "Diese Galerie ist am {{date}} abgelaufen", + "contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen", + "searchPhotos": "Fotos nach Dateiname suchen...", + "sortByDate": "Nach Datum sortieren", + "sortByName": "Nach Name sortieren", + "sortBySize": "Nach Größe sortieren", + "allPhotos": "Alle Fotos", + "downloadSelected": "Ausgewählte herunterladen", + "shareGallery": "Galerie teilen", + "needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter", + "noPhotosFound": "Keine Fotos gefunden", + "failedToLoad": "Fotos konnten nicht geladen werden", + "tryAgain": "Erneut versuchen", + "loading": "Galerie wird geladen...", + "expiredOn": "Diese Galerie ist am {{date}} abgelaufen.", + "contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.", + "expiresIn": "Galerie läuft in {{count}} Tag ab", + "expiresIn_plural": "Galerie läuft in {{count}} Tagen ab", + "downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.", + "viewGallery": "Galerie anzeigen", + "downloadAll": "Alle herunterladen", + "downloading": "Lade {{count}} Foto herunter...", + "downloading_plural": "Lade {{count}} Fotos herunter...", + "downloadedPhotos": "{{count}} Foto heruntergeladen!", + "downloadedPhotos_plural": "{{count}} Fotos heruntergeladen!", + "downloadError": "Einige Fotos konnten nicht heruntergeladen werden", + "selectPhotos": "Fotos auswählen", + "cancelSelection": "Auswahl abbrechen", + "photosSelected": "{{count}} ausgewählt", + "selectAll": "Alle auswählen", + "deselectAll": "Auswahl aufheben", + "downloadSelected": "{{count}} ausgewählte herunterladen", + "remaining": "verbleibend", + "selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen" + }, + "categories": { + "title": "Fotokategorien", + "global": "Globale Kategorien", + "eventSpecific": "Veranstaltungsspezifische Kategorien", + "addCategory": "Kategorie hinzufügen", + "categoryName": "Kategoriename", + "noCategory": "Keine Kategorie", + "noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.", + "deleteConfirm": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten?", + "cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu." + }, + "events": { + "title": "Veranstaltungen", + "createEvent": "Veranstaltung erstellen", + "eventDetails": "Veranstaltungsdetails", + "eventName": "Veranstaltungsname", + "eventType": "Veranstaltungstyp", + "eventDate": "Veranstaltungsdatum", + "hostEmail": "Gastgeber-E-Mail", + "adminEmail": "Admin-E-Mail", + "expirationDate": "Ablaufdatum", + "active": "Aktiv", + "archived": "Archiviert", + "photoCount": "{{count}} Fotos", + "totalSize": "Gesamtgröße", + "shareLink": "Freigabelink", + "copyLink": "Link kopieren", + "linkCopied": "Link kopiert!", + "viewGallery": "Galerie ansehen", + "uploadPhotos": "Fotos hochladen", + "archiveEvent": "Veranstaltung archivieren", + "archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "extendExpiration": "Um {{days}} Tage verlängern" + }, + "settings": { + "title": "Systemeinstellungen", + "general": { + "title": "Allgemein", + "siteConfiguration": "Website-Konfiguration", + "siteUrl": "Website-URL", + "siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet", + "defaultExpiration": "Standardablauf (Tage)", + "maxFileSize": "Max. Dateigröße (MB)", + "allowedFileTypes": "Erlaubte Dateitypen", + "allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen", + "featureToggles": "Funktionsschalter", + "enableWatermark": "Wasserzeichen auf Fotos aktivieren", + "enableAnalytics": "Analytics-Tracking aktivieren", + "enableRegistration": "Selbstregistrierung für Admins erlauben", + "maintenanceMode": "Wartungsmodus aktivieren", + "language": "Sprache", + "saveSettings": "Allgemeine Einstellungen speichern" + }, + "storage": { + "title": "Speicher", + "overview": "Speicherübersicht", + "totalUsed": "Gesamt verwendet", + "archiveStorage": "Archivspeicher", + "storageLimit": "Speicherlimit", + "storageUsage": "Speichernutzung", + "storageByEvent": "Speicher nach Veranstaltung", + "storageManagement": "Speicherverwaltung", + "storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien." + }, + "security": { + "title": "Sicherheit", + "passwordSettings": "Passworteinstellungen", + "requirePassword": "Passwort für alle Galerien erforderlich", + "minPasswordLength": "Minimale Passwortlänge", + "sessionAuth": "Sitzung & Authentifizierung", + "sessionTimeout": "Sitzungs-Timeout (Minuten)", + "maxLoginAttempts": "Max. Anmeldeversuche", + "enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren", + "recaptchaSettings": "reCAPTCHA-Einstellungen", + "enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren", + "siteKey": "Site-Schlüssel", + "secretKey": "Geheimer Schlüssel", + "recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von", + "saveSettings": "Sicherheitseinstellungen speichern" + }, + "categories": { + "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." + } + }, + "branding": { + "title": "Branding & Anpassung", + "companyInfo": "Unternehmensinformationen", + "companyName": "Unternehmensname", + "companyTagline": "Unternehmens-Slogan", + "supportEmail": "Support-E-Mail", + "footerText": "Fußzeilentext", + "logo": "Logo", + "uploadLogo": "Logo hochladen", + "removeLogo": "Logo entfernen", + "favicon": "Favicon", + "uploadFavicon": "Favicon hochladen", + "removeFavicon": "Favicon entfernen", + "watermark": "Wasserzeichen", + "enableWatermark": "Wasserzeichen auf Fotos aktivieren", + "theme": "Theme", + "themeCustomization": "Theme-Anpassung", + "selectPreset": "Vorgefertigtes Theme auswählen", + "colors": "Farben", + "primaryColor": "Primärfarbe", + "secondaryColor": "Sekundärfarbe", + "accentColor": "Akzentfarbe", + "customCSS": "Benutzerdefiniertes CSS", + "preview": "Vorschau", + "previewInNewTab": "Vorschau in neuem Tab", + "reset": "Zurücksetzen", + "saveChanges": "Änderungen speichern" + }, + "admin": { + "title": "Admin-Panel", + "welcome": "Willkommen zurück, {{name}}", + "recentActivity": "Letzte Aktivitäten", + "systemStatus": "Systemstatus", + "totalEvents": "Gesamte Veranstaltungen", + "activeGalleries": "Aktive Galerien", + "storageUsed": "Speicher verwendet", + "totalPhotos": "Gesamte Fotos", + "storagePercent": "{{percent}}% von {{limit}}", + "notifications": "Benachrichtigungen", + "viewAllNotifications": "Alle Benachrichtigungen anzeigen", + "changePassword": "Passwort ändern", + "loadingDashboard": "Dashboard wird geladen...", + "activeEvents": "Aktive Veranstaltungen", + "expiringSoon": "Demnächst ablaufend", + "next7Days": "Nächste 7 Tage", + "totalViews": "Gesamtaufrufe", + "downloads": "Downloads", + "percentFromLastWeek": "{{percent}}% gegenüber letzter Woche", + "dashboardSubtitle": "Willkommen zurück! Hier ist, was mit Ihren Galerien passiert.", + "eventsExpiringSoon": "Demnächst ablaufende Veranstaltungen", + "noEventsExpiring": "Keine Veranstaltungen laufen in den nächsten 7 Tagen ab", + "daysLeft": "{{count}} Tag verbleibend", + "daysLeft_plural": "{{count}} Tage verbleibend", + "viewAllExpiringEvents": "Alle {{count}} ablaufenden Veranstaltungen anzeigen", + "noRecentActivity": "Keine aktuellen Aktivitäten", + "viewAllActivity": "Alle Aktivitäten anzeigen", + "quickActions": "Schnellaktionen", + "viewArchives": "Archive anzeigen", + "analytics": "Analytik" + }, + "errors": { + "notFound": "Nicht gefunden", + "galleryNotFound": "Galerie nicht gefunden", + "galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.", + "unauthorized": "Nicht autorisiert", + "forbidden": "Verboten", + "serverError": "Serverfehler", + "somethingWentWrong": "Etwas ist schiefgelaufen", + "tryAgainLater": "Bitte versuchen Sie es später erneut", + "refreshPage": "Seite neu laden", + "oopsSomethingWentWrong": "Ups! Etwas ist schiefgelaufen", + "unexpectedError": "Es ist ein unerwarteter Fehler aufgetreten. Keine Sorge, Ihre Daten sind sicher.", + "goToHomepage": "Zur Startseite", + "errorDetails": "Fehlerdetails" + }, + "legal": { + "impressum": "Impressum", + "datenschutz": "Datenschutzerklärung", + "termsOfService": "Nutzungsbedingungen", + "cookiePolicy": "Cookie-Richtlinie" + }, + "toast": { + "saveSuccess": "Änderungen erfolgreich gespeichert", + "saveError": "Fehler beim Speichern der Änderungen", + "deleteSuccess": "Erfolgreich gelöscht", + "deleteError": "Fehler beim Löschen", + "uploadSuccess": "Upload erfolgreich abgeschlossen", + "uploadError": "Upload fehlgeschlagen", + "loginSuccess": "Anmeldung erfolgreich", + "loginError": "Anmeldung fehlgeschlagen", + "passwordChanged": "Passwort erfolgreich geändert", + "linkCopied": "Link in Zwischenablage kopiert", + "eventCreated": "Veranstaltung erfolgreich erstellt", + "eventUpdated": "Veranstaltung erfolgreich aktualisiert", + "eventArchived": "Veranstaltung erfolgreich archiviert", + "settingsSaved": "Einstellungen erfolgreich gespeichert", + "themeUpdated": "Theme erfolgreich aktualisiert", + "brandingUpdated": "Branding erfolgreich aktualisiert", + "categoryAdded": "Kategorie erfolgreich hinzugefügt", + "categoryDeleted": "Kategorie erfolgreich gelöscht", + "categoryUpdated": "Kategorie erfolgreich aktualisiert", + "emailConfigSaved": "E-Mail-Konfiguration erfolgreich gespeichert", + "testEmailSent": "Test-E-Mail erfolgreich gesendet", + "pageUpdated": "Seite erfolgreich aktualisiert", + "archiveRestored": "Archiv erfolgreich wiederhergestellt", + "archiveDeleted": "Archiv dauerhaft gelöscht" + } +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..5f301b1 --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,284 @@ +{ + "common": { + "loading": "Loading...", + "error": "Error", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "search": "Search", + "filter": "Filter", + "sortBy": "Sort by", + "yes": "Yes", + "no": "No", + "back": "Back", + "next": "Next", + "previous": "Previous", + "close": "Close", + "logout": "Logout", + "download": "Download", + "downloadAll": "Download All", + "uploading": "Uploading...", + "uploaded": "Uploaded", + "photo": "photo", + "photos": "photos" + }, + "navigation": { + "dashboard": "Dashboard", + "events": "Events", + "archives": "Archives", + "settings": "Settings", + "branding": "Branding", + "analytics": "Analytics", + "emailSettings": "Email Settings", + "cmsPages": "CMS Pages" + }, + "auth": { + "login": "Login", + "password": "Password", + "enterPassword": "Enter Gallery Password", + "passwordPlaceholder": "Enter the gallery password", + "invalidPassword": "Invalid password", + "sessionExpired": "Session expired", + "pleaseEnterPassword": "Please enter a password", + "passwordHint": "The password was provided by the event organizer. Contact them if you don't have it." + }, + "gallery": { + "title": "Photo Gallery", + "welcomeMessage": "Welcome Message", + "expiresOn": "Expires on", + "expires": "Expires", + "expired": "Expired", + "daysRemaining": "{{days}} days remaining", + "dayRemaining": "1 day remaining", + "hoursRemaining": "{{hours}} hours remaining", + "expiredMessage": "This gallery expired on {{date}}", + "contactOrganizer": "Please contact the event organizer if you need access to these photos", + "searchPhotos": "Search photos by filename...", + "sortByDate": "Sort by Date", + "sortByName": "Sort by Name", + "sortBySize": "Sort by Size", + "allPhotos": "All Photos", + "downloadSelected": "Download Selected", + "shareGallery": "Share Gallery", + "needHelp": "Need help? Contact us at", + "noPhotosFound": "No photos found", + "failedToLoad": "Failed to load photos", + "tryAgain": "Try Again", + "loading": "Loading gallery...", + "expiredOn": "This gallery expired on {{date}}.", + "contactOrganizer": "Please contact the event organizer if you need access to these photos.", + "expiresIn": "Gallery expires in {{count}} day", + "expiresIn_plural": "Gallery expires in {{count}} days", + "downloadBefore": "Download your photos before they're no longer available.", + "viewGallery": "View Gallery", + "downloadAll": "Download All", + "downloading": "Downloading {{count}} photo...", + "downloading_plural": "Downloading {{count}} photos...", + "downloadedPhotos": "Downloaded {{count}} photo!", + "downloadedPhotos_plural": "Downloaded {{count}} photos!", + "downloadError": "Some photos failed to download", + "selectPhotos": "Select Photos", + "cancelSelection": "Cancel Selection", + "photosSelected": "{{count}} selected", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "downloadSelected": "Download {{count}} Selected", + "remaining": "remaining", + "selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos" + }, + "categories": { + "title": "Photo Categories", + "global": "Global Categories", + "eventSpecific": "Event-Specific Categories", + "addCategory": "Add Category", + "categoryName": "Category name", + "noCategory": "No category", + "noCategoriesYet": "No categories yet. Create your first category to organize photos.", + "deleteConfirm": "Are you sure you want to delete \"{{name}}\"?", + "cannotDelete": "Cannot delete category with photos. Please reassign photos first." + }, + "events": { + "title": "Events", + "createEvent": "Create Event", + "eventDetails": "Event Details", + "eventName": "Event Name", + "eventType": "Event Type", + "eventDate": "Event Date", + "hostEmail": "Host Email", + "adminEmail": "Admin Email", + "expirationDate": "Expiration Date", + "active": "Active", + "archived": "Archived", + "photoCount": "{{count}} photos", + "totalSize": "Total Size", + "shareLink": "Share Link", + "copyLink": "Copy Link", + "linkCopied": "Link copied!", + "viewGallery": "View Gallery", + "uploadPhotos": "Upload Photos", + "archiveEvent": "Archive Event", + "archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.", + "extendExpiration": "Extend {{days}} Days" + }, + "settings": { + "title": "System Settings", + "general": { + "title": "General", + "siteConfiguration": "Site Configuration", + "siteUrl": "Site URL", + "siteUrlHelp": "Used for generating gallery links in emails", + "defaultExpiration": "Default Expiration (days)", + "maxFileSize": "Max File Size (MB)", + "allowedFileTypes": "Allowed File Types", + "allowedFileTypesHelp": "Comma-separated list of file extensions", + "featureToggles": "Feature Toggles", + "enableWatermark": "Enable watermark on photos", + "enableAnalytics": "Enable analytics tracking", + "enableRegistration": "Allow self-registration for admins", + "maintenanceMode": "Enable maintenance mode", + "language": "Language", + "saveSettings": "Save General Settings" + }, + "storage": { + "title": "Storage", + "overview": "Storage Overview", + "totalUsed": "Total Used", + "archiveStorage": "Archive Storage", + "storageLimit": "Storage Limit", + "storageUsage": "Storage Usage", + "storageByEvent": "Storage by Event", + "storageManagement": "Storage Management", + "storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries." + }, + "security": { + "title": "Security", + "passwordSettings": "Password Settings", + "requirePassword": "Require password for all galleries", + "minPasswordLength": "Minimum Password Length", + "sessionAuth": "Session & Authentication", + "sessionTimeout": "Session Timeout (minutes)", + "maxLoginAttempts": "Max Login Attempts", + "enable2FA": "Enable two-factor authentication for admins", + "recaptchaSettings": "reCAPTCHA Settings", + "enableRecaptcha": "Enable reCAPTCHA for login forms", + "siteKey": "Site Key", + "secretKey": "Secret Key", + "recaptchaHelp": "Get your reCAPTCHA keys from", + "saveSettings": "Save Security Settings" + }, + "categories": { + "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." + } + }, + "branding": { + "title": "Branding & Customization", + "companyInfo": "Company Information", + "companyName": "Company Name", + "companyTagline": "Company Tagline", + "supportEmail": "Support Email", + "footerText": "Footer Text", + "logo": "Logo", + "uploadLogo": "Upload Logo", + "removeLogo": "Remove Logo", + "favicon": "Favicon", + "uploadFavicon": "Upload Favicon", + "removeFavicon": "Remove Favicon", + "watermark": "Watermark", + "enableWatermark": "Enable watermark on photos", + "theme": "Theme", + "themeCustomization": "Theme Customization", + "selectPreset": "Select a preset theme", + "colors": "Colors", + "primaryColor": "Primary Color", + "secondaryColor": "Secondary Color", + "accentColor": "Accent Color", + "customCSS": "Custom CSS", + "preview": "Preview", + "previewInNewTab": "Preview in New Tab", + "reset": "Reset", + "saveChanges": "Save Changes" + }, + "admin": { + "title": "Admin Panel", + "welcome": "Welcome back, {{name}}", + "recentActivity": "Recent Activity", + "systemStatus": "System Status", + "totalEvents": "Total Events", + "activeGalleries": "Active Galleries", + "storageUsed": "Storage Used", + "totalPhotos": "Total Photos", + "storagePercent": "{{percent}}% of {{limit}}", + "notifications": "Notifications", + "viewAllNotifications": "View all notifications", + "changePassword": "Change Password", + "loadingDashboard": "Loading dashboard...", + "activeEvents": "Active Events", + "expiringSoon": "Expiring Soon", + "next7Days": "Next 7 days", + "totalViews": "Total Views", + "downloads": "Downloads", + "percentFromLastWeek": "{{percent}}% from last week", + "dashboardSubtitle": "Welcome back! Here's what's happening with your galleries.", + "eventsExpiringSoon": "Events Expiring Soon", + "noEventsExpiring": "No events expiring in the next 7 days", + "daysLeft": "{{count}} day left", + "daysLeft_plural": "{{count}} days left", + "viewAllExpiringEvents": "View all {{count}} expiring events", + "noRecentActivity": "No recent activity", + "viewAllActivity": "View all activity", + "quickActions": "Quick Actions", + "viewArchives": "View Archives", + "analytics": "Analytics" + }, + "errors": { + "notFound": "Not Found", + "galleryNotFound": "Gallery Not Found", + "galleryNotFoundMessage": "This gallery does not exist or has been removed.", + "unauthorized": "Unauthorized", + "forbidden": "Forbidden", + "serverError": "Server Error", + "somethingWentWrong": "Something went wrong", + "tryAgainLater": "Please try again later", + "refreshPage": "Refresh Page", + "oopsSomethingWentWrong": "Oops! Something went wrong", + "unexpectedError": "We encountered an unexpected error. Don't worry, your data is safe.", + "goToHomepage": "Go to Homepage", + "errorDetails": "Error Details" + }, + "legal": { + "impressum": "Legal Notice", + "datenschutz": "Privacy Policy", + "termsOfService": "Terms of Service", + "cookiePolicy": "Cookie Policy" + }, + "toast": { + "saveSuccess": "Changes saved successfully", + "saveError": "Failed to save changes", + "deleteSuccess": "Deleted successfully", + "deleteError": "Failed to delete", + "uploadSuccess": "Upload completed successfully", + "uploadError": "Upload failed", + "loginSuccess": "Login successful", + "loginError": "Login failed", + "passwordChanged": "Password changed successfully", + "linkCopied": "Link copied to clipboard", + "eventCreated": "Event created successfully", + "eventUpdated": "Event updated successfully", + "eventArchived": "Event archived successfully", + "settingsSaved": "Settings saved successfully", + "themeUpdated": "Theme updated successfully", + "brandingUpdated": "Branding updated successfully", + "categoryAdded": "Category added successfully", + "categoryDeleted": "Category deleted successfully", + "categoryUpdated": "Category updated successfully", + "emailConfigSaved": "Email configuration saved successfully", + "testEmailSent": "Test email sent successfully", + "pageUpdated": "Page updated successfully", + "archiveRestored": "Archive restored successfully", + "archiveDeleted": "Archive deleted permanently" + } +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 899136a..b90f9fa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -160,4 +160,39 @@ .smooth-scroll { scroll-behavior: smooth; } + + /* Custom range slider styles */ + .slider { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + } + + .slider::-webkit-slider-track { + @apply bg-neutral-200 h-2 rounded-lg; + } + + .slider::-moz-range-track { + @apply bg-neutral-200 h-2 rounded-lg; + } + + .slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + @apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all; + margin-top: -6px; + } + + .slider::-moz-range-thumb { + @apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all border-0; + } + + .slider:hover::-webkit-slider-thumb { + @apply bg-primary-700 scale-110; + } + + .slider:hover::-moz-range-thumb { + @apply bg-primary-700 scale-110; + } } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..fbb4c06 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' +import './i18n/config' import App from './App.tsx' createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 2e7e41d..9252ef5 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -1,23 +1,44 @@ import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react'; import { format, differenceInDays, parseISO } from 'date-fns'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, Input, Button, Loading } from '../components/common'; import { useGalleryAuth } from '../contexts'; import { useGalleryInfo } from '../hooks/useGallery'; import { GalleryView } from '../components/gallery'; import { analyticsService } from '../services/analytics.service'; +import { api } from '../config/api'; export const GalleryPage: React.FC = () => { const { slug, token } = useParams<{ slug: string; token?: string }>(); const { isAuthenticated, login, event } = useGalleryAuth(); + const { t, i18n } = useTranslation(); const [password, setPassword] = useState(''); const [isLoggingIn, setIsLoggingIn] = useState(false); const [loginError, setLoginError] = useState(null); // Fetch gallery info (public data) const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token); + + // Fetch branding settings + const { data: settingsData } = useQuery({ + queryKey: ['gallery-settings'], + queryFn: async () => { + const response = await api.get('/api/public/settings'); + return response.data; + }, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); + + // Set language from admin settings when on login page + React.useEffect(() => { + if (!isAuthenticated && settingsData?.default_language) { + i18n.changeLanguage(settingsData.default_language); + } + }, [settingsData, isAuthenticated, i18n]); // Calculate days until expiration const daysUntilExpiration = galleryInfo @@ -27,7 +48,7 @@ export const GalleryPage: React.FC = () => { const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); if (!password.trim()) { - setLoginError('Please enter a password'); + setLoginError(t('auth.pleaseEnterPassword')); return; } @@ -42,7 +63,7 @@ export const GalleryPage: React.FC = () => { success: true }); } catch (error: any) { - setLoginError(error.response?.data?.error || 'Invalid password'); + setLoginError(error.response?.data?.error || t('auth.invalidPassword')); // Track failed password entry analyticsService.trackGalleryEvent('password_entry', { @@ -57,8 +78,10 @@ export const GalleryPage: React.FC = () => { // Show loading state if (isLoadingInfo) { return ( -
- +
+
+ +
); } @@ -66,16 +89,50 @@ export const GalleryPage: React.FC = () => { // Show error state if (infoError) { return ( -
- - - -

Gallery Not Found

-

- This gallery does not exist or has been removed. -

-
-
+
+
+ {/* Logo at top */} + {settingsData?.branding_logo_url && ( +
+ {settingsData.branding_company_name +
+ )} + +
+ + + +

{t('errors.galleryNotFound')}

+

+ {t('errors.galleryNotFoundMessage')} +

+
+
+
+ + {/* Legal Links */} +
+
+ + {t('legal.impressum')} + + | + + {t('legal.datenschutz')} + +
+
+
); } @@ -83,19 +140,53 @@ export const GalleryPage: React.FC = () => { // Show expired state if (galleryInfo?.is_expired) { return ( -
- - - -

Gallery Expired

-

- This gallery expired on {format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy')}. -

-

- Please contact the event organizer if you need access to these photos. -

-
-
+
+
+ {/* Logo at top */} + {settingsData?.branding_logo_url && ( +
+ {settingsData.branding_company_name +
+ )} + +
+ + + +

{t('gallery.expired')}

+

+ {t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy') })} +

+

+ {t('gallery.contactOrganizer')} +

+
+
+
+ + {/* Legal Links */} +
+
+ + {t('legal.impressum')} + + | + + {t('legal.datenschutz')} + +
+
+
); } @@ -112,9 +203,17 @@ export const GalleryPage: React.FC = () => {
{/* Logo/Header */}
-
- -
+ {settingsData?.branding_logo_url ? ( + {settingsData.branding_company_name + ) : ( +
+ +
+ )}

{galleryInfo?.event_name}

@@ -131,10 +230,10 @@ export const GalleryPage: React.FC = () => {

- Gallery expires in {daysUntilExpiration} {daysUntilExpiration === 1 ? 'day' : 'days'} + {t('gallery.expiresIn', { count: daysUntilExpiration })}

- Download your photos before they're no longer available. + {t('gallery.downloadBefore')}

@@ -144,13 +243,13 @@ export const GalleryPage: React.FC = () => { {/* Login Card */} -

Enter Gallery Password

+

{t('auth.enterPassword')}

setPassword(e.target.value)} error={loginError || undefined} @@ -165,22 +264,33 @@ export const GalleryPage: React.FC = () => { isLoading={isLoggingIn} disabled={isLoggingIn} > - View Gallery + {t('gallery.viewGallery')}

- The password was provided by the event organizer. - Contact them if you don't have it. + {t('auth.passwordHint')}

- {/* Event Type Badge */} + {/* Legal Links */}
- - {galleryInfo?.event_type} - +
diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index d762d91..3c8d4ee 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -14,6 +14,7 @@ import { Image } from 'lucide-react'; import { format, differenceInDays, parseISO, formatDistanceToNow } from 'date-fns'; +import { useTranslation } from 'react-i18next'; import { Button, Card, Loading } from '../../components/common'; import { useQuery } from '@tanstack/react-query'; @@ -29,6 +30,7 @@ interface StatCard { } export const AdminDashboard: React.FC = () => { + const { t } = useTranslation(); const navigate = useNavigate(); // Fetch dashboard statistics @@ -54,7 +56,7 @@ export const AdminDashboard: React.FC = () => { if (isLoading) { return (
- +
); } @@ -76,26 +78,26 @@ export const AdminDashboard: React.FC = () => { // Build statistics cards const stats: StatCard[] = [ { - title: 'Active Events', + title: t('admin.activeEvents'), value: dashboardStats?.activeEvents || 0, icon: Calendar, color: 'text-green-600', }, { - title: 'Expiring Soon', + title: t('admin.expiringSoon'), value: dashboardStats?.expiringEvents || 0, - change: 'Next 7 days', + change: t('admin.next7Days'), icon: AlertTriangle, color: 'text-orange-600', }, { - title: 'Total Photos', + title: t('admin.totalPhotos'), value: formatNumber(dashboardStats?.totalPhotos || 0), icon: Image, color: 'text-blue-600', }, { - title: 'Storage Used', + title: t('admin.storageUsed'), value: adminService.formatBytes(dashboardStats?.storageUsed || 0), icon: HardDrive, color: 'text-purple-600', @@ -106,16 +108,16 @@ export const AdminDashboard: React.FC = () => { if (dashboardStats?.totalViews !== undefined) { stats.push( { - title: 'Total Views', + title: t('admin.totalViews'), value: formatNumber(dashboardStats.totalViews), - change: dashboardStats.viewsTrend > 0 ? `+${dashboardStats.viewsTrend}% from last week` : undefined, + change: dashboardStats.viewsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.viewsTrend}` }) : undefined, icon: Eye, color: 'text-indigo-600', }, { - title: 'Downloads', + title: t('admin.downloads'), value: formatNumber(dashboardStats.totalDownloads), - change: dashboardStats.downloadsTrend > 0 ? `+${dashboardStats.downloadsTrend}% from last week` : undefined, + change: dashboardStats.downloadsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.downloadsTrend}` }) : undefined, icon: Download, color: 'text-pink-600', } @@ -127,15 +129,15 @@ export const AdminDashboard: React.FC = () => { {/* Page Header */}
-

Dashboard

-

Welcome back! Here's what's happening with your galleries.

+

{t('navigation.dashboard')}

+

{t('admin.dashboardSubtitle')}

@@ -165,12 +167,12 @@ export const AdminDashboard: React.FC = () => {
-

Events Expiring Soon

+

{t('admin.eventsExpiringSoon')}

{expiringEvents.length === 0 ? ( -

No events expiring in the next 7 days

+

{t('admin.noEventsExpiring')}

) : (
{expiringEvents.slice(0, 5).map((event) => { @@ -190,10 +192,10 @@ export const AdminDashboard: React.FC = () => {

- {daysLeft} {daysLeft === 1 ? 'day' : 'days'} left + {t('admin.daysLeft', { count: daysLeft })}

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

@@ -207,7 +209,7 @@ export const AdminDashboard: React.FC = () => { onClick={() => navigate('/admin/events?filter=expiring')} className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium" > - View all {expiringEvents.length} expiring events → + {t('admin.viewAllExpiringEvents', { count: expiringEvents.length })} → )} @@ -216,13 +218,13 @@ export const AdminDashboard: React.FC = () => { {/* Recent Activity */}
-

Recent Activity

+

{t('admin.recentActivity')}

{!recentActivity || recentActivity.length === 0 ? ( -

No recent activity

+

{t('admin.noRecentActivity')}

) : ( recentActivity.slice(0, 5).map((activity) => { // Get color based on activity type @@ -265,7 +267,7 @@ export const AdminDashboard: React.FC = () => { onClick={() => navigate('/admin/activity')} className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium" > - View all activity → + {t('admin.viewAllActivity')} → )} @@ -273,7 +275,7 @@ export const AdminDashboard: React.FC = () => { {/* Quick Actions */} -

Quick Actions

+

{t('admin.quickActions')}

diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 1fc2d54..0c63b2a 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -1,25 +1,31 @@ -import React, { useState, useEffect } from 'react'; -import { Save, Eye, Palette } from 'lucide-react'; +import React, { useState, useEffect, useRef } from 'react'; +import { Save, Eye, Palette, Upload } from 'lucide-react'; import { toast } from 'react-toastify'; import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common'; import { ThemeCustomizer } from '../../components/admin/ThemeCustomizer'; import { useTheme, type ThemeConfig, PRESET_THEMES } from '../../contexts/ThemeContext'; import { useQuery, useMutation } from '@tanstack/react-query'; -import { settingsService } from '../../services/settings.service'; +import { settingsService, type BrandingSettings } from '../../services/settings.service'; export const BrandingPage: React.FC = () => { const { theme, setTheme } = useTheme(); - const [brandingSettings, setBrandingSettings] = useState({ + const [brandingSettings, setBrandingSettings] = useState({ company_name: '', company_tagline: '', footer_text: '© 2024 Your Company. All rights reserved.', support_email: '', watermark_enabled: false, + watermark_position: 'bottom-right', + watermark_opacity: 50, + watermark_size: 15, + watermark_logo_url: '', + favicon_url: '', }); const [currentTheme, setCurrentTheme] = useState(theme); const [currentThemeName, setCurrentThemeName] = useState('default'); const [isPreviewMode, setIsPreviewMode] = useState(false); + const faviconInputRef = useRef(null); // Fetch current settings const { data: settings, isLoading } = useQuery({ @@ -68,8 +74,12 @@ export const BrandingPage: React.FC = () => { if (themeSettings) { const formatted = settingsService.formatThemeSettings(themeSettings); if (formatted && Object.keys(formatted).length > 0) { - setCurrentTheme(formatted); - setTheme(formatted); + // Merge logo URL from branding settings if available + const logoUrl = settings?.branding_logo_url || brandingSettings.logo_url; + const themeWithLogo = logoUrl ? { ...formatted, logoUrl } : formatted; + + setCurrentTheme(themeWithLogo); + setTheme(themeWithLogo); // Try to identify which preset this matches for (const [key, preset] of Object.entries(PRESET_THEMES)) { @@ -80,7 +90,7 @@ export const BrandingPage: React.FC = () => { } } } - }, [themeSettings, setTheme]); + }, [themeSettings, settings, brandingSettings.logo_url, setTheme]); const handleBrandingChange = (key: string, value: any) => { setBrandingSettings(prev => ({ ...prev, [key]: value })); @@ -88,6 +98,10 @@ export const BrandingPage: React.FC = () => { const handleThemeChange = (newTheme: ThemeConfig) => { setCurrentTheme(newTheme); + // Also update logo URL in branding settings if it changed + if (newTheme.logoUrl !== currentTheme.logoUrl) { + setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' })); + } if (isPreviewMode) { setTheme(newTheme); } @@ -105,16 +119,47 @@ export const BrandingPage: React.FC = () => { } }; + const handleFaviconUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + try { + const faviconUrl = await settingsService.uploadFavicon(file); + setBrandingSettings(prev => ({ ...prev, favicon_url: faviconUrl })); + toast.success('Favicon uploaded successfully'); + } catch (error) { + console.error('Failed to upload favicon:', error); + toast.error('Failed to upload favicon. Please use PNG or ICO format.'); + } + } + }; + + const handleWatermarkLogoUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + try { + const watermarkLogoUrl = await settingsService.uploadWatermarkLogo(file); + setBrandingSettings(prev => ({ ...prev, watermark_logo_url: watermarkLogoUrl })); + toast.success('Watermark logo uploaded successfully'); + } catch (error) { + console.error('Failed to upload watermark logo:', error); + toast.error('Failed to upload watermark logo. Please use PNG format with transparency.'); + } + } + }; + const handleSave = async () => { try { // Save branding settings to database await brandingMutation.mutateAsync(brandingSettings); - // Save theme settings to database - await themeMutation.mutateAsync(currentTheme); + // Save theme settings to database (including logo URL if present) + const themeToSave = brandingSettings.logo_url + ? { ...currentTheme, logoUrl: brandingSettings.logo_url } + : currentTheme; + await themeMutation.mutateAsync(themeToSave); // Apply theme globally - setTheme(currentTheme); + setTheme(themeToSave); } catch (error) { console.error('Failed to save settings:', error); } @@ -223,6 +268,175 @@ export const BrandingPage: React.FC = () => {
+ +
+
+ +
+ {brandingSettings.favicon_url && ( +
+ Current favicon + Current favicon + +
+ )} +
+ + +

PNG or ICO format, recommended size: 32x32px

+
+
+
+
+ + {/* Watermark Settings */} + {brandingSettings.watermark_enabled && ( +
+

Watermark Settings

+ + {/* Watermark Logo Upload */} +
+ +
+ {brandingSettings.watermark_logo_url && ( +
+ Current watermark + Current watermark + +
+ )} +
+ + +

PNG format with transparency recommended

+
+
+
+ + {/* Position Selector */} +
+ +
+ {[ + { value: 'top-left', label: 'Top Left' }, + { value: 'top-right', label: 'Top Right' }, + { value: 'center', label: 'Center' }, + { value: 'bottom-left', label: 'Bottom Left' }, + { value: 'bottom-right', label: 'Bottom Right' } + ].map((position) => ( + + ))} +
+
+ + {/* Opacity Slider */} +
+ + handleBrandingChange('watermark_opacity', parseInt(e.target.value))} + className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider" + /> +
+ 10% + 50% + 100% +
+
+ + {/* Size Slider */} +
+ + handleBrandingChange('watermark_size', parseInt(e.target.value))} + className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider" + /> +
+ 5% + 15% + 30% +
+
+
+ )} {/* Theme Customization */} @@ -247,6 +461,7 @@ export const BrandingPage: React.FC = () => { onChange={handleThemeChange} presetName={currentThemeName} onPresetChange={handlePresetChange} + isPreviewMode={isPreviewMode} />
diff --git a/frontend/src/pages/admin/CMSPage.tsx b/frontend/src/pages/admin/CMSPage.tsx new file mode 100644 index 0000000..88f72fd --- /dev/null +++ b/frontend/src/pages/admin/CMSPage.tsx @@ -0,0 +1,212 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { Save, FileText, Globe } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Button, Card, Input, Loading } from '../../components/common'; +import { CMSEditor } from '../../components/admin/CMSEditor'; +import { cmsService } from '../../services/cms.service'; +import type { CMSPage as CMSPageType } from '../../services/cms.service'; + +export const CMSPage: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [selectedPage, setSelectedPage] = useState('impressum'); + const [editingLang, setEditingLang] = useState<'en' | 'de'>('en'); + const [editForm, setEditForm] = useState>({}); + + // Fetch CMS pages + const { data: pages, isLoading } = useQuery({ + queryKey: ['cms-pages'], + queryFn: cmsService.getPages, + }); + + // Update page mutation + const updateMutation = useMutation({ + mutationFn: ({ slug, data }: { slug: string; data: Partial }) => + cmsService.updatePage(slug, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); + toast.success('Page updated successfully'); + }, + onError: () => { + toast.error('Failed to update page'); + }, + }); + + // Load page data when selection changes + React.useEffect(() => { + if (pages) { + const page = pages.find(p => p.slug === selectedPage); + if (page) { + setEditForm(page); + } + } + }, [pages, selectedPage]); + + const handleSave = () => { + updateMutation.mutate({ + slug: selectedPage, + data: editForm, + }); + }; + + const handleContentChange = (content: string) => { + const field = editingLang === 'de' ? 'content_de' : 'content_en'; + setEditForm(prev => ({ ...prev, [field]: content })); + }; + + const handleTitleChange = (title: string) => { + const field = editingLang === 'de' ? 'title_de' : 'title_en'; + setEditForm(prev => ({ ...prev, [field]: title })); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const currentPage = pages?.find(p => p.slug === selectedPage); + + return ( +
+
+

CMS Pages

+

Manage legal and informational pages

+
+ +
+ {/* Page Selection */} +
+ +

Pages

+
+ {pages?.map((page) => ( + + ))} +
+
+ + +

Preview Links

+ +
+
+ + {/* Editor */} +
+ +
+

+ Edit {t(`legal.${selectedPage}`)} +

+ + {/* Language Tabs */} +
+ + +
+
+ +
+ {/* Title */} +
+ + handleTitleChange(e.target.value)} + placeholder="Enter page title..." + /> +
+ + {/* Content */} +
+ + +
+
+ +
+ +
+ + {currentPage?.updated_at && ( +

+ Last updated: {new Date(currentPage.updated_at).toLocaleString()} +

+ )} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 7b1a495..cb57131 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -20,7 +20,7 @@ import { format, parseISO, differenceInDays } from 'date-fns'; import { toast } from 'react-toastify'; import { Button, Input, Card, Loading } from '../../components/common'; -import { PhotoUpload } from '../../components/admin'; +import { PhotoUpload, EventCategoryManager } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { galleryService } from '../../services/gallery.service'; @@ -408,10 +408,14 @@ export const EventDetailsPage: React.FC = () => { Storage Location: /storage/events/active/{event.slug}/

- Photos can also be added by placing them in the 'individual' or 'collages' folders. + Photos are organized by categories you define.

+ +
+ +
{/* Actions */} diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 9872122..3b274bf 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -4,17 +4,21 @@ import { Database, Globe, Key, - AlertCircle + AlertCircle, + Image } from 'lucide-react'; import { toast } from 'react-toastify'; import { Button, Card, Input, Loading } from '../../components/common'; +import { CategoryManager } from '../../components/admin/CategoryManager'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { settingsService } from '../../services/settings.service'; +import { useTranslation } from 'react-i18next'; export const SettingsPage: React.FC = () => { - const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general'); const queryClient = useQueryClient(); + const { t } = useTranslation(); // Fetch settings const { data: settings, isLoading } = useQuery({ @@ -38,7 +42,8 @@ export const SettingsPage: React.FC = () => { enable_watermark: false, enable_analytics: true, enable_registration: false, - maintenance_mode: false + maintenance_mode: false, + default_language: 'en' }); // Security settings state @@ -64,7 +69,8 @@ export const SettingsPage: React.FC = () => { enable_watermark: settings.general_enable_watermark || false, enable_analytics: settings.general_enable_analytics || true, enable_registration: settings.general_enable_registration || false, - maintenance_mode: settings.general_maintenance_mode || false + maintenance_mode: settings.general_maintenance_mode || false, + default_language: settings.general_default_language || 'en' }); // Extract security settings @@ -166,6 +172,16 @@ export const SettingsPage: React.FC = () => { > Security +
@@ -280,6 +296,29 @@ export const SettingsPage: React.FC = () => { Enable maintenance mode
+ + + +

{t('settings.general.language')}

+ +
+
+ + +

+ Sets the default language for all gallery pages and login screens +

+
+
@@ -510,6 +549,29 @@ export const SettingsPage: React.FC = () => {
)} + + {/* Categories Tab */} + {activeTab === 'categories' && ( +
+ + + + + +
+ +
+

About Photo Categories

+

+ 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. +

+
+
+
+
+ )}
); }; \ No newline at end of file diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index 17b9a3f..4ce00bb 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -7,4 +7,5 @@ export { EmailConfigPage } from './EmailConfigPage'; export { ArchivesPage } from './ArchivesPage'; export { AnalyticsPage } from './AnalyticsPage'; export { BrandingPage } from './BrandingPage'; -export { SettingsPage } from './SettingsPage'; \ No newline at end of file +export { SettingsPage } from './SettingsPage'; +export { CMSPage } from './CMSPage'; \ No newline at end of file diff --git a/frontend/src/pages/gallery/PreviewPage.tsx b/frontend/src/pages/gallery/PreviewPage.tsx new file mode 100644 index 0000000..8d02d95 --- /dev/null +++ b/frontend/src/pages/gallery/PreviewPage.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import { GalleryLayout, PhotoFilterBar } from '../../components/gallery'; +import { Card } from '../../components/common'; +import { Camera } from 'lucide-react'; + +// Mock photo data for preview +const generateMockPhotos = (count: number) => { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + filename: `photo-${i + 1}.jpg`, + url: '', + thumbnail_url: '', + type: i % 3 === 0 ? 'collage' : 'individual', + category_id: (i % 4) + 1, + category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4], + category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4], + size: Math.floor(Math.random() * 5000000) + 1000000, + uploaded_at: new Date().toISOString(), + })); +}; + +const mockCategories = [ + { id: 1, name: 'Ceremony', slug: 'ceremony', is_global: true }, + { id: 2, name: 'Reception', slug: 'reception', is_global: true }, + { id: 3, name: 'Portraits', slug: 'portraits', is_global: true }, + { id: 4, name: 'Party', slug: 'party', is_global: true }, +]; + +export const PreviewPage: React.FC = () => { + const { setTheme } = useTheme(); + const [brandingSettings, setBrandingSettings] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date'); + + const mockPhotos = useMemo(() => generateMockPhotos(12), []); + const mockEvent = { + event_name: 'Preview Wedding Gallery', + event_date: new Date().toISOString(), + expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }; + + useEffect(() => { + // Listen for theme preview messages from the branding page + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'THEME_PREVIEW') { + setTheme(event.data.theme); + setBrandingSettings(event.data.branding); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [setTheme]); + + // Filter photos + const filteredPhotos = useMemo(() => { + let photos = [...mockPhotos]; + + // Apply category filter + if (selectedCategoryId) { + photos = photos.filter(photo => photo.category_id === selectedCategoryId); + } + + // Apply search filter + if (searchTerm) { + photos = photos.filter(photo => + photo.filename.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Apply sorting + photos.sort((a, b) => { + switch (sortBy) { + case 'name': + return a.filename.localeCompare(b.filename); + case 'size': + return b.size - a.size; + case 'date': + default: + return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime(); + } + }); + + return photos; + }, [mockPhotos, selectedCategoryId, searchTerm, sortBy]); + + // Custom photo renderer for preview + const PreviewPhotoGrid: React.FC<{ photos: any[] }> = ({ photos }) => ( +
+ {photos.map((photo) => ( + +
+
+ +
+
+

{photo.filename}

+ {photo.category_name && ( +

{photo.category_name}

+ )} +
+
+
+ ))} +
+ ); + + return ( + +
+
+

Theme Preview

+

This is how your galleries will look with the current theme settings

+
+ + {/* Filters */} + + + {/* Photo Grid */} +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/public/LegalPage.tsx b/frontend/src/pages/public/LegalPage.tsx new file mode 100644 index 0000000..59068ff --- /dev/null +++ b/frontend/src/pages/public/LegalPage.tsx @@ -0,0 +1,138 @@ +import React, { useEffect } from 'react'; +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { ArrowLeft, Home } from 'lucide-react'; +import { Loading, Card } from '../../components/common'; +import { cmsService } from '../../services/cms.service'; +import { api } from '../../config/api'; + +export const LegalPage: React.FC = () => { + const { slug } = useParams<{ slug: string }>(); + const { i18n } = useTranslation(); + const navigate = useNavigate(); + + // Extract page slug from pathname if not in params (for static routes like /impressum) + const pathname = window.location.pathname; + const pageSlug = slug || pathname.split('/').pop() || ''; + + // Fetch settings to get default language + const { data: settingsData } = useQuery({ + queryKey: ['public-settings'], + queryFn: async () => { + const response = await api.get('/api/public/settings'); + return response.data; + }, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); + + // Use admin settings language + const lang = settingsData?.default_language || 'en'; + + // Fetch page content + const { data: page, isLoading, error } = useQuery({ + queryKey: ['legal-page', pageSlug, lang], + queryFn: () => cmsService.getPublicPage(pageSlug, lang), + enabled: !!pageSlug && pageSlug !== '' && !!settingsData, + }); + + // Set i18n language when settings are loaded + useEffect(() => { + if (settingsData?.default_language) { + i18n.changeLanguage(settingsData.default_language); + } + }, [settingsData, i18n]); + + // Update page title + useEffect(() => { + if (page?.title) { + document.title = `${page.title} - Wedding Photo Sharing`; + } + }, [page?.title]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !page) { + return ( +
+ +
+

Page Not Found

+

+ The page you're looking for doesn't exist. +

+ + + Go to Homepage + +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Content */} +
+
+ +

{page.title}

+ +
+ + +
+
+ + {/* Footer */} +
+
+
+ + {lang === 'de' ? 'Impressum' : 'Legal Notice'} + + + + {lang === 'de' ? 'Datenschutz' : 'Privacy Policy'} + +
+

+ © 2024 Wedding Photo Sharing. All rights reserved. +

+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/services/categories.service.ts b/frontend/src/services/categories.service.ts new file mode 100644 index 0000000..67185af --- /dev/null +++ b/frontend/src/services/categories.service.ts @@ -0,0 +1,48 @@ +import { api } from '../config/api'; + +export interface PhotoCategory { + id: number; + name: string; + slug: string; + is_global: boolean; + event_id: number | null; + created_at: string; +} + +export interface CreateCategoryData { + name: string; + slug?: string; + is_global?: boolean; + event_id?: number; +} + +export const categoriesService = { + // Get all global categories + async getGlobalCategories(): Promise { + const response = await api.get('/api/admin/categories/global'); + return response.data; + }, + + // Get categories for a specific event (global + event-specific) + async getEventCategories(eventId: number): Promise { + const response = await api.get(`/api/admin/categories/event/${eventId}`); + return response.data; + }, + + // Create a new category + async createCategory(data: CreateCategoryData): Promise { + const response = await api.post('/api/admin/categories', data); + return response.data; + }, + + // Update a category + async updateCategory(id: number, name: string): Promise { + const response = await api.put(`/api/admin/categories/${id}`, { name }); + return response.data; + }, + + // Delete a category + async deleteCategory(id: number): Promise { + await api.delete(`/api/admin/categories/${id}`); + } +}; \ No newline at end of file diff --git a/frontend/src/services/cms.service.ts b/frontend/src/services/cms.service.ts new file mode 100644 index 0000000..072ba77 --- /dev/null +++ b/frontend/src/services/cms.service.ts @@ -0,0 +1,39 @@ +import { api } from '../config/api'; + +export interface CMSPage { + id: number; + slug: string; + title_en: string; + title_de: string; + content_en: string; + content_de: string; + updated_at: string; +} + +export const cmsService = { + // Get all CMS pages + async getPages(): Promise { + const response = await api.get('/api/admin/cms/pages'); + return response.data; + }, + + // Get a single CMS page + async getPage(slug: string): Promise { + const response = await api.get(`/api/admin/cms/pages/${slug}`); + return response.data; + }, + + // Update a CMS page + async updatePage(slug: string, data: Partial): Promise { + const response = await api.put(`/api/admin/cms/pages/${slug}`, data); + return response.data; + }, + + // Get public CMS page (no auth required) + async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> { + const response = await api.get<{ title: string; content: string }>(`/api/public/pages/${slug}`, { + params: { lang } + }); + return response.data; + } +}; \ No newline at end of file diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 6b31d60..009de23 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -5,4 +5,5 @@ export { adminService } from './admin.service'; export { analyticsService } from './analytics.service'; export { archiveService } from './archive.service'; export { emailService } from './email.service'; -export { settingsService } from './settings.service'; \ No newline at end of file +export { settingsService } from './settings.service'; +export { cmsService } from './cms.service'; \ No newline at end of file diff --git a/frontend/src/services/settings.service.ts b/frontend/src/services/settings.service.ts index 9c4a71a..6d700ba 100644 --- a/frontend/src/services/settings.service.ts +++ b/frontend/src/services/settings.service.ts @@ -6,7 +6,12 @@ export interface BrandingSettings { support_email: string; footer_text: string; watermark_enabled: boolean; + watermark_position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'center'; + watermark_opacity?: number; + watermark_size?: number; + watermark_logo_url?: string; logo_url?: string; + favicon_url?: string; } export interface ThemeSettings { @@ -50,11 +55,11 @@ export const settingsService = { }, // Upload logo - async uploadLogo(file: File): Promise<{ logo_url: string }> { + async uploadLogo(file: File): Promise { const formData = new FormData(); formData.append('logo', file); - const response = await api.post<{ message: string; logo_url: string }>( + const response = await api.post<{ logoUrl: string }>( '/api/admin/settings/logo', formData, { @@ -64,7 +69,43 @@ export const settingsService = { } ); - return { logo_url: response.data.logo_url }; + return response.data.logoUrl; + }, + + // Upload favicon + async uploadFavicon(file: File): Promise { + const formData = new FormData(); + formData.append('favicon', file); + + const response = await api.post<{ faviconUrl: string }>( + '/api/admin/settings/favicon', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data' + } + } + ); + + return response.data.faviconUrl; + }, + + // Upload watermark logo + async uploadWatermarkLogo(file: File): Promise { + const formData = new FormData(); + formData.append('watermarkLogo', file); + + const response = await api.post<{ watermarkLogoUrl: string }>( + '/api/admin/settings/branding/watermark-logo', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data' + } + } + ); + + return response.data.watermarkLogoUrl; }, // Update theme settings @@ -91,7 +132,12 @@ export const settingsService = { support_email: rawSettings.branding_support_email || '', footer_text: rawSettings.branding_footer_text || '', watermark_enabled: rawSettings.branding_watermark_enabled || false, - logo_url: rawSettings.branding_logo_url || undefined + watermark_position: rawSettings.branding_watermark_position || 'bottom-right', + watermark_opacity: rawSettings.branding_watermark_opacity || 50, + watermark_size: rawSettings.branding_watermark_size || 15, + watermark_logo_url: rawSettings.branding_watermark_logo_url || undefined, + logo_url: rawSettings.branding_logo_url || undefined, + favicon_url: rawSettings.branding_favicon_url || undefined }; }, diff --git a/frontend/src/services/toast.service.ts b/frontend/src/services/toast.service.ts new file mode 100644 index 0000000..360e106 --- /dev/null +++ b/frontend/src/services/toast.service.ts @@ -0,0 +1,30 @@ +import { toast as toastify } from 'react-toastify'; +import i18n from '../i18n/config'; + +export const toast = { + success: (messageKey: string, interpolations?: Record) => { + const message = i18n.t(messageKey, interpolations); + toastify.success(message); + }, + + error: (messageKey: string, interpolations?: Record) => { + const message = i18n.t(messageKey, interpolations); + toastify.error(message); + }, + + info: (messageKey: string, interpolations?: Record) => { + const message = i18n.t(messageKey, interpolations); + toastify.info(message); + }, + + warning: (messageKey: string, interpolations?: Record) => { + const message = i18n.t(messageKey, interpolations); + toastify.warning(message); + }, + + // For direct messages (not translation keys) + successDirect: (message: string) => toastify.success(message), + errorDirect: (message: string) => toastify.error(message), + infoDirect: (message: string) => toastify.info(message), + warningDirect: (message: string) => toastify.warning(message), +}; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 75934ee..e095826 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -42,10 +42,20 @@ export interface Photo { url: string; thumbnail_url?: string; type: 'collage' | 'individual'; + category_id?: number; + category_name?: string; + category_slug?: string; size: number; uploaded_at: string; } +export interface PhotoCategory { + id: number; + name: string; + slug: string; + is_global: boolean; +} + export interface GalleryData { event: { id: number; @@ -56,6 +66,7 @@ export interface GalleryData { color_theme?: string; expires_at: string; }; + categories?: PhotoCategory[]; photos: Photo[]; } diff --git a/migrations/005_add_watermark_settings.js b/migrations/005_add_watermark_settings.js new file mode 100644 index 0000000..0a36d59 --- /dev/null +++ b/migrations/005_add_watermark_settings.js @@ -0,0 +1,61 @@ +const knex = require('knex'); + +exports.up = async function(db) { + console.log('Adding watermark settings...'); + + // Add watermark settings to app_settings table + const watermarkSettings = [ + { + setting_key: 'branding_watermark_logo_path', + setting_value: JSON.stringify(null), + setting_type: 'branding' + }, + { + setting_key: 'branding_watermark_logo_url', + setting_value: JSON.stringify(null), + setting_type: 'branding' + }, + { + setting_key: 'branding_watermark_position', + setting_value: JSON.stringify('bottom-right'), + setting_type: 'branding' + }, + { + setting_key: 'branding_watermark_opacity', + setting_value: JSON.stringify(50), + setting_type: 'branding' + }, + { + setting_key: 'branding_watermark_size', + setting_value: JSON.stringify(15), + setting_type: 'branding' + } + ]; + + for (const setting of watermarkSettings) { + // Check if setting already exists + const existing = await db('app_settings') + .where('setting_key', setting.setting_key) + .first(); + + if (!existing) { + await db('app_settings').insert(setting); + console.log(`Added setting: ${setting.setting_key}`); + } + } + + console.log('Watermark settings migration completed'); +}; + +exports.down = async function(db) { + // Remove watermark settings + await db('app_settings') + .whereIn('setting_key', [ + 'branding_watermark_logo_path', + 'branding_watermark_logo_url', + 'branding_watermark_position', + 'branding_watermark_opacity', + 'branding_watermark_size' + ]) + .del(); +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eed44d3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "wedding-photo-sharing", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f513339 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "node-fetch": "^2.7.0" + } +} diff --git a/storage/uploads/favicons/favicon-1751900441264.png b/storage/uploads/favicons/favicon-1751900441264.png new file mode 100644 index 0000000..1541735 Binary files /dev/null and b/storage/uploads/favicons/favicon-1751900441264.png differ diff --git a/storage/uploads/favicons/favicon-1751900464374.png b/storage/uploads/favicons/favicon-1751900464374.png new file mode 100644 index 0000000..1541735 Binary files /dev/null and b/storage/uploads/favicons/favicon-1751900464374.png differ diff --git a/storage/uploads/favicons/favicon-1751900673622.png b/storage/uploads/favicons/favicon-1751900673622.png new file mode 100644 index 0000000..1541735 Binary files /dev/null and b/storage/uploads/favicons/favicon-1751900673622.png differ diff --git a/storage/uploads/favicons/favicon-1751901117619.png b/storage/uploads/favicons/favicon-1751901117619.png new file mode 100644 index 0000000..1541735 Binary files /dev/null and b/storage/uploads/favicons/favicon-1751901117619.png differ diff --git a/storage/uploads/logos/logo-1751899826077.png b/storage/uploads/logos/logo-1751899826077.png new file mode 100644 index 0000000..c412564 Binary files /dev/null and b/storage/uploads/logos/logo-1751899826077.png differ diff --git a/storage/uploads/logos/logo-1751900093292.png b/storage/uploads/logos/logo-1751900093292.png new file mode 100644 index 0000000..c412564 Binary files /dev/null and b/storage/uploads/logos/logo-1751900093292.png differ diff --git a/storage/uploads/logos/logo-1751900103851.png b/storage/uploads/logos/logo-1751900103851.png new file mode 100644 index 0000000..c412564 Binary files /dev/null and b/storage/uploads/logos/logo-1751900103851.png differ diff --git a/storage/uploads/logos/logo-1751900150420.png b/storage/uploads/logos/logo-1751900150420.png new file mode 100644 index 0000000..1541735 Binary files /dev/null and b/storage/uploads/logos/logo-1751900150420.png differ diff --git a/storage/uploads/logos/logo-1751900199827.png b/storage/uploads/logos/logo-1751900199827.png new file mode 100644 index 0000000..1541735 Binary files /dev/null and b/storage/uploads/logos/logo-1751900199827.png differ diff --git a/storage/uploads/logos/logo-1751901737940.png b/storage/uploads/logos/logo-1751901737940.png new file mode 100644 index 0000000..91e0c72 Binary files /dev/null and b/storage/uploads/logos/logo-1751901737940.png differ diff --git a/update-local.sh b/update-local.sh new file mode 100755 index 0000000..8280f89 --- /dev/null +++ b/update-local.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🔄 Updating Photo Sharing Platform - Local Development${NC}" +echo "====================================================" + +# Check if Docker is running +if ! docker info &> /dev/null; then + echo -e "${RED}❌ Docker is not running. Please start Docker Desktop.${NC}" + exit 1 +fi + +# Stop all containers +echo -e "${YELLOW}🛑 Stopping all containers...${NC}" +docker-compose -f docker-compose.local.yml down + +# Remove old images to force rebuild +echo -e "${YELLOW}🗑️ Removing old images...${NC}" +docker-compose -f docker-compose.local.yml rm -f + +# Pull latest base images +echo -e "${YELLOW}📥 Pulling latest base images...${NC}" +docker-compose -f docker-compose.local.yml pull + +# Build frontend production files +echo -e "${YELLOW}📦 Building frontend production files...${NC}" +cd frontend +npm install --legacy-peer-deps +npm run build +cd .. + +# Rebuild all images with no cache +echo -e "${YELLOW}🔨 Rebuilding Docker images (no cache)...${NC}" +docker-compose -f docker-compose.local.yml build --no-cache + +# Start all services +echo -e "${YELLOW}🚀 Starting services...${NC}" +docker-compose -f docker-compose.local.yml up -d + +# Wait for backend to be ready +echo -e "${YELLOW}⏳ Waiting for backend to start...${NC}" +max_attempts=30 +attempt=1 +while [ $attempt -le $max_attempts ]; do + if curl -s http://localhost:3001/api/health > /dev/null 2>&1; then + echo -e "${GREEN}✅ Backend is ready!${NC}" + break + fi + echo -n "." + sleep 2 + attempt=$((attempt + 1)) +done + +if [ $attempt -gt $max_attempts ]; then + echo -e "${RED}❌ Backend failed to start. Check logs with: docker-compose -f docker-compose.local.yml logs backend${NC}" + exit 1 +fi + +# Wait a bit more for frontend to be ready +echo -e "${YELLOW}⏳ Waiting for frontend to be ready...${NC}" +sleep 5 + +# Show status +echo "" +echo -e "${GREEN}✅ Local development environment has been updated!${NC}" +echo "" +echo -e "${GREEN}🌐 Access Points:${NC}" +echo " Frontend (Nginx): http://localhost:3005" +echo " Frontend (Dev): http://localhost:3002" +echo " Backend API: http://localhost:3001" +echo " Mailhog: http://localhost:8025" +echo "" +echo -e "${GREEN}📝 Container Status:${NC}" +docker-compose -f docker-compose.local.yml ps +echo "" +echo -e "${GREEN}💡 Tips:${NC}" +echo " - View logs: docker-compose -f docker-compose.local.yml logs -f" +echo " - View specific service logs: docker-compose -f docker-compose.local.yml logs -f [service-name]" +echo " - Stop all: ./stop-local.sh" +echo "" + +# Open browser +if command -v xdg-open &> /dev/null; then + xdg-open http://localhost:3005 +elif command -v open &> /dev/null; then + open http://localhost:3005 +fi + +echo -e "${GREEN}✨ Update complete! The browser should open automatically.${NC}" \ No newline at end of file