Fix language setting not being saved to database on admin settings page
- Added default_language field to general settings state in SettingsPage - Replaced LanguageSelector component with simple select dropdown on settings page - Fixed public settings endpoint to read general_default_language from database - Language setting now properly saved when clicking Save Settings button - Setting is correctly used by gallery login page and legal pages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
@@ -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: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
slug: 'datenschutz',
|
||||
title_en: 'Privacy Policy',
|
||||
title_de: 'Datenschutzerklärung',
|
||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
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 };
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,6 +47,7 @@ async function photoAuth(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await db('access_logs').insert({
|
||||
@@ -25,10 +58,15 @@ async function photoAuth(req, res, next) {
|
||||
});
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -34,7 +34,7 @@ router.post('/', adminAuth, [
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
color_theme = 'default',
|
||||
color_theme = null,
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 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,11 +231,22 @@ 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);
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = `
|
||||
<svg width="${settings.companyName.length * fontSize * 0.6 + padding * 2}" height="${fontSize + padding * 2}">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="black" opacity="0.5" rx="5"/>
|
||||
<text x="${padding}" y="${fontSize + padding/2}"
|
||||
font-family="Arial, sans-serif"
|
||||
font-size="${fontSize}"
|
||||
fill="white"
|
||||
opacity="${settings.opacity / 100}">
|
||||
${settings.companyName}
|
||||
</text>
|
||||
</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();
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<PageErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<DynamicFavicon />
|
||||
<Router>
|
||||
<SkipLink />
|
||||
<Routes>
|
||||
{/* Public gallery routes */}
|
||||
<Route path="/gallery/preview" element={<PreviewPage />} />
|
||||
<Route path="/gallery/:slug/:token?" element={
|
||||
<GalleryAuthProvider>
|
||||
<GalleryPage />
|
||||
@@ -76,10 +81,16 @@ function App() {
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Public legal pages */}
|
||||
<Route path="/impressum" element={<LegalPage />} />
|
||||
<Route path="/datenschutz" element={<LegalPage />} />
|
||||
<Route path="/:slug" element={<LegalPage />} />
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
||||
import { PasswordChangeModal } from './PasswordChangeModal';
|
||||
import { LanguageSelector } from '../common';
|
||||
|
||||
interface AdminHeaderProps {
|
||||
onMenuClick: () => void;
|
||||
@@ -14,6 +16,7 @@ interface AdminHeaderProps {
|
||||
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAdminAuth();
|
||||
const { t } = useTranslation();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
@@ -66,6 +69,9 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative" ref={notificationRef}>
|
||||
<button
|
||||
@@ -82,7 +88,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
{showNotifications && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-neutral-200 py-2">
|
||||
<div className="px-4 py-2 border-b border-neutral-100">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Notifications</h3>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.map((notification) => (
|
||||
@@ -99,7 +105,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-neutral-100">
|
||||
<button className="text-sm text-primary-600 hover:text-primary-700">
|
||||
View all notifications
|
||||
{t('admin.viewAllNotifications')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +142,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
{t('navigation.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -146,14 +152,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
Change Password
|
||||
{t('admin.changePassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign Out
|
||||
{t('common.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
Settings,
|
||||
Camera,
|
||||
X,
|
||||
Palette
|
||||
Palette,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
|
||||
interface AdminSidebarProps {
|
||||
@@ -20,23 +22,25 @@ interface AdminSidebarProps {
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
nameKey: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Events', href: '/admin/events', icon: Calendar },
|
||||
{ name: 'Archives', href: '/admin/archives', icon: Archive },
|
||||
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||
{ name: 'Email Settings', href: '/admin/email', icon: Mail },
|
||||
{ name: 'Branding', href: '/admin/branding', icon: Palette },
|
||||
{ name: 'Settings', href: '/admin/settings', icon: Settings },
|
||||
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
|
||||
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar },
|
||||
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive },
|
||||
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
|
||||
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
|
||||
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
|
||||
];
|
||||
|
||||
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -49,7 +53,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200">
|
||||
<div className="flex items-center">
|
||||
<Camera className="w-8 h-8 text-primary-600" />
|
||||
<span className="ml-2 text-xl font-bold text-neutral-900">Photo Admin</span>
|
||||
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -67,7 +71,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
key={item.nameKey}
|
||||
to={item.href}
|
||||
onClick={() => onClose()}
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
@@ -79,7 +83,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
<item.icon className={`w-5 h-5 mr-3 ${
|
||||
isActive ? 'text-primary-600' : 'text-neutral-400'
|
||||
}`} />
|
||||
{item.name}
|
||||
{t(item.nameKey)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@@ -93,6 +97,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
};
|
||||
|
||||
const StorageInfo: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: storageInfo } = useQuery({
|
||||
queryKey: ['storage-info'],
|
||||
queryFn: () => settingsService.getStorageInfo(),
|
||||
@@ -115,7 +120,7 @@ const StorageInfo: React.FC = () => {
|
||||
<div className="p-4 border-t border-neutral-200">
|
||||
<div className="bg-neutral-100 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-700">Storage Used</span>
|
||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</span>
|
||||
@@ -127,7 +132,7 @@ const StorageInfo: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
{usagePercent}% of {settingsService.formatBytes(storageInfo.storage_limit)}
|
||||
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Undo,
|
||||
Redo
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
|
||||
interface CMSEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
}
|
||||
|
||||
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
// Update editor content when prop changes
|
||||
React.useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addLink = () => {
|
||||
if (linkUrl) {
|
||||
editor.chain().focus().setLink({ href: linkUrl }).run();
|
||||
setLinkUrl('');
|
||||
setShowLinkDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const MenuButton: React.FC<{
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}> = ({ onClick, active, children, title }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`p-2 rounded hover:bg-neutral-100 ${
|
||||
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
|
||||
}`}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-300 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-2 border-b border-neutral-200 bg-neutral-50 flex-wrap">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title="Add Link"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{showLinkDialog && (
|
||||
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addLink()}
|
||||
placeholder="Enter URL..."
|
||||
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={addLink}>Add Link</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setShowLinkDialog(false);
|
||||
setLinkUrl('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[300px] p-4 prose prose-neutral max-w-none focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CMSEditor.displayName = 'CMSEditor';
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
|
||||
export const CategoryManager: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// Fetch global categories
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['global-categories'],
|
||||
queryFn: categoriesService.getGlobalCategories,
|
||||
});
|
||||
|
||||
// Create category mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({ name, is_global: true }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||
toast.success('Category created successfully');
|
||||
setNewCategoryName('');
|
||||
setIsAdding(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create category');
|
||||
},
|
||||
});
|
||||
|
||||
// Update category mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: number; name: string }) =>
|
||||
categoriesService.updateCategory(id, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||
toast.success('Category updated successfully');
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update category');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete category mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: categoriesService.deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||
toast.success('Category deleted successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete category');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = (id: number) => {
|
||||
if (editingName.trim()) {
|
||||
updateMutation.mutate({ id, name: editingName.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (category: PhotoCategory) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
|
||||
deleteMutation.mutate(category.id);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (category: PhotoCategory) => {
|
||||
setEditingId(category.id);
|
||||
setEditingName(category.name);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Photo Categories</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setIsAdding(true)}
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
Add Category
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new category form */}
|
||||
{isAdding && (
|
||||
<div className="flex gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
placeholder="Category name"
|
||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!newCategoryName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewCategoryName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories list */}
|
||||
<div className="space-y-2">
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-neutral-500 text-center py-8">
|
||||
No categories yet. Create your first category to organize photos.
|
||||
</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-lg border border-neutral-200 hover:border-neutral-300 transition-colors"
|
||||
>
|
||||
{editingId === category.id ? (
|
||||
<div className="flex gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') handleUpdate(category.id);
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
}}
|
||||
className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleUpdate(category.id)}
|
||||
disabled={!editingName.trim() || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500">/{category.slug}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(category)}
|
||||
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||
title="Edit category"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="Delete category"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CategoryManager.displayName = 'CategoryManager';
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
|
||||
interface EventCategoryManagerProps {
|
||||
eventId: number;
|
||||
}
|
||||
|
||||
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
// Fetch categories for this event
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
// Filter to show only event-specific categories
|
||||
const eventCategories = categories.filter(cat => !cat.is_global);
|
||||
|
||||
// Create category mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
is_global: false,
|
||||
event_id: eventId
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
toast.success('Category created successfully');
|
||||
setNewCategoryName('');
|
||||
setIsAdding(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create category');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete category mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: categoriesService.deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
toast.success('Category deleted successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete category');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (category: PhotoCategory) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
|
||||
deleteMutation.mutate(category.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700">Event-Specific Categories</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdding(true)}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new category form */}
|
||||
{isAdding && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
placeholder="Category name"
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!newCategoryName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
'Add'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewCategoryName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
No event-specific categories. Global categories are available by default.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{eventCategories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
|
||||
>
|
||||
<span className="text-sm text-neutral-700">{category.name}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
|
||||
title="Delete category"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">Global Categories (always available):</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
.map(cat => (
|
||||
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
|
||||
{cat.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EventCategoryManager.displayName = 'EventCategoryManager';
|
||||
@@ -4,6 +4,8 @@ import { Button } from '../common';
|
||||
import { clsx } from 'clsx';
|
||||
import { api } from '../../config/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
eventId: number;
|
||||
@@ -14,9 +16,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [photoType, setPhotoType] = useState<'individual' | 'collage'>('individual');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch categories for this event
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
@@ -36,10 +44,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setUploadProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
selectedFiles.forEach((file, index) => {
|
||||
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`);
|
||||
formData.append('photos', file);
|
||||
});
|
||||
formData.append('type', photoType);
|
||||
|
||||
if (selectedCategoryId) {
|
||||
formData.append('category_id', selectedCategoryId.toString());
|
||||
}
|
||||
|
||||
// Debug: Log FormData contents
|
||||
console.log('FormData entries:');
|
||||
for (let pair of formData.entries()) {
|
||||
console.log(pair[0], pair[1]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
|
||||
@@ -86,33 +104,23 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Photo Type Selection */}
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Photo Type
|
||||
Photo Category
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="individual"
|
||||
checked={photoType === 'individual'}
|
||||
onChange={(e) => setPhotoType(e.target.value as 'individual')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span>Individual Photos</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="collage"
|
||||
checked={photoType === 'collage'}
|
||||
onChange={(e) => setPhotoType(e.target.value as 'collage')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span>Collages</span>
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
value={selectedCategoryId || ''}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name} {!category.is_global && '(Event specific)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* File Input Area */}
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import { PRESET_THEMES, type ThemeConfig } from '../../contexts/ThemeContext';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface ThemeCustomizerProps {
|
||||
value: ThemeConfig;
|
||||
onChange: (theme: ThemeConfig) => void;
|
||||
presetName?: string;
|
||||
onPresetChange?: (presetName: string) => void;
|
||||
isPreviewMode?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetName = 'default',
|
||||
onPresetChange
|
||||
onPresetChange,
|
||||
isPreviewMode = false
|
||||
}) => {
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||
const [customCss, setCustomCss] = useState(value.customCss || '');
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTheme(value);
|
||||
setCustomCss(value.customCss || '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPreset(presetName);
|
||||
}, [presetName]);
|
||||
|
||||
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
|
||||
const updated = { ...localTheme, [key]: newValue };
|
||||
setLocalTheme(updated);
|
||||
@@ -55,18 +63,31 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
handlePresetSelect('default');
|
||||
const defaultPreset = PRESET_THEMES['default'];
|
||||
if (defaultPreset) {
|
||||
setSelectedPreset('default');
|
||||
setLocalTheme(defaultPreset.config);
|
||||
setCustomCss('');
|
||||
onChange(defaultPreset.config);
|
||||
if (onPresetChange) {
|
||||
onPresetChange('default');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const dataUrl = e.target?.result as string;
|
||||
handleChange('logoUrl', dataUrl);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
try {
|
||||
// Upload to server
|
||||
const logoUrl = await settingsService.uploadLogo(file);
|
||||
// Update theme with the server URL
|
||||
handleChange('logoUrl', logoUrl);
|
||||
toast.success('Logo uploaded successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload logo:', error);
|
||||
toast.error('Failed to upload logo');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,13 +273,13 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
<div className="flex items-center gap-4">
|
||||
{localTheme.logoUrl && (
|
||||
<img
|
||||
src={localTheme.logoUrl}
|
||||
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${localTheme.logoUrl}`}
|
||||
alt="Custom logo"
|
||||
className="h-16 w-auto object-contain"
|
||||
/>
|
||||
)}
|
||||
<label className="cursor-pointer">
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoUpload}
|
||||
@@ -268,17 +289,17 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
>
|
||||
Upload Logo
|
||||
</Button>
|
||||
</label>
|
||||
{localTheme.logoUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleChange('logoUrl', undefined)}
|
||||
onClick={() => handleChange('logoUrl', '')}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
@@ -303,19 +324,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPreviewMode}
|
||||
onChange={(e) => setIsPreviewMode(e.target.checked)}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">Live Preview</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
@@ -332,6 +341,5 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,3 +5,6 @@ export { ThemeCustomizer } from './ThemeCustomizer';
|
||||
export { PasswordChangeModal } from './PasswordChangeModal';
|
||||
export { AdminAuthWrapper } from './AdminAuthWrapper';
|
||||
export { PhotoUpload } from './PhotoUpload';
|
||||
export { CategoryManager } from './CategoryManager';
|
||||
export { EventCategoryManager } from './EventCategoryManager';
|
||||
export { CMSEditor } from './CMSEditor';
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getAuthToken } from '../../config/api';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
src,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
useWatermark = false,
|
||||
...props
|
||||
}) => {
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const token = getAuthToken();
|
||||
|
||||
if (!src) {
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.warn('No auth token found for image:', src);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Create a new URL with auth header
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// If watermark is requested and this is a gallery photo, use the protected images endpoint
|
||||
let imageUrl = src;
|
||||
if (useWatermark && src.includes('/photos/')) {
|
||||
// Extract gallery slug and photo ID from the URL
|
||||
// URL format: /photos/events/active/{slug}/photos/{photoId}.jpg
|
||||
const match = src.match(/\/photos\/events\/active\/([^\/]+)\/photos\/(\d+)\./);
|
||||
if (match) {
|
||||
const [, slug, photoId] = match;
|
||||
imageUrl = `/api/images/${slug}/photo/${photoId}/view`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Fetching authenticated image:', imageUrl);
|
||||
const response = await fetch(imageUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load image:', src, err);
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImage();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src, fallbackSrc, useWatermark]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
|
||||
{/* Show a placeholder while loading */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && fallbackSrc) {
|
||||
return <img src={fallbackSrc} alt={alt} {...props} />;
|
||||
}
|
||||
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <img src={imageSrc} alt={alt} {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const DynamicFavicon: React.FC = () => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.branding_favicon_url) {
|
||||
// Remove existing favicon links
|
||||
const existingFavicons = document.querySelectorAll("link[rel*='icon']");
|
||||
existingFavicons.forEach(favicon => favicon.remove());
|
||||
|
||||
// Create new favicon link
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = 'image/png';
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_favicon_url}`;
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}, [settings?.branding_favicon_url]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import i18n from '../../i18n/config';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -46,16 +47,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
<div className="text-center max-w-md">
|
||||
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
|
||||
Something went wrong
|
||||
{i18n.t('errors.somethingWentWrong')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mb-6">
|
||||
{this.state.error?.message || 'An unexpected error occurred. Please try refreshing the page.'}
|
||||
{this.state.error?.message || i18n.t('errors.tryAgainLater')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={this.handleReset}
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
>
|
||||
Refresh Page
|
||||
{i18n.t('errors.refreshPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,10 +94,10 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-6" />
|
||||
<h1 className="text-2xl font-bold text-neutral-900 mb-4">
|
||||
Oops! Something went wrong
|
||||
{i18n.t('errors.oopsSomethingWentWrong')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mb-8">
|
||||
We encountered an unexpected error. Don't worry, your data is safe.
|
||||
{i18n.t('errors.unexpectedError')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
@@ -105,20 +106,20 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
Go to Homepage
|
||||
{i18n.t('errors.goToHomepage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full"
|
||||
>
|
||||
Try Again
|
||||
{i18n.t('gallery.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mt-8 text-left">
|
||||
<summary className="text-sm text-neutral-500 cursor-pointer hover:text-neutral-700">
|
||||
Error Details
|
||||
{i18n.t('errors.errorDetails')}
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-neutral-100 p-3 rounded overflow-auto">
|
||||
{this.state.error.stack}
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.name}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
|
||||
{languages.map((language) => (
|
||||
<button
|
||||
key={language.code}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{language.flag}</span>
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LanguageSelector.displayName = 'LanguageSelector';
|
||||
@@ -13,3 +13,6 @@ export {
|
||||
} from './Skeleton';
|
||||
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
||||
export { SkipLink } from './SkipLink';
|
||||
export { DynamicFavicon } from './DynamicFavicon';
|
||||
export { LanguageSelector } from './LanguageSelector';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
@@ -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<CountdownTimerProps> = ({ expiresAt, className = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -43,7 +45,7 @@ export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, class
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-red-600 ${className}`}>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span className="font-semibold">Gallery Expired</span>
|
||||
<span className="font-semibold">{t('gallery.expired')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +71,7 @@ export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, class
|
||||
{String(timeLeft.seconds).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-orange-600 font-medium">remaining</span>
|
||||
<span className="text-sm text-orange-600 font-medium">{t('gallery.remaining')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { AlertTriangle, Download } from 'lucide-react';
|
||||
import Countdown from 'react-countdown';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ExpirationBannerProps {
|
||||
daysRemaining: number;
|
||||
@@ -12,11 +13,12 @@ export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
|
||||
daysRemaining,
|
||||
expiresAt
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const expirationDate = parseISO(expiresAt);
|
||||
|
||||
const countdownRenderer = ({ days, hours, minutes, completed }: any) => {
|
||||
if (completed) {
|
||||
return <span>Gallery has expired</span>;
|
||||
return <span>{t('gallery.expired')}</span>;
|
||||
} else {
|
||||
return (
|
||||
<span className="font-mono">
|
||||
@@ -39,12 +41,12 @@ export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 mr-2 animate-pulse" />
|
||||
<span className="font-medium">
|
||||
Gallery expires in <Countdown date={expirationDate} renderer={countdownRenderer} />
|
||||
{t('gallery.expiresIn', { count: daysRemaining })} <Countdown date={expirationDate} renderer={countdownRenderer} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
<span>Download your photos now!</span>
|
||||
<span>{t('gallery.downloadBefore')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, LanguageSelector } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
|
||||
interface GalleryLayoutProps {
|
||||
event: {
|
||||
event_name: string;
|
||||
event_type?: string;
|
||||
event_date?: string;
|
||||
expires_at?: string;
|
||||
};
|
||||
brandingSettings?: {
|
||||
company_name?: string;
|
||||
company_tagline?: string;
|
||||
support_email?: string;
|
||||
footer_text?: string;
|
||||
favicon_url?: string;
|
||||
logo_url?: string;
|
||||
};
|
||||
showLogout?: boolean;
|
||||
onLogout?: () => void;
|
||||
showDownloadAll?: boolean;
|
||||
onDownloadAll?: () => void;
|
||||
isDownloading?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
event,
|
||||
brandingSettings,
|
||||
showLogout = false,
|
||||
onLogout,
|
||||
showDownloadAll = false,
|
||||
onDownloadAll,
|
||||
isDownloading = false,
|
||||
headerExtra,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
||||
<div className="container py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Company logo */}
|
||||
{brandingSettings?.logo_url && (
|
||||
<div className="pr-4 border-r border-neutral-200">
|
||||
<img
|
||||
src={brandingSettings.logo_url}
|
||||
alt={brandingSettings.company_name || 'Company Logo'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Company branding */}
|
||||
{!brandingSettings?.logo_url && brandingSettings?.company_name && (
|
||||
<div className="pr-4 border-r border-neutral-200">
|
||||
<h2 className="text-lg font-semibold text-neutral-800">{brandingSettings.company_name}</h2>
|
||||
{brandingSettings.company_tagline && (
|
||||
<p className="text-xs text-neutral-600">{brandingSettings.company_tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
{t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{headerExtra}
|
||||
<LanguageSelector />
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
{t('gallery.downloadAll')}
|
||||
</Button>
|
||||
)}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 py-8 border-t border-neutral-200">
|
||||
<div className="container text-center">
|
||||
{brandingSettings?.support_email && (
|
||||
<p className="text-sm text-neutral-600 mb-2">
|
||||
{t('gallery.needHelp')}{' '}
|
||||
<a
|
||||
href={`mailto:${brandingSettings.support_email}`}
|
||||
className="text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{brandingSettings.support_email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
{brandingSettings.company_name} - {brandingSettings.company_tagline}
|
||||
</p>
|
||||
)}
|
||||
{/* Legal Links */}
|
||||
<div className="mt-4 flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GalleryLayout.displayName = 'GalleryLayout';
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Download, Grid, Square, LogOut, Calendar, Clock, Search, SortAsc } from 'lucide-react';
|
||||
import { format, differenceInDays, parseISO } from 'date-fns';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, SkeletonGalleryGrid, Skeleton } from '../common';
|
||||
import { Button, SkeletonGalleryGrid, Skeleton } from '../common';
|
||||
import { useGalleryAuth, useTheme } from '../../contexts';
|
||||
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { ExpirationBanner } from './ExpirationBanner';
|
||||
import { CountdownTimer } from './CountdownTimer';
|
||||
import { GalleryLayout } from './GalleryLayout';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
@@ -26,13 +28,14 @@ interface GalleryViewProps {
|
||||
}
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { setTheme } = useTheme();
|
||||
const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const themeAppliedRef = useRef(false);
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||
@@ -48,40 +51,56 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Apply theme and branding settings
|
||||
// Apply branding settings
|
||||
useEffect(() => {
|
||||
if (settingsData) {
|
||||
// Apply branding settings
|
||||
setBrandingSettings({
|
||||
company_name: settingsData.branding_company_name || '',
|
||||
company_tagline: settingsData.branding_company_tagline || '',
|
||||
support_email: settingsData.branding_support_email || '',
|
||||
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
|
||||
watermark_enabled: settingsData.branding_watermark_enabled || false,
|
||||
logo_url: settingsData.branding_logo_url || null,
|
||||
});
|
||||
|
||||
// Apply theme settings
|
||||
if (settingsData.theme_config) {
|
||||
setTheme(settingsData.theme_config);
|
||||
}
|
||||
}
|
||||
}, [settingsData, setTheme]);
|
||||
}, [settingsData]);
|
||||
|
||||
// Apply event-specific theme if available
|
||||
// Apply theme only once when component mounts and settings are loaded
|
||||
useEffect(() => {
|
||||
if (!themeAppliedRef.current && settingsData) {
|
||||
let themeToApply = null;
|
||||
|
||||
if (event.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
if (event.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(event.color_theme);
|
||||
console.log('Applying event-specific theme:', eventTheme);
|
||||
setTheme(eventTheme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - use global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse event theme:', e);
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
} else if (settingsData?.theme_config) {
|
||||
// Fall back to global theme if no event-specific theme
|
||||
console.log('No event theme, using global theme');
|
||||
}
|
||||
}, [event.color_theme, setTheme, settingsData]);
|
||||
} else if (settingsData.theme_config) {
|
||||
// No event theme, use global theme
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Apply theme only once
|
||||
if (themeToApply) {
|
||||
themeAppliedRef.current = true;
|
||||
setTheme(themeToApply);
|
||||
}
|
||||
}
|
||||
}, [settingsData]); // Only depend on settingsData, not setTheme or event
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
@@ -93,11 +112,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
|
||||
let photos = [...data.photos];
|
||||
|
||||
// Apply view mode filter
|
||||
if (viewMode === 'collages') {
|
||||
photos = photos.filter(photo => photo.type === 'collage');
|
||||
} else if (viewMode === 'individual') {
|
||||
photos = photos.filter(photo => photo.type === 'individual');
|
||||
// Apply category filter
|
||||
if (selectedCategoryId) {
|
||||
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
@@ -122,7 +139,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
});
|
||||
|
||||
return photos;
|
||||
}, [data?.photos, viewMode, searchTerm, sortBy]);
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy]);
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
downloadAllMutation.mutate(slug);
|
||||
@@ -185,9 +202,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-neutral-600">Failed to load photos</p>
|
||||
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
|
||||
<Button onClick={() => window.location.reload()} className="mt-4">
|
||||
Try Again
|
||||
{t('gallery.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,71 +212,28 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={true}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
headerExtra={
|
||||
daysUntilExpiration <= 1 && daysUntilExpiration > 0 ? (
|
||||
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
||||
<div className="container py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Company branding */}
|
||||
{brandingSettings?.company_name && (
|
||||
<div className="pr-4 border-r border-neutral-200">
|
||||
<h2 className="text-lg font-semibold text-neutral-800">{brandingSettings.company_name}</h2>
|
||||
{brandingSettings.company_tagline && (
|
||||
<p className="text-xs text-neutral-600">{brandingSettings.company_tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-neutral-600">
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Expires {format(parseISO(event.expires_at), 'MMM d')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
|
||||
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={handleDownloadAll}
|
||||
isLoading={downloadAllMutation.isPending}
|
||||
className={showUrgentWarning ? 'animate-pulse' : ''}
|
||||
>
|
||||
Download All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Welcome Message */}
|
||||
{event.welcome_message && (
|
||||
<div className="container mt-6">
|
||||
<div className="mt-6">
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<p className="text-primary-900">{event.welcome_message}</p>
|
||||
</div>
|
||||
@@ -267,125 +241,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="container mt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 mb-6">
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search photos by filename..."
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<SortAsc className="w-4 h-4" />}
|
||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
||||
>
|
||||
Sort by {sortBy === 'date' ? 'Date' : sortBy === 'name' ? 'Name' : 'Size'}
|
||||
</Button>
|
||||
|
||||
{showSortMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSortBy('date');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
||||
>
|
||||
Sort by Date
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSortBy('name');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
||||
>
|
||||
Sort by Name
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSortBy('size');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
||||
>
|
||||
Sort by Size
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'all' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('all')}
|
||||
leftIcon={<Grid className="w-4 h-4" />}
|
||||
>
|
||||
All Photos ({data.photos.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'collages' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('collages')}
|
||||
leftIcon={<Square className="w-4 h-4" />}
|
||||
>
|
||||
Collages ({data.photos.filter(p => p.type === 'collage').length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'individual' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('individual')}
|
||||
>
|
||||
Individual ({data.photos.filter(p => p.type === 'individual').length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600">
|
||||
{filteredPhotos.length} {filteredPhotos.length === 1 ? 'photo' : 'photos'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Photo Grid */}
|
||||
<PhotoGrid photos={filteredPhotos} slug={slug} />
|
||||
<div className="mt-6">
|
||||
<PhotoGrid photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 py-8 border-t border-neutral-200">
|
||||
<div className="container text-center">
|
||||
{brandingSettings?.support_email && (
|
||||
<p className="text-sm text-neutral-600 mb-2">
|
||||
Need help? Contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${brandingSettings.support_email}`}
|
||||
className="text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{brandingSettings.support_email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
{brandingSettings.company_name} - {brandingSettings.company_tagline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</GalleryLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, SortAsc, Grid } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
|
||||
interface PhotoCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_global: boolean;
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
id: number;
|
||||
category_id?: number;
|
||||
}
|
||||
|
||||
interface PhotoFilterBarProps {
|
||||
categories?: PhotoCategory[];
|
||||
photos: Photo[];
|
||||
selectedCategoryId: number | null;
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size') => void;
|
||||
photoCount: number;
|
||||
}
|
||||
|
||||
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
categories = [],
|
||||
photos,
|
||||
selectedCategoryId,
|
||||
onCategoryChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
photoCount,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search and Sort */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('gallery.searchPhotos')}
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<SortAsc className="w-4 h-4" />}
|
||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
||||
>
|
||||
{t('common.sortBy')} {sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') : sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') : t('gallery.sortBySize').replace('Sort by ', '')}
|
||||
</Button>
|
||||
|
||||
{showSortMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
onSortChange('date');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
|
||||
sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{t('gallery.sortByDate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSortChange('name');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
|
||||
sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{t('gallery.sortByName')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSortChange('size');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
|
||||
sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{t('gallery.sortBySize')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
leftIcon={<Grid className="w-4 h-4" />}
|
||||
>
|
||||
{t('gallery.allPhotos')} ({photos.length})
|
||||
</Button>
|
||||
{categories.map((category) => {
|
||||
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
|
||||
if (categoryPhotoCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
>
|
||||
{category.name} ({categoryPhotoCount})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600">
|
||||
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PhotoFilterBar.displayName = 'PhotoFilterBar';
|
||||
@@ -1,28 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Maximize2, Check, Package } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { toast } from 'react-toastify';
|
||||
import { toast as toastify } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { PhotoLightbox } from './PhotoLightbox';
|
||||
import { Button } from '../common';
|
||||
import { Button, AuthenticatedImage } from '../common';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
|
||||
interface PhotoGridProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
categoryId?: number | null;
|
||||
}
|
||||
|
||||
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
|
||||
const handlePhotoClick = (index: number) => {
|
||||
if (isSelectionMode) {
|
||||
// Clear selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedPhotos(new Set());
|
||||
}, [categoryId]);
|
||||
|
||||
const handlePhotoClick = (index: number, e?: React.MouseEvent) => {
|
||||
// Check for ctrl/cmd+click for quick selection
|
||||
if (e && (e.ctrlKey || e.metaKey)) {
|
||||
if (!isSelectionMode) {
|
||||
setIsSelectionMode(true);
|
||||
}
|
||||
const newSelected = new Set(selectedPhotos);
|
||||
if (newSelected.has(photos[index].id)) {
|
||||
newSelected.delete(photos[index].id);
|
||||
} else {
|
||||
newSelected.add(photos[index].id);
|
||||
}
|
||||
setSelectedPhotos(newSelected);
|
||||
} else if (isSelectionMode) {
|
||||
const newSelected = new Set(selectedPhotos);
|
||||
if (newSelected.has(photos[index].id)) {
|
||||
newSelected.delete(photos[index].id);
|
||||
@@ -66,7 +86,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
|
||||
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
toast.info(`Downloading ${selectedPhotos.size} photos...`);
|
||||
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
|
||||
|
||||
// Download each selected photo
|
||||
const downloadPromises = selectedPhotosList.map(photo =>
|
||||
@@ -79,7 +99,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
|
||||
try {
|
||||
await Promise.all(downloadPromises);
|
||||
toast.success(`Downloaded ${selectedPhotos.size} photos!`);
|
||||
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
@@ -91,14 +111,14 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
} catch (error) {
|
||||
toast.error('Some photos failed to download');
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
}
|
||||
};
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-600">No photos found</p>
|
||||
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,24 +128,39 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
{/* Selection Mode Controls */}
|
||||
{photos.length > 1 && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleSelectionMode}
|
||||
title={t('gallery.selectPhotosHint')}
|
||||
>
|
||||
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
{!isSelectionMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsSelectionMode(true);
|
||||
selectAll();
|
||||
}}
|
||||
>
|
||||
{t('gallery.selectAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelectionMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-neutral-600">
|
||||
{selectedPhotos.size} selected
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||
Select All
|
||||
{t('gallery.selectAll')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll}>
|
||||
Deselect All
|
||||
{t('gallery.deselectAll')}
|
||||
</Button>
|
||||
{selectedPhotos.size > 0 && (
|
||||
<Button
|
||||
@@ -134,7 +169,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
leftIcon={<Package className="w-4 h-4" />}
|
||||
onClick={handleDownloadSelected}
|
||||
>
|
||||
Download {selectedPhotos.size} Selected
|
||||
{t('gallery.downloadSelected', { count: selectedPhotos.size })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -150,7 +185,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(index)}
|
||||
onClick={(e) => handlePhotoClick(index, e)}
|
||||
onDownload={(e) => handleDownload(photo, e)}
|
||||
/>
|
||||
))}
|
||||
@@ -173,7 +208,7 @@ interface PhotoThumbnailProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
@@ -192,12 +227,12 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative group cursor-pointer"
|
||||
onClick={onClick}
|
||||
className="relative group cursor-pointer aspect-square"
|
||||
onClick={(e) => onClick(e)}
|
||||
>
|
||||
{inView ? (
|
||||
<>
|
||||
<img
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
|
||||
@@ -212,7 +247,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
onClick(e);
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
@@ -232,7 +267,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
{/* Selection checkbox */}
|
||||
{isSelectionMode && (
|
||||
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center`}>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -241,7 +242,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
>
|
||||
<img
|
||||
<AuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
@@ -250,6 +251,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||
}}
|
||||
draggable={false}
|
||||
useWatermark={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,3 +3,5 @@ export { PhotoGrid } from './PhotoGrid';
|
||||
export { PhotoLightbox } from './PhotoLightbox';
|
||||
export { ExpirationBanner } from './ExpirationBanner';
|
||||
export { CountdownTimer } from './CountdownTimer';
|
||||
export { GalleryLayout } from './GalleryLayout';
|
||||
export { PhotoFilterBar } from './PhotoFilterBar';
|
||||
@@ -26,6 +26,11 @@ api.interceptors.request.use(
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Don't set Content-Type for FormData - let browser set it with boundary
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface ThemeConfig {
|
||||
@@ -107,7 +107,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
|
||||
const [themeName, setThemeName] = useState(initialThemeName);
|
||||
|
||||
const applyTheme = (themeConfig: ThemeConfig) => {
|
||||
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply CSS variables
|
||||
@@ -154,27 +154,42 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
}
|
||||
styleElement.textContent = themeConfig.customCss;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setThemeByName = (name: string) => {
|
||||
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
|
||||
setTheme(newTheme);
|
||||
applyTheme(newTheme);
|
||||
}, [applyTheme]);
|
||||
|
||||
const setThemeByName = useCallback((name: string) => {
|
||||
const presetTheme = PRESET_THEMES[name];
|
||||
if (presetTheme) {
|
||||
setThemeName(name);
|
||||
setTheme(presetTheme.config);
|
||||
applyTheme(presetTheme.config);
|
||||
}
|
||||
};
|
||||
}, [applyTheme]);
|
||||
|
||||
const resetTheme = () => {
|
||||
const resetTheme = useCallback(() => {
|
||||
setThemeByName('default');
|
||||
};
|
||||
}, [setThemeByName]);
|
||||
|
||||
// Apply theme when it changes, but skip if it's the same
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const currentPrimary = root.style.getPropertyValue('--color-primary');
|
||||
|
||||
// Only apply if the theme has actually changed
|
||||
if (currentPrimary !== theme.primaryColor) {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
}
|
||||
}, [theme, applyTheme]);
|
||||
|
||||
// Load theme from localStorage on mount
|
||||
// Load theme from localStorage on mount (skip if in gallery view)
|
||||
useEffect(() => {
|
||||
// Check if we're in a gallery view by looking at the URL
|
||||
const isGalleryView = window.location.pathname.includes('/gallery/');
|
||||
if (!isGalleryView) {
|
||||
const savedTheme = localStorage.getItem('gallery-theme');
|
||||
if (savedTheme) {
|
||||
try {
|
||||
@@ -185,25 +200,30 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
console.error('Failed to load saved theme:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save theme to localStorage when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('gallery-theme', JSON.stringify({ name: themeName, config: theme }));
|
||||
// Only save if theme has actually changed
|
||||
const currentSaved = localStorage.getItem('gallery-theme');
|
||||
const newValue = JSON.stringify({ name: themeName, config: theme });
|
||||
if (currentSaved !== newValue) {
|
||||
localStorage.setItem('gallery-theme', newValue);
|
||||
}
|
||||
}, [theme, themeName]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{
|
||||
const contextValue = useMemo(() => ({
|
||||
theme,
|
||||
themeName,
|
||||
setTheme: (newTheme) => {
|
||||
setTheme(newTheme);
|
||||
applyTheme(newTheme);
|
||||
},
|
||||
setTheme: setThemeConfig,
|
||||
setThemeByName,
|
||||
applyTheme,
|
||||
resetTheme
|
||||
}}>
|
||||
}), [theme, themeName, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import HttpBackend from 'i18next-http-backend';
|
||||
|
||||
import enTranslations from './locales/en.json';
|
||||
import deTranslations from './locales/de.json';
|
||||
|
||||
i18n
|
||||
.use(HttpBackend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslations,
|
||||
},
|
||||
de: {
|
||||
translation: deTranslations,
|
||||
},
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
|
||||
caches: ['localStorage', 'cookie'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Wird geladen...",
|
||||
"error": "Fehler",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"search": "Suchen",
|
||||
"filter": "Filtern",
|
||||
"sortBy": "Sortieren nach",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"previous": "Zurück",
|
||||
"close": "Schließen",
|
||||
"logout": "Abmelden",
|
||||
"download": "Herunterladen",
|
||||
"downloadAll": "Alle herunterladen",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploaded": "Hochgeladen",
|
||||
"photo": "Foto",
|
||||
"photos": "Fotos"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Veranstaltungen",
|
||||
"archives": "Archive",
|
||||
"settings": "Einstellungen",
|
||||
"branding": "Branding",
|
||||
"analytics": "Analytik",
|
||||
"emailSettings": "E-Mail-Einstellungen",
|
||||
"cmsPages": "CMS-Seiten"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"password": "Passwort",
|
||||
"enterPassword": "Galerie-Passwort eingeben",
|
||||
"passwordPlaceholder": "Geben Sie das Galerie-Passwort ein",
|
||||
"invalidPassword": "Ungültiges Passwort",
|
||||
"sessionExpired": "Sitzung abgelaufen",
|
||||
"pleaseEnterPassword": "Bitte geben Sie ein Passwort ein",
|
||||
"passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Fotogalerie",
|
||||
"welcomeMessage": "Willkommensnachricht",
|
||||
"expiresOn": "Läuft ab am",
|
||||
"expires": "Läuft ab",
|
||||
"expired": "Abgelaufen",
|
||||
"daysRemaining": "{{days}} Tage verbleibend",
|
||||
"dayRemaining": "1 Tag verbleibend",
|
||||
"hoursRemaining": "{{hours}} Stunden verbleibend",
|
||||
"expiredMessage": "Diese Galerie ist am {{date}} abgelaufen",
|
||||
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen",
|
||||
"searchPhotos": "Fotos nach Dateiname suchen...",
|
||||
"sortByDate": "Nach Datum sortieren",
|
||||
"sortByName": "Nach Name sortieren",
|
||||
"sortBySize": "Nach Größe sortieren",
|
||||
"allPhotos": "Alle Fotos",
|
||||
"downloadSelected": "Ausgewählte herunterladen",
|
||||
"shareGallery": "Galerie teilen",
|
||||
"needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter",
|
||||
"noPhotosFound": "Keine Fotos gefunden",
|
||||
"failedToLoad": "Fotos konnten nicht geladen werden",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"loading": "Galerie wird geladen...",
|
||||
"expiredOn": "Diese Galerie ist am {{date}} abgelaufen.",
|
||||
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
|
||||
"expiresIn": "Galerie läuft in {{count}} Tag ab",
|
||||
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
|
||||
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
|
||||
"viewGallery": "Galerie anzeigen",
|
||||
"downloadAll": "Alle herunterladen",
|
||||
"downloading": "Lade {{count}} Foto herunter...",
|
||||
"downloading_plural": "Lade {{count}} Fotos herunter...",
|
||||
"downloadedPhotos": "{{count}} Foto heruntergeladen!",
|
||||
"downloadedPhotos_plural": "{{count}} Fotos heruntergeladen!",
|
||||
"downloadError": "Einige Fotos konnten nicht heruntergeladen werden",
|
||||
"selectPhotos": "Fotos auswählen",
|
||||
"cancelSelection": "Auswahl abbrechen",
|
||||
"photosSelected": "{{count}} ausgewählt",
|
||||
"selectAll": "Alle auswählen",
|
||||
"deselectAll": "Auswahl aufheben",
|
||||
"downloadSelected": "{{count}} ausgewählte herunterladen",
|
||||
"remaining": "verbleibend",
|
||||
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Fotokategorien",
|
||||
"global": "Globale Kategorien",
|
||||
"eventSpecific": "Veranstaltungsspezifische Kategorien",
|
||||
"addCategory": "Kategorie hinzufügen",
|
||||
"categoryName": "Kategoriename",
|
||||
"noCategory": "Keine Kategorie",
|
||||
"noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.",
|
||||
"deleteConfirm": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten?",
|
||||
"cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
|
||||
},
|
||||
"events": {
|
||||
"title": "Veranstaltungen",
|
||||
"createEvent": "Veranstaltung erstellen",
|
||||
"eventDetails": "Veranstaltungsdetails",
|
||||
"eventName": "Veranstaltungsname",
|
||||
"eventType": "Veranstaltungstyp",
|
||||
"eventDate": "Veranstaltungsdatum",
|
||||
"hostEmail": "Gastgeber-E-Mail",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"expirationDate": "Ablaufdatum",
|
||||
"active": "Aktiv",
|
||||
"archived": "Archiviert",
|
||||
"photoCount": "{{count}} Fotos",
|
||||
"totalSize": "Gesamtgröße",
|
||||
"shareLink": "Freigabelink",
|
||||
"copyLink": "Link kopieren",
|
||||
"linkCopied": "Link kopiert!",
|
||||
"viewGallery": "Galerie ansehen",
|
||||
"uploadPhotos": "Fotos hochladen",
|
||||
"archiveEvent": "Veranstaltung archivieren",
|
||||
"archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"extendExpiration": "Um {{days}} Tage verlängern"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
"general": {
|
||||
"title": "Allgemein",
|
||||
"siteConfiguration": "Website-Konfiguration",
|
||||
"siteUrl": "Website-URL",
|
||||
"siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet",
|
||||
"defaultExpiration": "Standardablauf (Tage)",
|
||||
"maxFileSize": "Max. Dateigröße (MB)",
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||
"language": "Sprache",
|
||||
"saveSettings": "Allgemeine Einstellungen speichern"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
"overview": "Speicherübersicht",
|
||||
"totalUsed": "Gesamt verwendet",
|
||||
"archiveStorage": "Archivspeicher",
|
||||
"storageLimit": "Speicherlimit",
|
||||
"storageUsage": "Speichernutzung",
|
||||
"storageByEvent": "Speicher nach Veranstaltung",
|
||||
"storageManagement": "Speicherverwaltung",
|
||||
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien."
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicherheit",
|
||||
"passwordSettings": "Passworteinstellungen",
|
||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
||||
"minPasswordLength": "Minimale Passwortlänge",
|
||||
"sessionAuth": "Sitzung & Authentifizierung",
|
||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||
"maxLoginAttempts": "Max. Anmeldeversuche",
|
||||
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||
"siteKey": "Site-Schlüssel",
|
||||
"secretKey": "Geheimer Schlüssel",
|
||||
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
|
||||
"saveSettings": "Sicherheitseinstellungen speichern"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien",
|
||||
"about": "Über Fotokategorien",
|
||||
"aboutText": "Globale Kategorien sind für alle Veranstaltungen verfügbar. Sie können auch veranstaltungsspezifische Kategorien erstellen, wenn Sie einzelne Veranstaltungen bearbeiten. Kategorien helfen beim Organisieren von Fotos und ermöglichen es Gästen, Fotos nach Typ in der Galerieansicht zu filtern."
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding & Anpassung",
|
||||
"companyInfo": "Unternehmensinformationen",
|
||||
"companyName": "Unternehmensname",
|
||||
"companyTagline": "Unternehmens-Slogan",
|
||||
"supportEmail": "Support-E-Mail",
|
||||
"footerText": "Fußzeilentext",
|
||||
"logo": "Logo",
|
||||
"uploadLogo": "Logo hochladen",
|
||||
"removeLogo": "Logo entfernen",
|
||||
"favicon": "Favicon",
|
||||
"uploadFavicon": "Favicon hochladen",
|
||||
"removeFavicon": "Favicon entfernen",
|
||||
"watermark": "Wasserzeichen",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"theme": "Theme",
|
||||
"themeCustomization": "Theme-Anpassung",
|
||||
"selectPreset": "Vorgefertigtes Theme auswählen",
|
||||
"colors": "Farben",
|
||||
"primaryColor": "Primärfarbe",
|
||||
"secondaryColor": "Sekundärfarbe",
|
||||
"accentColor": "Akzentfarbe",
|
||||
"customCSS": "Benutzerdefiniertes CSS",
|
||||
"preview": "Vorschau",
|
||||
"previewInNewTab": "Vorschau in neuem Tab",
|
||||
"reset": "Zurücksetzen",
|
||||
"saveChanges": "Änderungen speichern"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin-Panel",
|
||||
"welcome": "Willkommen zurück, {{name}}",
|
||||
"recentActivity": "Letzte Aktivitäten",
|
||||
"systemStatus": "Systemstatus",
|
||||
"totalEvents": "Gesamte Veranstaltungen",
|
||||
"activeGalleries": "Aktive Galerien",
|
||||
"storageUsed": "Speicher verwendet",
|
||||
"totalPhotos": "Gesamte Fotos",
|
||||
"storagePercent": "{{percent}}% von {{limit}}",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"changePassword": "Passwort ändern",
|
||||
"loadingDashboard": "Dashboard wird geladen...",
|
||||
"activeEvents": "Aktive Veranstaltungen",
|
||||
"expiringSoon": "Demnächst ablaufend",
|
||||
"next7Days": "Nächste 7 Tage",
|
||||
"totalViews": "Gesamtaufrufe",
|
||||
"downloads": "Downloads",
|
||||
"percentFromLastWeek": "{{percent}}% gegenüber letzter Woche",
|
||||
"dashboardSubtitle": "Willkommen zurück! Hier ist, was mit Ihren Galerien passiert.",
|
||||
"eventsExpiringSoon": "Demnächst ablaufende Veranstaltungen",
|
||||
"noEventsExpiring": "Keine Veranstaltungen laufen in den nächsten 7 Tagen ab",
|
||||
"daysLeft": "{{count}} Tag verbleibend",
|
||||
"daysLeft_plural": "{{count}} Tage verbleibend",
|
||||
"viewAllExpiringEvents": "Alle {{count}} ablaufenden Veranstaltungen anzeigen",
|
||||
"noRecentActivity": "Keine aktuellen Aktivitäten",
|
||||
"viewAllActivity": "Alle Aktivitäten anzeigen",
|
||||
"quickActions": "Schnellaktionen",
|
||||
"viewArchives": "Archive anzeigen",
|
||||
"analytics": "Analytik"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Nicht gefunden",
|
||||
"galleryNotFound": "Galerie nicht gefunden",
|
||||
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
|
||||
"unauthorized": "Nicht autorisiert",
|
||||
"forbidden": "Verboten",
|
||||
"serverError": "Serverfehler",
|
||||
"somethingWentWrong": "Etwas ist schiefgelaufen",
|
||||
"tryAgainLater": "Bitte versuchen Sie es später erneut",
|
||||
"refreshPage": "Seite neu laden",
|
||||
"oopsSomethingWentWrong": "Ups! Etwas ist schiefgelaufen",
|
||||
"unexpectedError": "Es ist ein unerwarteter Fehler aufgetreten. Keine Sorge, Ihre Daten sind sicher.",
|
||||
"goToHomepage": "Zur Startseite",
|
||||
"errorDetails": "Fehlerdetails"
|
||||
},
|
||||
"legal": {
|
||||
"impressum": "Impressum",
|
||||
"datenschutz": "Datenschutzerklärung",
|
||||
"termsOfService": "Nutzungsbedingungen",
|
||||
"cookiePolicy": "Cookie-Richtlinie"
|
||||
},
|
||||
"toast": {
|
||||
"saveSuccess": "Änderungen erfolgreich gespeichert",
|
||||
"saveError": "Fehler beim Speichern der Änderungen",
|
||||
"deleteSuccess": "Erfolgreich gelöscht",
|
||||
"deleteError": "Fehler beim Löschen",
|
||||
"uploadSuccess": "Upload erfolgreich abgeschlossen",
|
||||
"uploadError": "Upload fehlgeschlagen",
|
||||
"loginSuccess": "Anmeldung erfolgreich",
|
||||
"loginError": "Anmeldung fehlgeschlagen",
|
||||
"passwordChanged": "Passwort erfolgreich geändert",
|
||||
"linkCopied": "Link in Zwischenablage kopiert",
|
||||
"eventCreated": "Veranstaltung erfolgreich erstellt",
|
||||
"eventUpdated": "Veranstaltung erfolgreich aktualisiert",
|
||||
"eventArchived": "Veranstaltung erfolgreich archiviert",
|
||||
"settingsSaved": "Einstellungen erfolgreich gespeichert",
|
||||
"themeUpdated": "Theme erfolgreich aktualisiert",
|
||||
"brandingUpdated": "Branding erfolgreich aktualisiert",
|
||||
"categoryAdded": "Kategorie erfolgreich hinzugefügt",
|
||||
"categoryDeleted": "Kategorie erfolgreich gelöscht",
|
||||
"categoryUpdated": "Kategorie erfolgreich aktualisiert",
|
||||
"emailConfigSaved": "E-Mail-Konfiguration erfolgreich gespeichert",
|
||||
"testEmailSent": "Test-E-Mail erfolgreich gesendet",
|
||||
"pageUpdated": "Seite erfolgreich aktualisiert",
|
||||
"archiveRestored": "Archiv erfolgreich wiederhergestellt",
|
||||
"archiveDeleted": "Archiv dauerhaft gelöscht"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sortBy": "Sort by",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"close": "Close",
|
||||
"logout": "Logout",
|
||||
"download": "Download",
|
||||
"downloadAll": "Download All",
|
||||
"uploading": "Uploading...",
|
||||
"uploaded": "Uploaded",
|
||||
"photo": "photo",
|
||||
"photos": "photos"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Events",
|
||||
"archives": "Archives",
|
||||
"settings": "Settings",
|
||||
"branding": "Branding",
|
||||
"analytics": "Analytics",
|
||||
"emailSettings": "Email Settings",
|
||||
"cmsPages": "CMS Pages"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"password": "Password",
|
||||
"enterPassword": "Enter Gallery Password",
|
||||
"passwordPlaceholder": "Enter the gallery password",
|
||||
"invalidPassword": "Invalid password",
|
||||
"sessionExpired": "Session expired",
|
||||
"pleaseEnterPassword": "Please enter a password",
|
||||
"passwordHint": "The password was provided by the event organizer. Contact them if you don't have it."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Photo Gallery",
|
||||
"welcomeMessage": "Welcome Message",
|
||||
"expiresOn": "Expires on",
|
||||
"expires": "Expires",
|
||||
"expired": "Expired",
|
||||
"daysRemaining": "{{days}} days remaining",
|
||||
"dayRemaining": "1 day remaining",
|
||||
"hoursRemaining": "{{hours}} hours remaining",
|
||||
"expiredMessage": "This gallery expired on {{date}}",
|
||||
"contactOrganizer": "Please contact the event organizer if you need access to these photos",
|
||||
"searchPhotos": "Search photos by filename...",
|
||||
"sortByDate": "Sort by Date",
|
||||
"sortByName": "Sort by Name",
|
||||
"sortBySize": "Sort by Size",
|
||||
"allPhotos": "All Photos",
|
||||
"downloadSelected": "Download Selected",
|
||||
"shareGallery": "Share Gallery",
|
||||
"needHelp": "Need help? Contact us at",
|
||||
"noPhotosFound": "No photos found",
|
||||
"failedToLoad": "Failed to load photos",
|
||||
"tryAgain": "Try Again",
|
||||
"loading": "Loading gallery...",
|
||||
"expiredOn": "This gallery expired on {{date}}.",
|
||||
"contactOrganizer": "Please contact the event organizer if you need access to these photos.",
|
||||
"expiresIn": "Gallery expires in {{count}} day",
|
||||
"expiresIn_plural": "Gallery expires in {{count}} days",
|
||||
"downloadBefore": "Download your photos before they're no longer available.",
|
||||
"viewGallery": "View Gallery",
|
||||
"downloadAll": "Download All",
|
||||
"downloading": "Downloading {{count}} photo...",
|
||||
"downloading_plural": "Downloading {{count}} photos...",
|
||||
"downloadedPhotos": "Downloaded {{count}} photo!",
|
||||
"downloadedPhotos_plural": "Downloaded {{count}} photos!",
|
||||
"downloadError": "Some photos failed to download",
|
||||
"selectPhotos": "Select Photos",
|
||||
"cancelSelection": "Cancel Selection",
|
||||
"photosSelected": "{{count}} selected",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"downloadSelected": "Download {{count}} Selected",
|
||||
"remaining": "remaining",
|
||||
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Photo Categories",
|
||||
"global": "Global Categories",
|
||||
"eventSpecific": "Event-Specific Categories",
|
||||
"addCategory": "Add Category",
|
||||
"categoryName": "Category name",
|
||||
"noCategory": "No category",
|
||||
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"?",
|
||||
"cannotDelete": "Cannot delete category with photos. Please reassign photos first."
|
||||
},
|
||||
"events": {
|
||||
"title": "Events",
|
||||
"createEvent": "Create Event",
|
||||
"eventDetails": "Event Details",
|
||||
"eventName": "Event Name",
|
||||
"eventType": "Event Type",
|
||||
"eventDate": "Event Date",
|
||||
"hostEmail": "Host Email",
|
||||
"adminEmail": "Admin Email",
|
||||
"expirationDate": "Expiration Date",
|
||||
"active": "Active",
|
||||
"archived": "Archived",
|
||||
"photoCount": "{{count}} photos",
|
||||
"totalSize": "Total Size",
|
||||
"shareLink": "Share Link",
|
||||
"copyLink": "Copy Link",
|
||||
"linkCopied": "Link copied!",
|
||||
"viewGallery": "View Gallery",
|
||||
"uploadPhotos": "Upload Photos",
|
||||
"archiveEvent": "Archive Event",
|
||||
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
|
||||
"extendExpiration": "Extend {{days}} Days"
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
"general": {
|
||||
"title": "General",
|
||||
"siteConfiguration": "Site Configuration",
|
||||
"siteUrl": "Site URL",
|
||||
"siteUrlHelp": "Used for generating gallery links in emails",
|
||||
"defaultExpiration": "Default Expiration (days)",
|
||||
"maxFileSize": "Max File Size (MB)",
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableAnalytics": "Enable analytics tracking",
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"maintenanceMode": "Enable maintenance mode",
|
||||
"language": "Language",
|
||||
"saveSettings": "Save General Settings"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"overview": "Storage Overview",
|
||||
"totalUsed": "Total Used",
|
||||
"archiveStorage": "Archive Storage",
|
||||
"storageLimit": "Storage Limit",
|
||||
"storageUsage": "Storage Usage",
|
||||
"storageByEvent": "Storage by Event",
|
||||
"storageManagement": "Storage Management",
|
||||
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries."
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"passwordSettings": "Password Settings",
|
||||
"requirePassword": "Require password for all galleries",
|
||||
"minPasswordLength": "Minimum Password Length",
|
||||
"sessionAuth": "Session & Authentication",
|
||||
"sessionTimeout": "Session Timeout (minutes)",
|
||||
"maxLoginAttempts": "Max Login Attempts",
|
||||
"enable2FA": "Enable two-factor authentication for admins",
|
||||
"recaptchaSettings": "reCAPTCHA Settings",
|
||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||
"siteKey": "Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"recaptchaHelp": "Get your reCAPTCHA keys from",
|
||||
"saveSettings": "Save Security Settings"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
"about": "About Photo Categories",
|
||||
"aboutText": "Global categories are available for all events. You can also create event-specific categories when editing individual events. Categories help organize photos and allow guests to filter photos by type in the gallery view."
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding & Customization",
|
||||
"companyInfo": "Company Information",
|
||||
"companyName": "Company Name",
|
||||
"companyTagline": "Company Tagline",
|
||||
"supportEmail": "Support Email",
|
||||
"footerText": "Footer Text",
|
||||
"logo": "Logo",
|
||||
"uploadLogo": "Upload Logo",
|
||||
"removeLogo": "Remove Logo",
|
||||
"favicon": "Favicon",
|
||||
"uploadFavicon": "Upload Favicon",
|
||||
"removeFavicon": "Remove Favicon",
|
||||
"watermark": "Watermark",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"theme": "Theme",
|
||||
"themeCustomization": "Theme Customization",
|
||||
"selectPreset": "Select a preset theme",
|
||||
"colors": "Colors",
|
||||
"primaryColor": "Primary Color",
|
||||
"secondaryColor": "Secondary Color",
|
||||
"accentColor": "Accent Color",
|
||||
"customCSS": "Custom CSS",
|
||||
"preview": "Preview",
|
||||
"previewInNewTab": "Preview in New Tab",
|
||||
"reset": "Reset",
|
||||
"saveChanges": "Save Changes"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Panel",
|
||||
"welcome": "Welcome back, {{name}}",
|
||||
"recentActivity": "Recent Activity",
|
||||
"systemStatus": "System Status",
|
||||
"totalEvents": "Total Events",
|
||||
"activeGalleries": "Active Galleries",
|
||||
"storageUsed": "Storage Used",
|
||||
"totalPhotos": "Total Photos",
|
||||
"storagePercent": "{{percent}}% of {{limit}}",
|
||||
"notifications": "Notifications",
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"changePassword": "Change Password",
|
||||
"loadingDashboard": "Loading dashboard...",
|
||||
"activeEvents": "Active Events",
|
||||
"expiringSoon": "Expiring Soon",
|
||||
"next7Days": "Next 7 days",
|
||||
"totalViews": "Total Views",
|
||||
"downloads": "Downloads",
|
||||
"percentFromLastWeek": "{{percent}}% from last week",
|
||||
"dashboardSubtitle": "Welcome back! Here's what's happening with your galleries.",
|
||||
"eventsExpiringSoon": "Events Expiring Soon",
|
||||
"noEventsExpiring": "No events expiring in the next 7 days",
|
||||
"daysLeft": "{{count}} day left",
|
||||
"daysLeft_plural": "{{count}} days left",
|
||||
"viewAllExpiringEvents": "View all {{count}} expiring events",
|
||||
"noRecentActivity": "No recent activity",
|
||||
"viewAllActivity": "View all activity",
|
||||
"quickActions": "Quick Actions",
|
||||
"viewArchives": "View Archives",
|
||||
"analytics": "Analytics"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Not Found",
|
||||
"galleryNotFound": "Gallery Not Found",
|
||||
"galleryNotFoundMessage": "This gallery does not exist or has been removed.",
|
||||
"unauthorized": "Unauthorized",
|
||||
"forbidden": "Forbidden",
|
||||
"serverError": "Server Error",
|
||||
"somethingWentWrong": "Something went wrong",
|
||||
"tryAgainLater": "Please try again later",
|
||||
"refreshPage": "Refresh Page",
|
||||
"oopsSomethingWentWrong": "Oops! Something went wrong",
|
||||
"unexpectedError": "We encountered an unexpected error. Don't worry, your data is safe.",
|
||||
"goToHomepage": "Go to Homepage",
|
||||
"errorDetails": "Error Details"
|
||||
},
|
||||
"legal": {
|
||||
"impressum": "Legal Notice",
|
||||
"datenschutz": "Privacy Policy",
|
||||
"termsOfService": "Terms of Service",
|
||||
"cookiePolicy": "Cookie Policy"
|
||||
},
|
||||
"toast": {
|
||||
"saveSuccess": "Changes saved successfully",
|
||||
"saveError": "Failed to save changes",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteError": "Failed to delete",
|
||||
"uploadSuccess": "Upload completed successfully",
|
||||
"uploadError": "Upload failed",
|
||||
"loginSuccess": "Login successful",
|
||||
"loginError": "Login failed",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"eventCreated": "Event created successfully",
|
||||
"eventUpdated": "Event updated successfully",
|
||||
"eventArchived": "Event archived successfully",
|
||||
"settingsSaved": "Settings saved successfully",
|
||||
"themeUpdated": "Theme updated successfully",
|
||||
"brandingUpdated": "Branding updated successfully",
|
||||
"categoryAdded": "Category added successfully",
|
||||
"categoryDeleted": "Category deleted successfully",
|
||||
"categoryUpdated": "Category updated successfully",
|
||||
"emailConfigSaved": "Email configuration saved successfully",
|
||||
"testEmailSent": "Test email sent successfully",
|
||||
"pageUpdated": "Page updated successfully",
|
||||
"archiveRestored": "Archive restored successfully",
|
||||
"archiveDeleted": "Archive deleted permanently"
|
||||
}
|
||||
}
|
||||
@@ -160,4 +160,39 @@
|
||||
.smooth-scroll {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom range slider styles */
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-track {
|
||||
@apply bg-neutral-200 h-2 rounded-lg;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
@apply bg-neutral-200 h-2 rounded-lg;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all border-0;
|
||||
}
|
||||
|
||||
.slider:hover::-webkit-slider-thumb {
|
||||
@apply bg-primary-700 scale-110;
|
||||
}
|
||||
|
||||
.slider:hover::-moz-range-thumb {
|
||||
@apply bg-primary-700 scale-110;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n/config'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
|
||||
import { format, differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Card, CardContent, Input, Button, Loading } from '../components/common';
|
||||
import { useGalleryAuth } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
import { analyticsService } from '../services/analytics.service';
|
||||
import { api } from '../config/api';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
const { isAuthenticated, login, event } = useGalleryAuth();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
@@ -19,6 +23,23 @@ export const GalleryPage: React.FC = () => {
|
||||
// Fetch gallery info (public data)
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Set language from admin settings when on login page
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated && settingsData?.default_language) {
|
||||
i18n.changeLanguage(settingsData.default_language);
|
||||
}
|
||||
}, [settingsData, isAuthenticated, i18n]);
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = galleryInfo
|
||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||
@@ -27,7 +48,7 @@ export const GalleryPage: React.FC = () => {
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) {
|
||||
setLoginError('Please enter a password');
|
||||
setLoginError(t('auth.pleaseEnterPassword'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +63,7 @@ export const GalleryPage: React.FC = () => {
|
||||
success: true
|
||||
});
|
||||
} catch (error: any) {
|
||||
setLoginError(error.response?.data?.error || 'Invalid password');
|
||||
setLoginError(error.response?.data?.error || t('auth.invalidPassword'));
|
||||
|
||||
// Track failed password entry
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
@@ -57,8 +78,10 @@ export const GalleryPage: React.FC = () => {
|
||||
// Show loading state
|
||||
if (isLoadingInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<Loading size="lg" text="Loading gallery..." />
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loading size="lg" text={t('gallery.loading')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,37 +89,105 @@ export const GalleryPage: React.FC = () => {
|
||||
// Show error state
|
||||
if (infoError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Logo at top */}
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={settingsData.branding_logo_url}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Gallery Not Found</h2>
|
||||
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
|
||||
<p className="text-neutral-600">
|
||||
This gallery does not exist or has been removed.
|
||||
{t('errors.galleryNotFoundMessage')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Legal Links */}
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show expired state
|
||||
if (galleryInfo?.is_expired) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Logo at top */}
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={settingsData.branding_logo_url}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Gallery Expired</h2>
|
||||
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
|
||||
<p className="text-neutral-600 mb-4">
|
||||
This gallery expired on {format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy')}.
|
||||
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy') })}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Please contact the event organizer if you need access to these photos.
|
||||
{t('gallery.contactOrganizer')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Legal Links */}
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,9 +203,17 @@ export const GalleryPage: React.FC = () => {
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
{settingsData?.branding_logo_url ? (
|
||||
<img
|
||||
src={settingsData.branding_logo_url}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-20 w-auto object-contain mx-auto mb-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
|
||||
<Camera className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
|
||||
{galleryInfo?.event_name}
|
||||
</h1>
|
||||
@@ -131,10 +230,10 @@ export const GalleryPage: React.FC = () => {
|
||||
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800">
|
||||
Gallery expires in {daysUntilExpiration} {daysUntilExpiration === 1 ? 'day' : 'days'}
|
||||
{t('gallery.expiresIn', { count: daysUntilExpiration })}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 mt-1">
|
||||
Download your photos before they're no longer available.
|
||||
{t('gallery.downloadBefore')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,13 +243,13 @@ export const GalleryPage: React.FC = () => {
|
||||
{/* Login Card */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-6">Enter Gallery Password</h2>
|
||||
<h2 className="text-xl font-semibold mb-6">{t('auth.enterPassword')}</h2>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder="Enter the gallery password"
|
||||
label={t('auth.password')}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={loginError || undefined}
|
||||
@@ -165,22 +264,33 @@ export const GalleryPage: React.FC = () => {
|
||||
isLoading={isLoggingIn}
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
View Gallery
|
||||
{t('gallery.viewGallery')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs text-neutral-500 text-center mt-6">
|
||||
The password was provided by the event organizer.
|
||||
Contact them if you don't have it.
|
||||
{t('auth.passwordHint')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event Type Badge */}
|
||||
{/* Legal Links */}
|
||||
<div className="text-center mt-6">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||
{galleryInfo?.event_type}
|
||||
</span>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</a>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<a
|
||||
href="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Image
|
||||
} from 'lucide-react';
|
||||
import { format, differenceInDays, parseISO, formatDistanceToNow } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -29,6 +30,7 @@ interface StatCard {
|
||||
}
|
||||
|
||||
export const AdminDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch dashboard statistics
|
||||
@@ -54,7 +56,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loading size="lg" text="Loading dashboard..." />
|
||||
<Loading size="lg" text={t('admin.loadingDashboard')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,26 +78,26 @@ export const AdminDashboard: React.FC = () => {
|
||||
// Build statistics cards
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
title: 'Active Events',
|
||||
title: t('admin.activeEvents'),
|
||||
value: dashboardStats?.activeEvents || 0,
|
||||
icon: Calendar,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
title: 'Expiring Soon',
|
||||
title: t('admin.expiringSoon'),
|
||||
value: dashboardStats?.expiringEvents || 0,
|
||||
change: 'Next 7 days',
|
||||
change: t('admin.next7Days'),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
title: 'Total Photos',
|
||||
title: t('admin.totalPhotos'),
|
||||
value: formatNumber(dashboardStats?.totalPhotos || 0),
|
||||
icon: Image,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
title: 'Storage Used',
|
||||
title: t('admin.storageUsed'),
|
||||
value: adminService.formatBytes(dashboardStats?.storageUsed || 0),
|
||||
icon: HardDrive,
|
||||
color: 'text-purple-600',
|
||||
@@ -106,16 +108,16 @@ export const AdminDashboard: React.FC = () => {
|
||||
if (dashboardStats?.totalViews !== undefined) {
|
||||
stats.push(
|
||||
{
|
||||
title: 'Total Views',
|
||||
title: t('admin.totalViews'),
|
||||
value: formatNumber(dashboardStats.totalViews),
|
||||
change: dashboardStats.viewsTrend > 0 ? `+${dashboardStats.viewsTrend}% from last week` : undefined,
|
||||
change: dashboardStats.viewsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.viewsTrend}` }) : undefined,
|
||||
icon: Eye,
|
||||
color: 'text-indigo-600',
|
||||
},
|
||||
{
|
||||
title: 'Downloads',
|
||||
title: t('admin.downloads'),
|
||||
value: formatNumber(dashboardStats.totalDownloads),
|
||||
change: dashboardStats.downloadsTrend > 0 ? `+${dashboardStats.downloadsTrend}% from last week` : undefined,
|
||||
change: dashboardStats.downloadsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.downloadsTrend}` }) : undefined,
|
||||
icon: Download,
|
||||
color: 'text-pink-600',
|
||||
}
|
||||
@@ -127,15 +129,15 @@ export const AdminDashboard: React.FC = () => {
|
||||
{/* Page Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">Dashboard</h1>
|
||||
<p className="text-neutral-600 mt-1">Welcome back! Here's what's happening with your galleries.</p>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">{t('navigation.dashboard')}</h1>
|
||||
<p className="text-neutral-600 mt-1">{t('admin.dashboardSubtitle')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-5 h-5" />}
|
||||
onClick={() => navigate('/admin/events/new')}
|
||||
>
|
||||
Create Event
|
||||
{t('events.createEvent')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -165,12 +167,12 @@ export const AdminDashboard: React.FC = () => {
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">Events Expiring Soon</h2>
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.eventsExpiringSoon')}</h2>
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
|
||||
{expiringEvents.length === 0 ? (
|
||||
<p className="text-neutral-600 py-8 text-center">No events expiring in the next 7 days</p>
|
||||
<p className="text-neutral-600 py-8 text-center">{t('admin.noEventsExpiring')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{expiringEvents.slice(0, 5).map((event) => {
|
||||
@@ -190,10 +192,10 @@ export const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
{daysLeft} {daysLeft === 1 ? 'day' : 'days'} left
|
||||
{t('admin.daysLeft', { count: daysLeft })}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Expires {format(parseISO(event.expires_at), 'MMM d')}
|
||||
{t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +209,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
onClick={() => navigate('/admin/events?filter=expiring')}
|
||||
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
View all {expiringEvents.length} expiring events →
|
||||
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })} →
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
@@ -216,13 +218,13 @@ export const AdminDashboard: React.FC = () => {
|
||||
{/* Recent Activity */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">Recent Activity</h2>
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.recentActivity')}</h2>
|
||||
<Clock className="w-5 h-5 text-neutral-500" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!recentActivity || recentActivity.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 text-center py-4">No recent activity</p>
|
||||
<p className="text-sm text-neutral-500 text-center py-4">{t('admin.noRecentActivity')}</p>
|
||||
) : (
|
||||
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')} →
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
@@ -273,7 +275,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card padding="md" className="mt-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Quick Actions</h2>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('admin.quickActions')}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -281,7 +283,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
onClick={() => navigate('/admin/events/new')}
|
||||
className="justify-center"
|
||||
>
|
||||
Create Event
|
||||
{t('events.createEvent')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -289,7 +291,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
onClick={() => navigate('/admin/archives')}
|
||||
className="justify-center"
|
||||
>
|
||||
View Archives
|
||||
{t('admin.viewArchives')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -297,7 +299,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
onClick={() => navigate('/admin/analytics')}
|
||||
className="justify-center"
|
||||
>
|
||||
Analytics
|
||||
{t('admin.analytics')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -305,7 +307,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
onClick={() => navigate('/admin/settings')}
|
||||
className="justify-center"
|
||||
>
|
||||
Settings
|
||||
{t('navigation.settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save, Eye, Palette } from 'lucide-react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Save, Eye, Palette, Upload } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
|
||||
import { ThemeCustomizer } from '../../components/admin/ThemeCustomizer';
|
||||
import { useTheme, type ThemeConfig, PRESET_THEMES } from '../../contexts/ThemeContext';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { settingsService, type BrandingSettings } from '../../services/settings.service';
|
||||
|
||||
export const BrandingPage: React.FC = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [brandingSettings, setBrandingSettings] = useState({
|
||||
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
|
||||
company_name: '',
|
||||
company_tagline: '',
|
||||
footer_text: '© 2024 Your Company. All rights reserved.',
|
||||
support_email: '',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
watermark_opacity: 50,
|
||||
watermark_size: 15,
|
||||
watermark_logo_url: '',
|
||||
favicon_url: '',
|
||||
});
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
||||
const [currentThemeName, setCurrentThemeName] = useState('default');
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const faviconInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch current settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -68,8 +74,12 @@ export const BrandingPage: React.FC = () => {
|
||||
if (themeSettings) {
|
||||
const formatted = settingsService.formatThemeSettings(themeSettings);
|
||||
if (formatted && Object.keys(formatted).length > 0) {
|
||||
setCurrentTheme(formatted);
|
||||
setTheme(formatted);
|
||||
// Merge logo URL from branding settings if available
|
||||
const logoUrl = settings?.branding_logo_url || brandingSettings.logo_url;
|
||||
const themeWithLogo = logoUrl ? { ...formatted, logoUrl } : formatted;
|
||||
|
||||
setCurrentTheme(themeWithLogo);
|
||||
setTheme(themeWithLogo);
|
||||
|
||||
// Try to identify which preset this matches
|
||||
for (const [key, preset] of Object.entries(PRESET_THEMES)) {
|
||||
@@ -80,7 +90,7 @@ export const BrandingPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [themeSettings, setTheme]);
|
||||
}, [themeSettings, settings, brandingSettings.logo_url, setTheme]);
|
||||
|
||||
const handleBrandingChange = (key: string, value: any) => {
|
||||
setBrandingSettings(prev => ({ ...prev, [key]: value }));
|
||||
@@ -88,6 +98,10 @@ export const BrandingPage: React.FC = () => {
|
||||
|
||||
const handleThemeChange = (newTheme: ThemeConfig) => {
|
||||
setCurrentTheme(newTheme);
|
||||
// Also update logo URL in branding settings if it changed
|
||||
if (newTheme.logoUrl !== currentTheme.logoUrl) {
|
||||
setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' }));
|
||||
}
|
||||
if (isPreviewMode) {
|
||||
setTheme(newTheme);
|
||||
}
|
||||
@@ -105,16 +119,47 @@ export const BrandingPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFaviconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const faviconUrl = await settingsService.uploadFavicon(file);
|
||||
setBrandingSettings(prev => ({ ...prev, favicon_url: faviconUrl }));
|
||||
toast.success('Favicon uploaded successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload favicon:', error);
|
||||
toast.error('Failed to upload favicon. Please use PNG or ICO format.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const watermarkLogoUrl = await settingsService.uploadWatermarkLogo(file);
|
||||
setBrandingSettings(prev => ({ ...prev, watermark_logo_url: watermarkLogoUrl }));
|
||||
toast.success('Watermark logo uploaded successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload watermark logo:', error);
|
||||
toast.error('Failed to upload watermark logo. Please use PNG format with transparency.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Save branding settings to database
|
||||
await brandingMutation.mutateAsync(brandingSettings);
|
||||
|
||||
// Save theme settings to database
|
||||
await themeMutation.mutateAsync(currentTheme);
|
||||
// Save theme settings to database (including logo URL if present)
|
||||
const themeToSave = brandingSettings.logo_url
|
||||
? { ...currentTheme, logoUrl: brandingSettings.logo_url }
|
||||
: currentTheme;
|
||||
await themeMutation.mutateAsync(themeToSave);
|
||||
|
||||
// Apply theme globally
|
||||
setTheme(currentTheme);
|
||||
setTheme(themeToSave);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
@@ -223,6 +268,175 @@ export const BrandingPage: React.FC = () => {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Favicon
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{brandingSettings.favicon_url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.favicon_url}`}
|
||||
alt="Current favicon"
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
<span className="text-sm text-neutral-600">Current favicon</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBrandingChange('favicon_url', '')}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
ref={faviconInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/x-icon"
|
||||
onChange={handleFaviconUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => faviconInputRef.current?.click()}
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
>
|
||||
Upload Favicon
|
||||
</Button>
|
||||
<p className="text-xs text-neutral-600 mt-1">PNG or ICO format, recommended size: 32x32px</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watermark Settings */}
|
||||
{brandingSettings.watermark_enabled && (
|
||||
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
|
||||
<h3 className="text-md font-semibold text-neutral-900">Watermark Settings</h3>
|
||||
|
||||
{/* Watermark Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Watermark Logo
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{brandingSettings.watermark_logo_url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.watermark_logo_url}`}
|
||||
alt="Current watermark"
|
||||
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded"
|
||||
/>
|
||||
<span className="text-sm text-neutral-600">Current watermark</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBrandingChange('watermark_logo_url', '')}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png"
|
||||
onChange={handleWatermarkLogoUpload}
|
||||
className="hidden"
|
||||
id="watermark-upload"
|
||||
/>
|
||||
<label htmlFor="watermark-upload">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('watermark-upload')?.click()}
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
>
|
||||
Upload Watermark Logo
|
||||
</Button>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-600 mt-1">PNG format with transparency recommended</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Watermark Position
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||
{[
|
||||
{ value: 'top-left', label: 'Top Left' },
|
||||
{ value: 'top-right', label: 'Top Right' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'bottom-left', label: 'Bottom Left' },
|
||||
{ value: 'bottom-right', label: 'Bottom Right' }
|
||||
].map((position) => (
|
||||
<button
|
||||
key={position.value}
|
||||
type="button"
|
||||
onClick={() => handleBrandingChange('watermark_position', position.value)}
|
||||
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
brandingSettings.watermark_position === position.value
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
{position.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Opacity Slider */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Watermark Opacity: {brandingSettings.watermark_opacity || 50}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
step="10"
|
||||
value={brandingSettings.watermark_opacity || 50}
|
||||
onChange={(e) => handleBrandingChange('watermark_opacity', parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
||||
<span>10%</span>
|
||||
<span>50%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Slider */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Watermark Size: {brandingSettings.watermark_size || 15}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="30"
|
||||
step="5"
|
||||
value={brandingSettings.watermark_size || 15}
|
||||
onChange={(e) => handleBrandingChange('watermark_size', parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
||||
<span>5%</span>
|
||||
<span>15%</span>
|
||||
<span>30%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Theme Customization */}
|
||||
@@ -247,6 +461,7 @@ export const BrandingPage: React.FC = () => {
|
||||
onChange={handleThemeChange}
|
||||
presetName={currentThemeName}
|
||||
onPresetChange={handlePresetChange}
|
||||
isPreviewMode={isPreviewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, FileText, Globe } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import type { CMSPage as CMSPageType } from '../../services/cms.service';
|
||||
|
||||
export const CMSPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
|
||||
|
||||
// Fetch CMS pages
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
queryKey: ['cms-pages'],
|
||||
queryFn: cmsService.getPages,
|
||||
});
|
||||
|
||||
// Update page mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||
cmsService.updatePage(slug, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
|
||||
toast.success('Page updated successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update page');
|
||||
},
|
||||
});
|
||||
|
||||
// Load page data when selection changes
|
||||
React.useEffect(() => {
|
||||
if (pages) {
|
||||
const page = pages.find(p => p.slug === selectedPage);
|
||||
if (page) {
|
||||
setEditForm(page);
|
||||
}
|
||||
}
|
||||
}, [pages, selectedPage]);
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({
|
||||
slug: selectedPage,
|
||||
data: editForm,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
const field = editingLang === 'de' ? 'content_de' : 'content_en';
|
||||
setEditForm(prev => ({ ...prev, [field]: content }));
|
||||
};
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
const field = editingLang === 'de' ? 'title_de' : 'title_en';
|
||||
setEditForm(prev => ({ ...prev, [field]: title }));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loading size="lg" text="Loading pages..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900">CMS Pages</h1>
|
||||
<p className="text-neutral-600 mt-1">Manage legal and informational pages</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Pages</h2>
|
||||
<div className="space-y-2">
|
||||
{pages?.map((page) => (
|
||||
<button
|
||||
key={page.slug}
|
||||
onClick={() => setSelectedPage(page.slug)}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||
selectedPage === page.slug
|
||||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-5 h-5" />
|
||||
<div>
|
||||
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
|
||||
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Preview Links</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<a
|
||||
href={`${window.location.origin}/${selectedPage}?lang=en`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
English Version
|
||||
</a>
|
||||
<a
|
||||
href={`${window.location.origin}/${selectedPage}?lang=de`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
German Version
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
Edit {t(`legal.${selectedPage}`)}
|
||||
</h2>
|
||||
|
||||
{/* Language Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingLang('en')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
editingLang === 'en'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
🇬🇧 English
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingLang('de')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
editingLang === 'de'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
🇩🇪 Deutsch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
Page Title ({editingLang === 'en' ? 'English' : 'German'})
|
||||
</label>
|
||||
<Input
|
||||
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Enter page title..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
Page Content ({editingLang === 'en' ? 'English' : 'German'})
|
||||
</label>
|
||||
<CMSEditor
|
||||
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={updateMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentPage?.updated_at && (
|
||||
<p className="text-xs text-neutral-500 mt-4">
|
||||
Last updated: {new Date(currentPage.updated_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import { format, parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { PhotoUpload } from '../../components/admin';
|
||||
import { PhotoUpload, EventCategoryManager } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
@@ -408,10 +408,14 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Photos can also be added by placing them in the 'individual' or 'collages' folders.
|
||||
Photos are organized by categories you define.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-neutral-200">
|
||||
<EventCategoryManager eventId={parseInt(id!)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -4,17 +4,21 @@ import {
|
||||
Database,
|
||||
Globe,
|
||||
Key,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Image
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { CategoryManager } from '../../components/admin/CategoryManager';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Fetch settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -38,7 +42,8 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_watermark: false,
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false
|
||||
maintenance_mode: false,
|
||||
default_language: 'en'
|
||||
});
|
||||
|
||||
// Security settings state
|
||||
@@ -64,7 +69,8 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_watermark: settings.general_enable_watermark || false,
|
||||
enable_analytics: settings.general_enable_analytics || true,
|
||||
enable_registration: settings.general_enable_registration || false,
|
||||
maintenance_mode: settings.general_maintenance_mode || false
|
||||
maintenance_mode: settings.general_maintenance_mode || false,
|
||||
default_language: settings.general_default_language || 'en'
|
||||
});
|
||||
|
||||
// Extract security settings
|
||||
@@ -166,6 +172,16 @@ export const SettingsPage: React.FC = () => {
|
||||
>
|
||||
Security
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'categories'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
Categories
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -280,6 +296,29 @@ export const SettingsPage: React.FC = () => {
|
||||
<span className="ml-2 text-sm text-neutral-700">Enable maintenance mode</span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('settings.general.language')}
|
||||
</label>
|
||||
<select
|
||||
value={generalSettings.default_language}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Sets the default language for all gallery pages and login screens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
@@ -288,7 +327,7 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={saveGeneralMutation.isPending}
|
||||
leftIcon={<Save className="w-5 h-5" />}
|
||||
>
|
||||
Save General Settings
|
||||
{t('settings.general.saveSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -510,6 +549,29 @@ export const SettingsPage: React.FC = () => {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories Tab */}
|
||||
{activeTab === 'categories' && (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<CategoryManager />
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<div className="flex items-start gap-3">
|
||||
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-900">About Photo Categories</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,3 +8,4 @@ export { ArchivesPage } from './ArchivesPage';
|
||||
export { AnalyticsPage } from './AnalyticsPage';
|
||||
export { BrandingPage } from './BrandingPage';
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { CMSPage } from './CMSPage';
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { GalleryLayout, PhotoFilterBar } from '../../components/gallery';
|
||||
import { Card } from '../../components/common';
|
||||
import { Camera } from 'lucide-react';
|
||||
|
||||
// Mock photo data for preview
|
||||
const generateMockPhotos = (count: number) => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
filename: `photo-${i + 1}.jpg`,
|
||||
url: '',
|
||||
thumbnail_url: '',
|
||||
type: i % 3 === 0 ? 'collage' : 'individual',
|
||||
category_id: (i % 4) + 1,
|
||||
category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4],
|
||||
category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4],
|
||||
size: Math.floor(Math.random() * 5000000) + 1000000,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}));
|
||||
};
|
||||
|
||||
const mockCategories = [
|
||||
{ id: 1, name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||
{ id: 2, name: 'Reception', slug: 'reception', is_global: true },
|
||||
{ id: 3, name: 'Portraits', slug: 'portraits', is_global: true },
|
||||
{ id: 4, name: 'Party', slug: 'party', is_global: true },
|
||||
];
|
||||
|
||||
export const PreviewPage: React.FC = () => {
|
||||
const { setTheme } = useTheme();
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
|
||||
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
|
||||
const mockEvent = {
|
||||
event_name: 'Preview Wedding Gallery',
|
||||
event_date: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for theme preview messages from the branding page
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'THEME_PREVIEW') {
|
||||
setTheme(event.data.theme);
|
||||
setBrandingSettings(event.data.branding);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [setTheme]);
|
||||
|
||||
// Filter photos
|
||||
const filteredPhotos = useMemo(() => {
|
||||
let photos = [...mockPhotos];
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategoryId) {
|
||||
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
photos = photos.filter(photo =>
|
||||
photo.filename.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
photos.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.filename.localeCompare(b.filename);
|
||||
case 'size':
|
||||
return b.size - a.size;
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return photos;
|
||||
}, [mockPhotos, selectedCategoryId, searchTerm, sortBy]);
|
||||
|
||||
// Custom photo renderer for preview
|
||||
const PreviewPhotoGrid: React.FC<{ photos: any[] }> = ({ photos }) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{photos.map((photo) => (
|
||||
<Card key={photo.id} className="overflow-hidden group cursor-pointer">
|
||||
<div className="aspect-[4/3] bg-gradient-to-br from-neutral-200 to-neutral-300 relative">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Camera className="w-12 h-12 text-neutral-400" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.filename}</p>
|
||||
{photo.category_name && (
|
||||
<p className="text-white/70 text-xs">{photo.category_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<GalleryLayout
|
||||
event={mockEvent}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={false}
|
||||
showDownloadAll={false}
|
||||
>
|
||||
<div className="mt-8">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">Theme Preview</h2>
|
||||
<p className="text-neutral-600">This is how your galleries will look with the current theme settings</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<PhotoFilterBar
|
||||
categories={mockCategories}
|
||||
photos={mockPhotos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="mt-6">
|
||||
<PreviewPhotoGrid photos={filteredPhotos} />
|
||||
</div>
|
||||
</div>
|
||||
</GalleryLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Home } from 'lucide-react';
|
||||
import { Loading, Card } from '../../components/common';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
export const LegalPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Extract page slug from pathname if not in params (for static routes like /impressum)
|
||||
const pathname = window.location.pathname;
|
||||
const pageSlug = slug || pathname.split('/').pop() || '';
|
||||
|
||||
// Fetch settings to get default language
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Use admin settings language
|
||||
const lang = settingsData?.default_language || 'en';
|
||||
|
||||
// Fetch page content
|
||||
const { data: page, isLoading, error } = useQuery({
|
||||
queryKey: ['legal-page', pageSlug, lang],
|
||||
queryFn: () => cmsService.getPublicPage(pageSlug, lang),
|
||||
enabled: !!pageSlug && pageSlug !== '' && !!settingsData,
|
||||
});
|
||||
|
||||
// Set i18n language when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settingsData?.default_language) {
|
||||
i18n.changeLanguage(settingsData.default_language);
|
||||
}
|
||||
}, [settingsData, i18n]);
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
if (page?.title) {
|
||||
document.title = `${page.title} - Wedding Photo Sharing`;
|
||||
}
|
||||
}, [page?.title]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<Loading size="lg" text="Loading..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !page) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<div className="text-center py-12 px-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Page Not Found</h2>
|
||||
<p className="text-neutral-600 mb-6">
|
||||
The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
Go to Homepage
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-neutral-200">
|
||||
<div className="container py-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 text-neutral-600 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{i18n.language === 'de' ? 'Zurück' : 'Back'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="container py-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card padding="lg">
|
||||
<h1 className="text-3xl font-bold text-neutral-900 mb-8">{page.title}</h1>
|
||||
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: page.content }}
|
||||
/>
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-auto py-8 border-t border-neutral-200">
|
||||
<div className="container text-center">
|
||||
<div className="flex justify-center gap-4 text-sm">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-neutral-600 hover:text-neutral-900"
|
||||
>
|
||||
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
|
||||
</Link>
|
||||
<span className="text-neutral-400">•</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-neutral-600 hover:text-neutral-900"
|
||||
>
|
||||
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-4">
|
||||
© 2024 Wedding Photo Sharing. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface PhotoCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_global: boolean;
|
||||
event_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateCategoryData {
|
||||
name: string;
|
||||
slug?: string;
|
||||
is_global?: boolean;
|
||||
event_id?: number;
|
||||
}
|
||||
|
||||
export const categoriesService = {
|
||||
// Get all global categories
|
||||
async getGlobalCategories(): Promise<PhotoCategory[]> {
|
||||
const response = await api.get<PhotoCategory[]>('/api/admin/categories/global');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
async getEventCategories(eventId: number): Promise<PhotoCategory[]> {
|
||||
const response = await api.get<PhotoCategory[]>(`/api/admin/categories/event/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create a new category
|
||||
async createCategory(data: CreateCategoryData): Promise<PhotoCategory> {
|
||||
const response = await api.post<PhotoCategory>('/api/admin/categories', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a category
|
||||
async updateCategory(id: number, name: string): Promise<PhotoCategory> {
|
||||
const response = await api.put<PhotoCategory>(`/api/admin/categories/${id}`, { name });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a category
|
||||
async deleteCategory(id: number): Promise<void> {
|
||||
await api.delete(`/api/admin/categories/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface CMSPage {
|
||||
id: number;
|
||||
slug: string;
|
||||
title_en: string;
|
||||
title_de: string;
|
||||
content_en: string;
|
||||
content_de: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const cmsService = {
|
||||
// Get all CMS pages
|
||||
async getPages(): Promise<CMSPage[]> {
|
||||
const response = await api.get<CMSPage[]>('/api/admin/cms/pages');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a single CMS page
|
||||
async getPage(slug: string): Promise<CMSPage> {
|
||||
const response = await api.get<CMSPage>(`/api/admin/cms/pages/${slug}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a CMS page
|
||||
async updatePage(slug: string, data: Partial<CMSPage>): Promise<CMSPage> {
|
||||
const response = await api.put<CMSPage>(`/api/admin/cms/pages/${slug}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get public CMS page (no auth required)
|
||||
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> {
|
||||
const response = await api.get<{ title: string; content: string }>(`/api/public/pages/${slug}`, {
|
||||
params: { lang }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -6,3 +6,4 @@ export { analyticsService } from './analytics.service';
|
||||
export { archiveService } from './archive.service';
|
||||
export { emailService } from './email.service';
|
||||
export { settingsService } from './settings.service';
|
||||
export { cmsService } from './cms.service';
|
||||
@@ -6,7 +6,12 @@ export interface BrandingSettings {
|
||||
support_email: string;
|
||||
footer_text: string;
|
||||
watermark_enabled: boolean;
|
||||
watermark_position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'center';
|
||||
watermark_opacity?: number;
|
||||
watermark_size?: number;
|
||||
watermark_logo_url?: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
@@ -50,11 +55,11 @@ export const settingsService = {
|
||||
},
|
||||
|
||||
// Upload logo
|
||||
async uploadLogo(file: File): Promise<{ logo_url: string }> {
|
||||
async uploadLogo(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
const response = await api.post<{ message: string; logo_url: string }>(
|
||||
const response = await api.post<{ logoUrl: string }>(
|
||||
'/api/admin/settings/logo',
|
||||
formData,
|
||||
{
|
||||
@@ -64,7 +69,43 @@ export const settingsService = {
|
||||
}
|
||||
);
|
||||
|
||||
return { logo_url: response.data.logo_url };
|
||||
return response.data.logoUrl;
|
||||
},
|
||||
|
||||
// Upload favicon
|
||||
async uploadFavicon(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('favicon', file);
|
||||
|
||||
const response = await api.post<{ faviconUrl: string }>(
|
||||
'/api/admin/settings/favicon',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.faviconUrl;
|
||||
},
|
||||
|
||||
// Upload watermark logo
|
||||
async uploadWatermarkLogo(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('watermarkLogo', file);
|
||||
|
||||
const response = await api.post<{ watermarkLogoUrl: string }>(
|
||||
'/api/admin/settings/branding/watermark-logo',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.watermarkLogoUrl;
|
||||
},
|
||||
|
||||
// Update theme settings
|
||||
@@ -91,7 +132,12 @@ export const settingsService = {
|
||||
support_email: rawSettings.branding_support_email || '',
|
||||
footer_text: rawSettings.branding_footer_text || '',
|
||||
watermark_enabled: rawSettings.branding_watermark_enabled || false,
|
||||
logo_url: rawSettings.branding_logo_url || undefined
|
||||
watermark_position: rawSettings.branding_watermark_position || 'bottom-right',
|
||||
watermark_opacity: rawSettings.branding_watermark_opacity || 50,
|
||||
watermark_size: rawSettings.branding_watermark_size || 15,
|
||||
watermark_logo_url: rawSettings.branding_watermark_logo_url || undefined,
|
||||
logo_url: rawSettings.branding_logo_url || undefined,
|
||||
favicon_url: rawSettings.branding_favicon_url || undefined
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { toast as toastify } from 'react-toastify';
|
||||
import i18n from '../i18n/config';
|
||||
|
||||
export const toast = {
|
||||
success: (messageKey: string, interpolations?: Record<string, any>) => {
|
||||
const message = i18n.t(messageKey, interpolations);
|
||||
toastify.success(message);
|
||||
},
|
||||
|
||||
error: (messageKey: string, interpolations?: Record<string, any>) => {
|
||||
const message = i18n.t(messageKey, interpolations);
|
||||
toastify.error(message);
|
||||
},
|
||||
|
||||
info: (messageKey: string, interpolations?: Record<string, any>) => {
|
||||
const message = i18n.t(messageKey, interpolations);
|
||||
toastify.info(message);
|
||||
},
|
||||
|
||||
warning: (messageKey: string, interpolations?: Record<string, any>) => {
|
||||
const message = i18n.t(messageKey, interpolations);
|
||||
toastify.warning(message);
|
||||
},
|
||||
|
||||
// For direct messages (not translation keys)
|
||||
successDirect: (message: string) => toastify.success(message),
|
||||
errorDirect: (message: string) => toastify.error(message),
|
||||
infoDirect: (message: string) => toastify.info(message),
|
||||
warningDirect: (message: string) => toastify.warning(message),
|
||||
};
|
||||
@@ -42,10 +42,20 @@ export interface Photo {
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
type: 'collage' | 'individual';
|
||||
category_id?: number;
|
||||
category_name?: string;
|
||||
category_slug?: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface PhotoCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_global: boolean;
|
||||
}
|
||||
|
||||
export interface GalleryData {
|
||||
event: {
|
||||
id: number;
|
||||
@@ -56,6 +66,7 @@ export interface GalleryData {
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
};
|
||||
categories?: PhotoCategory[];
|
||||
photos: Photo[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const knex = require('knex');
|
||||
|
||||
exports.up = async function(db) {
|
||||
console.log('Adding watermark settings...');
|
||||
|
||||
// Add watermark settings to app_settings table
|
||||
const watermarkSettings = [
|
||||
{
|
||||
setting_key: 'branding_watermark_logo_path',
|
||||
setting_value: JSON.stringify(null),
|
||||
setting_type: 'branding'
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_watermark_logo_url',
|
||||
setting_value: JSON.stringify(null),
|
||||
setting_type: 'branding'
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_watermark_position',
|
||||
setting_value: JSON.stringify('bottom-right'),
|
||||
setting_type: 'branding'
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_watermark_opacity',
|
||||
setting_value: JSON.stringify(50),
|
||||
setting_type: 'branding'
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_watermark_size',
|
||||
setting_value: JSON.stringify(15),
|
||||
setting_type: 'branding'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of watermarkSettings) {
|
||||
// Check if setting already exists
|
||||
const existing = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
await db('app_settings').insert(setting);
|
||||
console.log(`Added setting: ${setting.setting_key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Watermark settings migration completed');
|
||||
};
|
||||
|
||||
exports.down = async function(db) {
|
||||
// Remove watermark settings
|
||||
await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_watermark_logo_path',
|
||||
'branding_watermark_logo_url',
|
||||
'branding_watermark_position',
|
||||
'branding_watermark_opacity',
|
||||
'branding_watermark_size'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "wedding-photo-sharing",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}🔄 Updating Photo Sharing Platform - Local Development${NC}"
|
||||
echo "===================================================="
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}❌ Docker is not running. Please start Docker Desktop.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop all containers
|
||||
echo -e "${YELLOW}🛑 Stopping all containers...${NC}"
|
||||
docker-compose -f docker-compose.local.yml down
|
||||
|
||||
# Remove old images to force rebuild
|
||||
echo -e "${YELLOW}🗑️ Removing old images...${NC}"
|
||||
docker-compose -f docker-compose.local.yml rm -f
|
||||
|
||||
# Pull latest base images
|
||||
echo -e "${YELLOW}📥 Pulling latest base images...${NC}"
|
||||
docker-compose -f docker-compose.local.yml pull
|
||||
|
||||
# Build frontend production files
|
||||
echo -e "${YELLOW}📦 Building frontend production files...${NC}"
|
||||
cd frontend
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# Rebuild all images with no cache
|
||||
echo -e "${YELLOW}🔨 Rebuilding Docker images (no cache)...${NC}"
|
||||
docker-compose -f docker-compose.local.yml build --no-cache
|
||||
|
||||
# Start all services
|
||||
echo -e "${YELLOW}🚀 Starting services...${NC}"
|
||||
docker-compose -f docker-compose.local.yml up -d
|
||||
|
||||
# Wait for backend to be ready
|
||||
echo -e "${YELLOW}⏳ Waiting for backend to start...${NC}"
|
||||
max_attempts=30
|
||||
attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if curl -s http://localhost:3001/api/health > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ Backend is ready!${NC}"
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo -e "${RED}❌ Backend failed to start. Check logs with: docker-compose -f docker-compose.local.yml logs backend${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait a bit more for frontend to be ready
|
||||
echo -e "${YELLOW}⏳ Waiting for frontend to be ready...${NC}"
|
||||
sleep 5
|
||||
|
||||
# Show status
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ Local development environment has been updated!${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}🌐 Access Points:${NC}"
|
||||
echo " Frontend (Nginx): http://localhost:3005"
|
||||
echo " Frontend (Dev): http://localhost:3002"
|
||||
echo " Backend API: http://localhost:3001"
|
||||
echo " Mailhog: http://localhost:8025"
|
||||
echo ""
|
||||
echo -e "${GREEN}📝 Container Status:${NC}"
|
||||
docker-compose -f docker-compose.local.yml ps
|
||||
echo ""
|
||||
echo -e "${GREEN}💡 Tips:${NC}"
|
||||
echo " - View logs: docker-compose -f docker-compose.local.yml logs -f"
|
||||
echo " - View specific service logs: docker-compose -f docker-compose.local.yml logs -f [service-name]"
|
||||
echo " - Stop all: ./stop-local.sh"
|
||||
echo ""
|
||||
|
||||
# Open browser
|
||||
if command -v xdg-open &> /dev/null; then
|
||||
xdg-open http://localhost:3005
|
||||
elif command -v open &> /dev/null; then
|
||||
open http://localhost:3005
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✨ Update complete! The browser should open automatically.${NC}"
|
||||