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: '
Please edit this content in the admin panel.
', + content_de: 'Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.
', + updated_at: new Date() + }, + { + slug: 'datenschutz', + title_en: 'Privacy Policy', + title_de: 'Datenschutzerklärung', + content_en: 'Please edit this content in the admin panel.
', + content_de: '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 = ` + + `; + + 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() {- {usagePercent}% of {settingsService.formatBytes(storageInfo.storage_limit)} + {t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
+ No categories yet. Create your first category to organize photos. +
+ ) : ( + categories.map((category) => ( +{category.name}
+/{category.slug}
++ No event-specific categories. Global categories are available by default. +
+ ) : ( +Global Categories (always available):
+- {this.state.error?.message || 'An unexpected error occurred. Please try refreshing the page.'} + {this.state.error?.message || i18n.t('errors.tryAgainLater')}
- We encountered an unexpected error. Don't worry, your data is safe. + {i18n.t('errors.unexpectedError')}
{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 (
+
+ setIsOpen(!isOpen)}
+ className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
+ >
+
+ {currentLanguage.flag}
+ {currentLanguage.name}
+
+
+ {isOpen && (
+
+ {languages.map((language) => (
+ handleLanguageChange(language.code)}
+ className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
+ language.code === i18n.language
+ ? 'text-primary-600 bg-primary-50'
+ : 'text-neutral-700'
+ }`}
+ >
+ {language.flag}
+ {language.name}
+
+ ))}
+
+ )}
+
+ );
+};
+
+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')}
{brandingSettings.company_tagline}
+ )} +Failed to load photos
+{t('gallery.failedToLoad')}
{brandingSettings.company_tagline}
- )} -{event.welcome_message}
- {filteredPhotos.length} {filteredPhotos.length === 1 ? 'photo' : 'photos'} -
-+ {photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')} +
+No photos found
+{t('gallery.noPhotosFound')}
- This gallery does not exist or has been removed. -
-+ {t('errors.galleryNotFoundMessage')} +
+- This gallery expired on {format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy')}. -
-- Please contact the event organizer if you need access to these photos. -
-+ {t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy') })} +
++ {t('gallery.contactOrganizer')} +
+- 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')}
- The password was provided by the event organizer. - Contact them if you don't have it. + {t('auth.passwordHint')}
Welcome back! Here's what's happening with your galleries.
+{t('admin.dashboardSubtitle')}
No events expiring in the next 7 days
+{t('admin.noEventsExpiring')}
) : (- {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')}
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 */}PNG or ICO format, recommended size: 32x32px
+PNG format with transparency recommended
+Manage legal and informational pages
+{t(`legal.${page.slug}`)}
+/{page.slug}
++ Last updated: {new Date(currentPage.updated_at).toLocaleString()} +
+ )} +- Photos can also be added by placing them in the 'individual' or 'collages' folders. + Photos are organized by categories you define.
+ Sets the default language for all gallery pages and login screens +
++ 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. +
+{photo.filename}
+ {photo.category_name && ( +{photo.category_name}
+ )} +This is how your galleries will look with the current theme settings
++ The page you're looking for doesn't exist. +
+ +