feat: overhaul public landing page and backup tooling
This commit is contained in:
@@ -201,6 +201,15 @@ openssl rand -base64 32 | tr -d '$'
|
||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||
|
||||
### Backend Configuration (.env)
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||
|
||||
@@ -34,6 +34,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
@@ -89,6 +90,18 @@ Note on Docker file permissions (PUID/PGID)
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
Perfect for:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||
const mockFs = require('mock-fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
if (!process.env.SKIP_S3_TESTS) {
|
||||
process.env.SKIP_S3_TESTS = 'true';
|
||||
}
|
||||
if (!process.env.STORAGE_PATH) {
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
}
|
||||
});
|
||||
|
||||
+38
-1
@@ -3,6 +3,22 @@ require('dotenv').config();
|
||||
const path = require('path');
|
||||
|
||||
// Database configuration for different environments
|
||||
const sqliteConnection = (filenameEnv) => ({
|
||||
filename: path.join(__dirname, filenameEnv || './data/photo_sharing.db')
|
||||
});
|
||||
|
||||
const baseSqliteConfig = {
|
||||
client: 'sqlite3',
|
||||
connection: sqliteConnection(),
|
||||
useNullAsDefault: true,
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './seeds'
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
development: {
|
||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||
@@ -24,6 +40,26 @@ const config = {
|
||||
}
|
||||
},
|
||||
|
||||
test: (() => {
|
||||
const client = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
const isPostgres = client === 'pg';
|
||||
|
||||
return {
|
||||
...baseSqliteConfig,
|
||||
client,
|
||||
useNullAsDefault: !isPostgres,
|
||||
connection: isPostgres
|
||||
? {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing_test'
|
||||
}
|
||||
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
|
||||
};
|
||||
})(),
|
||||
|
||||
production: {
|
||||
client: process.env.DATABASE_CLIENT || 'pg',
|
||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||
@@ -63,5 +99,6 @@ const config = {
|
||||
acquireConnectionTimeout: 60000
|
||||
}
|
||||
};
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
|
||||
module.exports = config[process.env.NODE_ENV || 'development'];
|
||||
module.exports = config[env] || config.development;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
} = require('../../src/constants/publicSiteDefaults');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const defaults = [
|
||||
{
|
||||
setting_key: 'general_public_site_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_html',
|
||||
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_custom_css',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'general'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of defaults) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_public_site_enabled',
|
||||
'general_public_site_html',
|
||||
'general_public_site_custom_css'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
Generated
+182
-3
@@ -38,6 +38,7 @@
|
||||
"nodemailer": "7.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -47,6 +48,7 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
"jest": "^29.5.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
@@ -4921,7 +4923,6 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5015,6 +5016,61 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -5130,6 +5186,18 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -5221,7 +5289,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -6169,6 +6236,25 @@
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.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",
|
||||
@@ -6563,6 +6649,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -8014,6 +8109,16 @@
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mock-fs": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
|
||||
"integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8038,6 +8143,24 @@
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -8543,6 +8666,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -8692,7 +8821,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -8787,6 +8915,34 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -9378,6 +9534,20 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
|
||||
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^8.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
@@ -9750,6 +9920,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.13",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"nodemailer": "7.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -51,6 +52,7 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
"jest": "^29.5.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
|
||||
+152
-2
@@ -26,6 +26,7 @@ const { startScheduledBackups } = require('./src/services/databaseBackup');
|
||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const {
|
||||
getAdminTokenFromRequest,
|
||||
@@ -150,6 +151,145 @@ app.options('/api/*', cors(corsOptions));
|
||||
let generalRateLimiter;
|
||||
let authRateLimiter;
|
||||
|
||||
function composeInlineStyles(payload) {
|
||||
const { branding } = payload;
|
||||
const cssSegments = [];
|
||||
|
||||
cssSegments.push(`:root {
|
||||
--brand-primary: ${branding.colors.primary};
|
||||
--brand-accent: ${branding.colors.accent};
|
||||
--brand-background: ${branding.colors.background};
|
||||
--brand-text: ${branding.colors.text};
|
||||
}`);
|
||||
|
||||
if (payload.baseCss) {
|
||||
cssSegments.push(payload.baseCss);
|
||||
}
|
||||
|
||||
if (payload.css) {
|
||||
cssSegments.push(`/* Custom styles */\n${payload.css}`);
|
||||
}
|
||||
|
||||
return cssSegments.join('\n\n');
|
||||
}
|
||||
|
||||
function renderBrandHeader(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
|
||||
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
|
||||
|
||||
const tagline = branding.companyTagline
|
||||
? `<p class="brand-tagline">${branding.companyTagline}</p>`
|
||||
: '';
|
||||
|
||||
return `<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#features">${'Features'}</a>
|
||||
<a href="#workflow">${'Workflow'}</a>
|
||||
<a href="#collections">${'Collections'}</a>
|
||||
<a href="#stories">${'Stories'}</a>
|
||||
<a href="#contact">${'Contact'}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function renderBrandFooter(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const footerNote = branding.footerText
|
||||
? `<p>${branding.footerText}</p>`
|
||||
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
|
||||
|
||||
const supportLink = branding.supportEmail
|
||||
? `<a href="mailto:${branding.supportEmail}">Support</a>`
|
||||
: '';
|
||||
|
||||
const legalLinks = `
|
||||
<a href="/datenschutz">Privacy Policy</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
${supportLink}
|
||||
`;
|
||||
|
||||
return `<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
${legalLinks}
|
||||
</div>
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
function buildPublicSiteDocument(payload) {
|
||||
const inlineStyles = composeInlineStyles(payload);
|
||||
const header = renderBrandHeader(payload.branding);
|
||||
const footer = renderBrandFooter(payload.branding);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${payload.title}</title>
|
||||
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
${header}
|
||||
<main class="site-main">
|
||||
${payload.html}
|
||||
</main>
|
||||
${footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function handlePublicSiteRequest(req, res, next) {
|
||||
try {
|
||||
const payload = await getPublicSitePayload();
|
||||
|
||||
if (!payload.enabled) {
|
||||
res.redirect(302, '/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const document = buildPublicSiteDocument(payload);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
|
||||
res.setHeader('ETag', payload.etag);
|
||||
res.setHeader('Vary', 'Accept-Encoding');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
|
||||
|
||||
res.status(200).send(document);
|
||||
} catch (error) {
|
||||
logger.error('Failed to render public site', { error: error.message });
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to initialize rate limiters
|
||||
async function initializeRateLimiters() {
|
||||
generalRateLimiter = await createRateLimiter();
|
||||
@@ -288,13 +428,23 @@ try {
|
||||
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
||||
if (shouldServe) {
|
||||
logger.info(`Serving frontend from ${frontendDir}`);
|
||||
// Serve pre-built assets
|
||||
app.use(express.static(frontendDir));
|
||||
// SPA fallback for non-API routes
|
||||
app.get([ '/', '/admin', '/admin/*', '/gallery/*' ], (req, res) => {
|
||||
|
||||
// Landing page handler or SPA fallback
|
||||
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
|
||||
// SPA fallback for admin + gallery routes
|
||||
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
} else {
|
||||
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
||||
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
return {
|
||||
db: mockDb,
|
||||
logActivity: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
|
||||
const buildPublicSiteRows = (overrides = {}) => ([
|
||||
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
|
||||
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
|
||||
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") }
|
||||
]);
|
||||
|
||||
const buildBrandingRows = (overrides = {}) => ([
|
||||
{ setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') },
|
||||
{ setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') },
|
||||
{ setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') },
|
||||
{ setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') },
|
||||
{ setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') },
|
||||
{ setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? {
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#1d4ed8',
|
||||
backgroundColor: '#f8fafc',
|
||||
textColor: '#0f172a'
|
||||
}) }
|
||||
]);
|
||||
|
||||
describe('publicSiteService', () => {
|
||||
beforeEach(() => {
|
||||
clearPublicSiteCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sanitizes stored HTML by stripping script tags', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({ html: '<h1>{{company_name}}</h1><script>alert(1)</script>' });
|
||||
const brandingRows = buildBrandingRows();
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.enabled).toBe(true);
|
||||
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
||||
expect(payload.html).not.toContain('<script');
|
||||
expect(payload.baseCss.length).toBeGreaterThan(0);
|
||||
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
|
||||
});
|
||||
|
||||
it('sanitizes custom CSS and removes dangerous patterns', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({
|
||||
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }"
|
||||
});
|
||||
const brandingRows = buildBrandingRows();
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.css).toContain('body { color: blue; }');
|
||||
expect(payload.css).not.toContain('@import');
|
||||
expect(payload.css).not.toContain('javascript:');
|
||||
// Client-side util should match server sanitization expectations
|
||||
const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : '');
|
||||
expect(clientSanitized).not.toContain('@import');
|
||||
expect(clientSanitized).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('injects branding tokens into the rendered payload', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({ html: '<section><h1>{{company_name}}</h1><p>{{company_tagline}}</p><a href="mailto:{{support_email}}">Get in touch</a></section>' });
|
||||
const brandingRows = buildBrandingRows({
|
||||
companyName: 'Aurora Collective',
|
||||
companyTagline: 'Modern photography for timeless celebrations.',
|
||||
supportEmail: 'studio@aurora.co',
|
||||
logoUrl: '/uploads/logos/aurora.png',
|
||||
themeConfig: {
|
||||
primaryColor: '#5C8762',
|
||||
accentColor: '#1d4ed8',
|
||||
backgroundColor: '#fafafa',
|
||||
textColor: '#171717'
|
||||
}
|
||||
});
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.html).toContain('Aurora Collective');
|
||||
expect(payload.html).toContain('Modern photography for timeless celebrations.');
|
||||
expect(payload.html).toContain('studio@aurora.co');
|
||||
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
|
||||
expect(payload.branding.colors.primary).toBe('#5C8762');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak — Curated Galleries, Effortless Sharing';
|
||||
|
||||
const DEFAULT_PUBLIC_SITE_HTML = `
|
||||
<section class="hero" id="welcome">
|
||||
<div class="hero__inner">
|
||||
<span class="hero__badge">PicPeak Showcase</span>
|
||||
<h1>Share the story of {{company_name}}</h1>
|
||||
<p class="hero__lead">{{company_tagline}}</p>
|
||||
<div class="hero__cta">
|
||||
<a href="#features" class="button button--primary">Explore Features</a>
|
||||
<a href="#collections" class="button button--ghost">View Sample Galleries</a>
|
||||
</div>
|
||||
<dl class="hero__stats">
|
||||
<div>
|
||||
<dt>Private invites</dt>
|
||||
<dd>Secure links for every guest</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Curated delivery</dt>
|
||||
<dd>Highlight every favourite instantly</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fully branded</dt>
|
||||
<dd>Colours, typography, and logo that match you</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="hero__visual">
|
||||
<article class="deck deck--primary">
|
||||
<header class="deck__header">
|
||||
<img src="{{brand_logo_url}}" alt="{{company_name}} logo" class="deck__logo" loading="lazy" decoding="async" />
|
||||
<span class="deck__title">PicPeak Gallery</span>
|
||||
</header>
|
||||
<ul class="deck__list">
|
||||
<li>Guided cover stories</li>
|
||||
<li>Guest uploads with approvals</li>
|
||||
<li>Protected high-res downloads</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="deck deck--secondary">
|
||||
<p class="deck__quote">“PicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.”</p>
|
||||
<p class="deck__author">— Studio Miraval</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features" id="features">
|
||||
<div class="section-head">
|
||||
<span class="section-badge">Why teams pick PicPeak</span>
|
||||
<h2>Design-first galleries with the workflow you already love</h2>
|
||||
<p>Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.</p>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
<article>
|
||||
<h3>Beautiful by default</h3>
|
||||
<p>Every gallery inherits your PicPeak theme, typography, and colour palette automatically.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Guided storytelling</h3>
|
||||
<p>Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Secure sharing</h3>
|
||||
<p>Password gates, expiring links, and download protection keep every celebration personal.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workflow" id="workflow">
|
||||
<div class="workflow__content">
|
||||
<h2>Launch in minutes</h2>
|
||||
<ol class="workflow__steps">
|
||||
<li>
|
||||
<h4>Brand it once</h4>
|
||||
<p>PicPeak automatically applies your logo, colours, and support details.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4>Curate sections</h4>
|
||||
<p>Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4>Share confidently</h4>
|
||||
<p>Send a single link that greets guests before they enter their private gallery.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="workflow__media">
|
||||
<figure class="workflow__browser">
|
||||
<img src="/picpeak-logo-transparent.png" alt="PicPeak interface" loading="lazy" decoding="async" />
|
||||
<figcaption>PicPeak dashboard — trusted by studios worldwide.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="collections" id="collections">
|
||||
<div class="section-head">
|
||||
<span class="section-badge">Showcase highlights</span>
|
||||
<h2>Curated sample galleries that mirror your client experience</h2>
|
||||
<p>Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.</p>
|
||||
</div>
|
||||
<div class="collection-showcase">
|
||||
<article>
|
||||
<h3>Signature Galleries</h3>
|
||||
<p>Use responsive cards to preview your most loved collections or vendor partnerships.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Welcome timelines</h3>
|
||||
<p>Guide guests from arrival to download with steps that feel effortless and on-brand.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stories" id="stories">
|
||||
<div class="section-head section-head--center">
|
||||
<span class="section-badge">Client notes</span>
|
||||
<h2>Experiences that keep guests coming back</h2>
|
||||
</div>
|
||||
<div class="story-grid">
|
||||
<figure>
|
||||
<blockquote>“From the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.”</blockquote>
|
||||
<figcaption>— Harper & Elias</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<blockquote>“The public landing page gives every collection a narrative. Our couples feel the care we put into every image.”</blockquote>
|
||||
<figcaption>— Jordan Rivera, Photographer</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta" id="contact">
|
||||
<div class="cta__inner">
|
||||
<div>
|
||||
<h2>Ready to welcome your guests?</h2>
|
||||
<p>Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.</p>
|
||||
</div>
|
||||
<div class="cta__actions">
|
||||
<a href="mailto:{{support_email}}" class="button button--primary">Contact us</a>
|
||||
<a href="#features" class="button button--ghost">Review features</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer" id="legal">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>{{company_name}}</h2>
|
||||
<p>Powered by PicPeak to keep every celebration beautifully organised.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/datenschutz">Privacy Policy</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="mailto:{{support_email}}">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
const DEFAULT_PUBLIC_SITE_CSS = `
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
|
||||
color: var(--brand-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.brand-label {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
position: relative;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.site-nav a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -6px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.site-nav a:hover::after {
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.site-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rem;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.site-main {
|
||||
padding: 3rem 0 5rem;
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.features,
|
||||
.workflow,
|
||||
.collections,
|
||||
.stories,
|
||||
.cta {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.hero__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.65rem, 4.8vw, 3.6rem);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.hero__lead {
|
||||
margin: 0;
|
||||
max-width: 32rem;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero__cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.hero__stats {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hero__stats dt {
|
||||
font-weight: 600;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.hero__stats dd {
|
||||
margin: 0.35rem 0 0;
|
||||
color: rgba(15, 23, 42, 0.6);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hero__visual {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.deck {
|
||||
border-radius: 20px;
|
||||
padding: 1.75rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
|
||||
.deck--primary {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.deck--secondary {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
|
||||
}
|
||||
|
||||
.deck__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.deck__logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.deck__title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.deck__list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
color: rgba(15, 23, 42, 0.68);
|
||||
}
|
||||
|
||||
.deck__quote {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
}
|
||||
|
||||
.deck__author {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.section-head--center {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
display: inline-flex;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 3vw, 2.6rem);
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.feature-grid article {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.workflow {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.workflow {
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.workflow__steps {
|
||||
margin: 1.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.workflow__steps h4 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.workflow__steps p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.workflow__browser {
|
||||
margin: 0;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.collection-showcase {
|
||||
margin-top: 2.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.collection-showcase article {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.story-grid {
|
||||
margin-top: 2.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.story-grid figure {
|
||||
margin: 0;
|
||||
padding: 1.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.story-grid blockquote {
|
||||
margin: 0 0 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.story-grid figcaption {
|
||||
font-weight: 600;
|
||||
color: rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent));
|
||||
color: #fff;
|
||||
border-radius: 28px;
|
||||
padding: clamp(2.5rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.cta__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.cta__inner h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.cta__inner p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.cta__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--brand-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background: var(--brand-accent);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 3rem 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.footer-inner h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.footer-inner p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero__visual {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workflow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cta__inner {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_PUBLIC_SITE_TITLE,
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
};
|
||||
@@ -8,6 +8,16 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
} = require('../constants/publicSiteDefaults');
|
||||
const {
|
||||
clearPublicSiteCache,
|
||||
getDefaultPublicSitePayload,
|
||||
getRawPublicSiteSettings,
|
||||
} = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for logo uploads
|
||||
@@ -284,6 +294,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
metadata: JSON.stringify({ company_name })
|
||||
});
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Branding settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
@@ -443,6 +455,8 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
||||
});
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Theme settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Theme update error:', error);
|
||||
@@ -453,7 +467,39 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
// Update general settings
|
||||
router.put('/general', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
const settings = { ...req.body };
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') {
|
||||
settings.general_public_site_html = settings.general_public_site_html.trim();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) {
|
||||
settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled);
|
||||
}
|
||||
|
||||
const enableToggle = settings.general_public_site_enabled;
|
||||
if (enableToggle === true) {
|
||||
let htmlValue = settings.general_public_site_html;
|
||||
|
||||
if (htmlValue === undefined) {
|
||||
const currentSettings = await getRawPublicSiteSettings();
|
||||
htmlValue = currentSettings.general_public_site_html;
|
||||
}
|
||||
|
||||
if (!htmlValue || !String(htmlValue).trim()) {
|
||||
return res.status(400).json({
|
||||
error: 'Public site HTML must be provided before enabling the public landing page.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
@@ -476,6 +522,10 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
clearMaintenanceCache();
|
||||
}
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
clearPublicSiteCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'general_settings_updated',
|
||||
@@ -603,11 +653,190 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
let diskStats = null;
|
||||
let rawDiskTotal = null;
|
||||
let rawDiskFree = null;
|
||||
let rawDiskAvailable = null;
|
||||
try {
|
||||
diskStats = await fs.statfs(storagePath);
|
||||
rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks);
|
||||
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
|
||||
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
|
||||
} catch (diskError) {
|
||||
console.error('Disk stats error:', diskError.message);
|
||||
}
|
||||
|
||||
const clampDiskValue = (value) => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Treat unusually large virtualised values as unreliable (>50TB)
|
||||
const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024;
|
||||
if (value > MAX_REASONABLE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
let diskTotal = null;
|
||||
let diskFree = null;
|
||||
let diskAvailable = null;
|
||||
|
||||
if (diskStats) {
|
||||
diskTotal = clampDiskValue(rawDiskTotal);
|
||||
diskFree = clampDiskValue(rawDiskFree);
|
||||
diskAvailable = clampDiskValue(rawDiskAvailable);
|
||||
|
||||
if (diskTotal && diskAvailable && diskAvailable > diskTotal) {
|
||||
diskAvailable = null;
|
||||
}
|
||||
if (diskTotal && diskFree && diskFree > diskTotal) {
|
||||
diskFree = null;
|
||||
}
|
||||
}
|
||||
|
||||
const totalUsed = totalStorage?.total || 0;
|
||||
|
||||
const parseBytesValue = (value) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
};
|
||||
|
||||
const parseEnvOverride = (bytesVar, gbVar) => {
|
||||
if (process.env[bytesVar]) {
|
||||
return parseBytesValue(process.env[bytesVar]);
|
||||
}
|
||||
if (process.env[gbVar]) {
|
||||
const value = parseBytesValue(process.env[gbVar]);
|
||||
return value ? value * 1024 * 1024 * 1024 : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let configuredSoftLimit = null;
|
||||
let capacityOverrideDb = null;
|
||||
let availableOverrideDb = null;
|
||||
|
||||
try {
|
||||
const storageSettings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_storage_soft_limit_bytes',
|
||||
'general_storage_capacity_override_bytes',
|
||||
'general_storage_available_override_bytes'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
storageSettings.forEach((setting) => {
|
||||
let parsedValue = null;
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
parsedValue = JSON.parse(setting.setting_value);
|
||||
} catch (error) {
|
||||
parsedValue = setting.setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
switch (setting.setting_key) {
|
||||
case 'general_storage_soft_limit_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
configuredSoftLimit = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_capacity_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
capacityOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_available_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
availableOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage settings read error:', error.message);
|
||||
}
|
||||
|
||||
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
|
||||
const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB');
|
||||
|
||||
let capacityOverrideBytes = null;
|
||||
let availableOverrideBytes = null;
|
||||
let overrideSource = null;
|
||||
|
||||
if (capacityOverrideEnv != null || availableOverrideEnv != null) {
|
||||
capacityOverrideBytes = capacityOverrideEnv;
|
||||
availableOverrideBytes = availableOverrideEnv;
|
||||
overrideSource = 'env';
|
||||
} else if (capacityOverrideDb != null || availableOverrideDb != null) {
|
||||
capacityOverrideBytes = capacityOverrideDb;
|
||||
availableOverrideBytes = availableOverrideDb;
|
||||
overrideSource = 'settings';
|
||||
}
|
||||
|
||||
if (capacityOverrideBytes != null) {
|
||||
diskTotal = capacityOverrideBytes;
|
||||
if (availableOverrideBytes == null) {
|
||||
diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0);
|
||||
} else {
|
||||
diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes);
|
||||
}
|
||||
diskFree = diskAvailable;
|
||||
} else if (availableOverrideBytes != null) {
|
||||
diskAvailable = Math.max(availableOverrideBytes, 0);
|
||||
diskFree = diskAvailable;
|
||||
}
|
||||
|
||||
let recommendedSoftLimit = null;
|
||||
if (diskTotal && diskAvailable) {
|
||||
const projected = totalUsed + Math.floor(diskAvailable * 0.8);
|
||||
recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5)));
|
||||
} else if (diskTotal) {
|
||||
recommendedSoftLimit = Math.floor(diskTotal * 0.8);
|
||||
} else if (diskAvailable) {
|
||||
recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8));
|
||||
}
|
||||
|
||||
if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) {
|
||||
recommendedSoftLimit = totalUsed;
|
||||
}
|
||||
|
||||
const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES;
|
||||
if (!recommendedSoftLimit && fallbackSoftLimit) {
|
||||
recommendedSoftLimit = fallbackSoftLimit;
|
||||
}
|
||||
const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES;
|
||||
|
||||
const diskMetricsReliable = Boolean(diskTotal);
|
||||
|
||||
res.json({
|
||||
total_used: totalStorage.total || 0,
|
||||
total_used: totalUsed,
|
||||
archive_storage: archiveStorage,
|
||||
storage_by_event: storageByEvent,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default
|
||||
storage_limit: effectiveSoftLimit,
|
||||
storage_soft_limit: effectiveSoftLimit,
|
||||
configured_soft_limit: configuredSoftLimit,
|
||||
recommended_soft_limit: recommendedSoftLimit,
|
||||
soft_limit_configured: Boolean(configuredSoftLimit),
|
||||
disk_total: diskTotal,
|
||||
disk_free: diskFree,
|
||||
disk_available: diskAvailable,
|
||||
disk_total_raw: rawDiskTotal,
|
||||
disk_free_raw: rawDiskFree,
|
||||
disk_available_raw: rawDiskAvailable,
|
||||
disk_metrics_reliable: diskMetricsReliable,
|
||||
disk_override_source: overrideSource
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage info error:', error);
|
||||
@@ -717,4 +946,79 @@ router.put('/security/rate-limit', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
// Get default public site template
|
||||
router.get('/public-site/default', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
res.json({
|
||||
enabled: false,
|
||||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||
css: '',
|
||||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||
branding: defaults.branding,
|
||||
meta: {
|
||||
title: defaults.title,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load public site defaults:', error);
|
||||
res.status(500).json({ error: 'Failed to load defaults' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset public site template to defaults
|
||||
router.post('/public-site/reset', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const entries = [
|
||||
{
|
||||
key: 'general_public_site_html',
|
||||
value: DEFAULT_PUBLIC_SITE_HTML.trim()
|
||||
},
|
||||
{
|
||||
key: 'general_public_site_custom_css',
|
||||
value: ''
|
||||
}
|
||||
];
|
||||
|
||||
for (const { key, value } of entries) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
await logActivity('public_site_reset_to_default',
|
||||
{
|
||||
template_length: DEFAULT_PUBLIC_SITE_HTML.length,
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Public site template reset to defaults',
|
||||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||
css: '',
|
||||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||
branding: defaults.branding
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to reset public site template:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
||||
const crypto = require('crypto');
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_TITLE,
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
} = require('../constants/publicSiteDefaults');
|
||||
|
||||
const CACHE_TTL_MS = Number(process.env.PUBLIC_SITE_CACHE_TTL_MS || 60_000);
|
||||
|
||||
let cachedPayload = null;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
const ALLOWED_HTML_TAGS = [
|
||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div',
|
||||
'em', 'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'header', 'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span',
|
||||
'strong', 'sup', 'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
|
||||
'ul'
|
||||
];
|
||||
|
||||
const COMMON_ATTRIBUTES = ['class', 'id', 'role', 'aria-label', 'aria-hidden'];
|
||||
|
||||
function parseSettingValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublicSiteSettings() {
|
||||
const rows = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_public_site_enabled',
|
||||
'general_public_site_html',
|
||||
'general_public_site_custom_css'
|
||||
]);
|
||||
|
||||
const map = {
|
||||
general_public_site_enabled: false,
|
||||
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
|
||||
general_public_site_custom_css: ''
|
||||
};
|
||||
|
||||
rows.forEach((row) => {
|
||||
const parsed = parseSettingValue(row.setting_value);
|
||||
map[row.setting_key] = parsed == null ? map[row.setting_key] : parsed;
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function sanitizeBrandUrl(url) {
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.startsWith('javascript:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function fetchBrandingContext() {
|
||||
const rows = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_company_name',
|
||||
'branding_company_tagline',
|
||||
'branding_support_email',
|
||||
'branding_logo_url',
|
||||
'branding_footer_text',
|
||||
'theme_config'
|
||||
]);
|
||||
|
||||
const context = {
|
||||
companyName: null,
|
||||
companyTagline: null,
|
||||
supportEmail: null,
|
||||
logoUrl: null,
|
||||
footerText: null,
|
||||
colors: {
|
||||
primary: '#16a34a',
|
||||
accent: '#0f766e',
|
||||
background: '#f4fbf6',
|
||||
text: '#0f172a'
|
||||
}
|
||||
};
|
||||
|
||||
rows.forEach((row) => {
|
||||
const parsed = parseSettingValue(row.setting_value);
|
||||
switch (row.setting_key) {
|
||||
case 'branding_company_name':
|
||||
context.companyName = parsed || context.companyName;
|
||||
break;
|
||||
case 'branding_company_tagline':
|
||||
context.companyTagline = parsed || context.companyTagline;
|
||||
break;
|
||||
case 'branding_support_email':
|
||||
context.supportEmail = parsed || context.supportEmail;
|
||||
break;
|
||||
case 'branding_logo_url':
|
||||
context.logoUrl = sanitizeBrandUrl(parsed);
|
||||
break;
|
||||
case 'branding_footer_text':
|
||||
context.footerText = parsed || context.footerText;
|
||||
break;
|
||||
case 'theme_config': {
|
||||
try {
|
||||
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
||||
if (themeConfig && typeof themeConfig === 'object') {
|
||||
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
|
||||
context.colors.accent = themeConfig.accentColor || context.colors.accent;
|
||||
context.colors.background = themeConfig.backgroundColor || context.colors.background;
|
||||
context.colors.text = themeConfig.textColor || context.colors.text;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function sanitizeHtmlPayload(html) {
|
||||
const sanitized = sanitizeHtml(html || '', {
|
||||
allowedTags: ALLOWED_HTML_TAGS,
|
||||
allowedAttributes: {
|
||||
'*': COMMON_ATTRIBUTES,
|
||||
a: ['href', 'target', 'rel', ...COMMON_ATTRIBUTES],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding', ...COMMON_ATTRIBUTES],
|
||||
button: ['type', ...COMMON_ATTRIBUTES]
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => {
|
||||
const transformed = { ...attribs };
|
||||
if (transformed.href && !/^https?:|^mailto:|^tel:/i.test(transformed.href)) {
|
||||
// sanitize-html will remove disallowed schemes, but we guard as well
|
||||
delete transformed.href;
|
||||
}
|
||||
|
||||
if (transformed.target === '_blank') {
|
||||
transformed.rel = transformed.rel ? `${transformed.rel} noopener noreferrer`.trim() : 'noopener noreferrer';
|
||||
}
|
||||
|
||||
return { tagName, attribs: transformed };
|
||||
}
|
||||
},
|
||||
nonBooleanAttributes: ['target'],
|
||||
parser: {
|
||||
lowerCaseAttributeNames: true
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function buildCachedPayload(raw) {
|
||||
const sanitizedHtml = sanitizeHtmlPayload(raw.publicSite.general_public_site_html || DEFAULT_PUBLIC_SITE_HTML);
|
||||
const sanitizedCss = sanitizeCss(raw.publicSite.general_public_site_custom_css || '');
|
||||
const enabled = Boolean(raw.publicSite.general_public_site_enabled);
|
||||
const title = raw.branding.companyName || DEFAULT_PUBLIC_SITE_TITLE;
|
||||
const baseCss = sanitizeCss(DEFAULT_PUBLIC_SITE_CSS);
|
||||
|
||||
const substitutedHtml = applyBrandTokens(sanitizedHtml, raw.branding);
|
||||
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${enabled}|${substitutedHtml}|${sanitizedCss}|${baseCss}|${JSON.stringify(raw.branding)}`)
|
||||
.digest('hex');
|
||||
|
||||
return {
|
||||
enabled,
|
||||
html: substitutedHtml,
|
||||
css: sanitizedCss,
|
||||
baseCss,
|
||||
title,
|
||||
branding: raw.branding,
|
||||
etag: `W/"${hash}"`
|
||||
};
|
||||
}
|
||||
|
||||
async function getPublicSitePayload({ bypassCache = false } = {}) {
|
||||
if (!bypassCache && cachedPayload && Date.now() < cacheExpiresAt) {
|
||||
return cachedPayload;
|
||||
}
|
||||
|
||||
const [publicSite, branding] = await Promise.all([
|
||||
fetchPublicSiteSettings(),
|
||||
fetchBrandingContext()
|
||||
]);
|
||||
|
||||
const payload = buildCachedPayload({ publicSite, branding });
|
||||
|
||||
cachedPayload = payload;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function clearPublicSiteCache() {
|
||||
cachedPayload = null;
|
||||
cacheExpiresAt = 0;
|
||||
}
|
||||
|
||||
async function getDefaultPublicSitePayload() {
|
||||
const branding = await fetchBrandingContext();
|
||||
return buildCachedPayload({
|
||||
publicSite: {
|
||||
general_public_site_enabled: false,
|
||||
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
|
||||
general_public_site_custom_css: ''
|
||||
},
|
||||
branding
|
||||
});
|
||||
}
|
||||
|
||||
async function getRawPublicSiteSettings() {
|
||||
return fetchPublicSiteSettings();
|
||||
}
|
||||
|
||||
function applyBrandTokens(html, branding) {
|
||||
if (!html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const tokens = {
|
||||
company_name: branding.companyName || '',
|
||||
company_tagline: branding.companyTagline || '',
|
||||
support_email: branding.supportEmail || '',
|
||||
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
|
||||
brand_primary_hex: branding.colors?.primary || '#2563eb',
|
||||
brand_accent_hex: branding.colors?.accent || '#1d4ed8',
|
||||
brand_background_hex: branding.colors?.background || '#f8fafc',
|
||||
brand_text_hex: branding.colors?.text || '#0f172a'
|
||||
};
|
||||
|
||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
|
||||
(_, key) => tokens[key] || '');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPublicSitePayload,
|
||||
clearPublicSiteCache,
|
||||
getDefaultPublicSitePayload,
|
||||
getRawPublicSiteSettings
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
const S3StorageAdapter = require('../s3Storage');
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { S3Client, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const fs = require('fs');
|
||||
const stream = require('stream');
|
||||
@@ -25,6 +25,10 @@ describe('S3StorageAdapter', () => {
|
||||
};
|
||||
S3Client.mockImplementation(() => mockS3Client);
|
||||
|
||||
HeadBucketCommand.mockImplementation((input) => ({ input }));
|
||||
HeadObjectCommand.mockImplementation((input) => ({ input }));
|
||||
ListObjectsV2Command.mockImplementation((input) => ({ input }));
|
||||
|
||||
// Create adapter instance
|
||||
s3Storage = new S3StorageAdapter({
|
||||
bucket: 'test-bucket',
|
||||
@@ -70,11 +74,7 @@ describe('S3StorageAdapter', () => {
|
||||
const result = await s3Storage.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { Bucket: 'test-bucket' }
|
||||
})
|
||||
);
|
||||
expect(HeadBucketCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket' });
|
||||
});
|
||||
|
||||
it('should throw error on connection failure', async () => {
|
||||
@@ -132,22 +132,23 @@ describe('S3StorageAdapter', () => {
|
||||
|
||||
it('should track upload progress', async () => {
|
||||
const onProgress = jest.fn();
|
||||
let progressCallback;
|
||||
|
||||
mockUpload.on.mockImplementation((event, callback) => {
|
||||
if (event === 'httpUploadProgress') {
|
||||
progressCallback = callback;
|
||||
}
|
||||
return mockUpload;
|
||||
Upload.mockImplementation(() => {
|
||||
const uploadInstance = {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'httpUploadProgress') {
|
||||
handler({ loaded: 512, total: 1024 });
|
||||
}
|
||||
return uploadInstance;
|
||||
}),
|
||||
done: mockDone
|
||||
};
|
||||
return uploadInstance;
|
||||
});
|
||||
|
||||
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
|
||||
onProgress
|
||||
});
|
||||
|
||||
// Simulate progress
|
||||
progressCallback({ loaded: 512, total: 1024 });
|
||||
|
||||
await uploadPromise;
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(512, 1024);
|
||||
@@ -177,11 +178,10 @@ describe('S3StorageAdapter', () => {
|
||||
const result = await s3Storage.exists('test-key');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { Bucket: 'test-bucket', Key: 'test-key' }
|
||||
})
|
||||
);
|
||||
expect(HeadObjectCommand).toHaveBeenCalledWith({
|
||||
Bucket: 'test-bucket',
|
||||
Key: 'test-key'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false if object does not exist', async () => {
|
||||
@@ -221,57 +221,50 @@ describe('S3StorageAdapter', () => {
|
||||
const retryableError = new Error('Connection reset');
|
||||
retryableError.code = 'ECONNRESET';
|
||||
|
||||
// First attempt fails, second succeeds
|
||||
mockSend
|
||||
const operation = jest.fn()
|
||||
.mockRejectedValueOnce(retryableError)
|
||||
.mockResolvedValueOnce({});
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
// Mock setTimeout to speed up test
|
||||
jest.useFakeTimers();
|
||||
const originalRandom = Math.random;
|
||||
const originalDelay = s3Storage.config.retryDelay;
|
||||
Math.random = jest.fn(() => 0);
|
||||
s3Storage.config.retryDelay = 0;
|
||||
|
||||
const promise = s3Storage.exists('test-key');
|
||||
const result = await s3Storage._retryOperation(operation);
|
||||
|
||||
// Advance timers
|
||||
jest.runAllTimers();
|
||||
expect(result).toBe('success');
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
Math.random = originalRandom;
|
||||
s3Storage.config.retryDelay = originalDelay;
|
||||
});
|
||||
|
||||
it('should not retry on non-retryable errors', async () => {
|
||||
const nonRetryableError = new Error('Invalid credentials');
|
||||
nonRetryableError.code = 'InvalidCredentials';
|
||||
|
||||
mockSend.mockRejectedValueOnce(nonRetryableError);
|
||||
const operation = jest.fn().mockRejectedValueOnce(nonRetryableError);
|
||||
|
||||
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Invalid credentials');
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop retrying after max attempts', async () => {
|
||||
const retryableError = new Error('Service unavailable');
|
||||
retryableError.code = 'ServiceUnavailable';
|
||||
|
||||
mockSend.mockRejectedValue(retryableError);
|
||||
const operation = jest.fn().mockRejectedValue(retryableError);
|
||||
|
||||
// Mock setTimeout to speed up test
|
||||
jest.useFakeTimers();
|
||||
const originalRandom = Math.random;
|
||||
const originalDelay = s3Storage.config.retryDelay;
|
||||
Math.random = jest.fn(() => 0);
|
||||
s3Storage.config.retryDelay = 0;
|
||||
|
||||
const promise = s3Storage.exists('test-key');
|
||||
await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Service unavailable');
|
||||
expect(operation).toHaveBeenCalledTimes(4); // initial + 3 retries
|
||||
|
||||
// Advance timers for all retries
|
||||
for (let i = 0; i < 4; i++) {
|
||||
jest.runAllTimers();
|
||||
}
|
||||
|
||||
await expect(promise).rejects.toThrow('Service unavailable');
|
||||
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
||||
|
||||
jest.useRealTimers();
|
||||
Math.random = originalRandom;
|
||||
s3Storage.config.retryDelay = originalDelay;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
function sanitizeCss(css) {
|
||||
if (!css || typeof css !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = css;
|
||||
|
||||
const disallowedPatterns = [
|
||||
/@import[^;]+;?/gi,
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
const MAX_LENGTH = 100 * 1024;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeCss,
|
||||
};
|
||||
@@ -101,6 +101,19 @@ server {
|
||||
proxy_cache_valid 404 1m;
|
||||
}
|
||||
|
||||
# Delegate root requests to backend for public landing page handling
|
||||
location = / {
|
||||
proxy_pass http://backend:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
AnalyticsPage,
|
||||
BrandingPage,
|
||||
SettingsPage,
|
||||
CMSPage,
|
||||
BackupManagement
|
||||
} from './pages/admin';
|
||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
||||
|
||||
@@ -167,6 +167,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{photos.map((photo, index) => {
|
||||
const isDeleting = deletingPhotos.has(photo.id);
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const averageRating = photo.average_rating ?? 0;
|
||||
const likeCount = photo.like_count ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
@@ -258,18 +261,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
)}
|
||||
|
||||
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
|
||||
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
|
||||
{photo.average_rating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
||||
{averageRating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(averageRating).toFixed(1)}`}>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(averageRating).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.comment_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
||||
{commentCount > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{commentCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
|
||||
type AdminFeedbackResponse = {
|
||||
feedback: PhotoFeedback[];
|
||||
summary?: FeedbackSummary;
|
||||
};
|
||||
|
||||
interface AdminPhotoViewerProps {
|
||||
photos: AdminPhoto[];
|
||||
initialIndex: number;
|
||||
@@ -34,9 +39,16 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
const averageRating = currentPhoto?.average_rating ?? 0;
|
||||
const likeCount = currentPhoto?.like_count ?? 0;
|
||||
const favoriteCount = currentPhoto?.favorite_count ?? 0;
|
||||
|
||||
if (!currentPhoto) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch feedback for current photo
|
||||
const { data: feedbackData } = useQuery({
|
||||
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
|
||||
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||
photoId: currentPhoto?.id.toString(),
|
||||
@@ -45,6 +57,8 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
enabled: !!currentPhoto
|
||||
});
|
||||
|
||||
const comments = (feedbackData?.feedback ?? []).filter((item): item is PhotoFeedback => item.feedback_type === 'comment');
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||
};
|
||||
@@ -304,41 +318,41 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
|
||||
{/* Feedback Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{currentPhoto.average_rating > 0 && (
|
||||
{averageRating > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-yellow-400 mb-1">
|
||||
<Star className="w-4 h-4" fill="currentColor" />
|
||||
<span className="text-white font-medium">{Number(currentPhoto.average_rating).toFixed(1)}</span>
|
||||
<span className="text-white font-medium">{Number(averageRating).toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Avg Rating</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.like_count > 0 && (
|
||||
{likeCount > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-red-400 mb-1">
|
||||
<Heart className="w-4 h-4" fill="currentColor" />
|
||||
<span className="text-white font-medium">{currentPhoto.like_count}</span>
|
||||
<span className="text-white font-medium">{likeCount}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Likes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.favorite_count > 0 && (
|
||||
{favoriteCount > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-blue-400 mb-1">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-white font-medium">{currentPhoto.favorite_count}</span>
|
||||
<span className="text-white font-medium">{favoriteCount}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Favorites</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedbackData.feedback && (
|
||||
{comments.length > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-green-400 mb-1">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="text-white font-medium">{feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}</span>
|
||||
<span className="text-white font-medium">{comments.length}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Comments</p>
|
||||
</div>
|
||||
@@ -346,20 +360,18 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && (
|
||||
{comments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setExpandedComments(!expandedComments)}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
|
||||
>
|
||||
{expandedComments ? 'Hide' : 'Show'} Comments ({feedbackData.feedback.filter(f => f.feedback_type === 'comment').length})
|
||||
{expandedComments ? 'Hide' : 'Show'} Comments ({comments.length})
|
||||
</button>
|
||||
|
||||
{expandedComments && (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{feedbackData.feedback
|
||||
.filter(f => f.feedback_type === 'comment')
|
||||
.map((comment) => (
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
@@ -459,7 +471,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
)}
|
||||
|
||||
{/* No feedback message */}
|
||||
{(!feedbackData.feedback || feedbackData.feedback.length === 0) && (
|
||||
{comments.length === 0 && (
|
||||
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -121,11 +121,20 @@ const StorageInfo: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100);
|
||||
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
|
||||
const usagePercent = limitInUse
|
||||
? Math.round((storageInfo.total_used / limitInUse) * 100)
|
||||
: 0;
|
||||
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
|
||||
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
|
||||
const containerClass = isOverSoftLimit
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: 'bg-neutral-100';
|
||||
const softLimitDisplay = settingsService.formatBytes(limitInUse);
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t border-neutral-200">
|
||||
<div className="bg-neutral-100 rounded-lg p-3">
|
||||
<div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900">
|
||||
@@ -134,12 +143,12 @@ const StorageInfo: React.FC = () => {
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
|
||||
{t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupConfiguration: ComponentType<any>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupDashboard: ComponentType<any>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupHistory: ComponentType<any>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
@@ -107,10 +107,11 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
},
|
||||
});
|
||||
|
||||
const updateCounts = useCallback((editor: any) => {
|
||||
const text = editor.state.doc.textContent;
|
||||
setCharCount(editor.storage.characterCount.characters());
|
||||
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
|
||||
const updateCounts = useCallback((editorInstance: Editor) => {
|
||||
const textContent = editorInstance.state.doc.textContent;
|
||||
setCharCount(editorInstance.storage.characterCount.characters());
|
||||
const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0);
|
||||
setWordCount(words.length);
|
||||
}, []);
|
||||
|
||||
// Update editor content when prop changes
|
||||
|
||||
@@ -3,12 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
MessageSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { parseISO } from 'date-fns';
|
||||
@@ -16,7 +13,7 @@ import { toast } from 'react-toastify';
|
||||
|
||||
import { Card, Loading, Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
interface FeedbackModerationPanelProps {
|
||||
@@ -38,7 +35,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// Fetch pending feedback
|
||||
const { data: feedbackData, isLoading } = useQuery({
|
||||
const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
|
||||
queryKey: ['event-feedback-moderation', eventId],
|
||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||
type: 'comment',
|
||||
@@ -77,12 +74,12 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
);
|
||||
}
|
||||
|
||||
const pendingComments = feedbackData?.feedback || [];
|
||||
const pendingComments: PhotoFeedback[] = feedbackData?.feedback || [];
|
||||
const hasPending = pendingComments.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="p-6">
|
||||
<div className={compact ? 'p-4' : 'p-6'}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
{t('feedback.pendingModeration', 'Pending Moderation')}
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Button, Card, Toggle, Select, Input, Textarea } from '../common';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface ProtectionSettings {
|
||||
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
default_image_quality: number;
|
||||
enable_devtools_protection: boolean;
|
||||
max_image_requests_per_minute: number;
|
||||
suspicious_activity_threshold: number;
|
||||
enable_canvas_rendering: boolean;
|
||||
default_fragmentation_level: number;
|
||||
enable_overlay_protection: boolean;
|
||||
protection_warning_message: string;
|
||||
}
|
||||
|
||||
export const ImageProtectionSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<ProtectionSettings>({
|
||||
default_protection_level: 'standard',
|
||||
default_image_quality: 85,
|
||||
enable_devtools_protection: true,
|
||||
max_image_requests_per_minute: 30,
|
||||
suspicious_activity_threshold: 10,
|
||||
enable_canvas_rendering: false,
|
||||
default_fragmentation_level: 3,
|
||||
enable_overlay_protection: true,
|
||||
protection_warning_message: 'Images in this gallery are protected from unauthorized download.'
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await settingsService.getSettings();
|
||||
|
||||
// Map settings from API response
|
||||
const protectionSettings: ProtectionSettings = {
|
||||
default_protection_level: response.default_protection_level || 'standard',
|
||||
default_image_quality: parseInt(response.default_image_quality) || 85,
|
||||
enable_devtools_protection: response.enable_devtools_protection !== false,
|
||||
max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30,
|
||||
suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10,
|
||||
enable_canvas_rendering: response.enable_canvas_rendering === true,
|
||||
default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3,
|
||||
enable_overlay_protection: response.enable_overlay_protection !== false,
|
||||
protection_warning_message: response.protection_warning_message || settings.protection_warning_message
|
||||
};
|
||||
|
||||
setSettings(protectionSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to load protection settings:', error);
|
||||
toast.error('Failed to load protection settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Convert settings to API format
|
||||
const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => {
|
||||
acc[key] = typeof value === 'boolean' ? value : value.toString();
|
||||
return acc;
|
||||
}, {} as Record<string, string | boolean>);
|
||||
|
||||
await settingsService.updateSettings(apiSettings);
|
||||
toast.success('Protection settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save protection settings:', error);
|
||||
toast.error('Failed to save protection settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = (key: keyof ProtectionSettings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const protectionLevels = [
|
||||
{ value: 'basic', label: 'Basic - Minimal protection, best performance' },
|
||||
{ value: 'standard', label: 'Standard - Balanced protection and performance' },
|
||||
{ value: 'enhanced', label: 'Enhanced - Strong protection with good performance' },
|
||||
{ value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' }
|
||||
];
|
||||
|
||||
const getProtectionLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'basic': return <Eye className="w-4 h-4 text-green-500" />;
|
||||
case 'standard': return <Shield className="w-4 h-4 text-blue-500" />;
|
||||
case 'enhanced': return <Lock className="w-4 h-4 text-orange-500" />;
|
||||
case 'maximum': return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
default: return <Shield className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getProtectionLevelDescription = (level: string) => {
|
||||
switch (level) {
|
||||
case 'basic':
|
||||
return 'Prevents drag/drop and basic right-click. Good for public galleries.';
|
||||
case 'standard':
|
||||
return 'Adds keyboard shortcut blocking and user selection prevention.';
|
||||
case 'enhanced':
|
||||
return 'Includes DevTools detection, rate limiting, and overlay protection.';
|
||||
case 'maximum':
|
||||
return 'Canvas rendering, image fragmentation, and comprehensive monitoring.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Shield className="w-6 h-6 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Image Protection Settings</h2>
|
||||
<p className="text-sm text-gray-600">Configure security measures for photo galleries</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Protection Level */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Protection Level
|
||||
</label>
|
||||
<Select
|
||||
value={settings.default_protection_level}
|
||||
onChange={(value) => updateSetting('default_protection_level', value as any)}
|
||||
options={protectionLevels}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getProtectionLevelIcon(settings.default_protection_level)}
|
||||
<span className="font-medium text-sm capitalize">
|
||||
{settings.default_protection_level} Protection
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{getProtectionLevelDescription(settings.default_protection_level)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Quality */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Image Quality ({settings.default_image_quality}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="30"
|
||||
max="100"
|
||||
step="5"
|
||||
value={settings.default_image_quality}
|
||||
onChange={(e) => updateSetting('default_image_quality', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Lower quality = Better protection</span>
|
||||
<span>Higher quality = Better image</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DevTools Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
DevTools Protection
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Detect and respond to browser developer tools
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_devtools_protection}
|
||||
onChange={(checked) => updateSetting('enable_devtools_protection', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Canvas Rendering */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Canvas Rendering
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Render images on canvas instead of img tags (stronger protection)
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_canvas_rendering}
|
||||
onChange={(checked) => updateSetting('enable_canvas_rendering', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fragmentation Level */}
|
||||
{settings.enable_canvas_rendering && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Image Fragmentation Level ({settings.default_fragmentation_level})
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={settings.default_fragmentation_level}
|
||||
onChange={(e) => updateSetting('default_fragmentation_level', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Low fragmentation</span>
|
||||
<span>High fragmentation</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Requests Per Minute
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
value={settings.max_image_requests_per_minute}
|
||||
onChange={(e) => updateSetting('max_image_requests_per_minute', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Suspicious Activity Threshold
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="50"
|
||||
value={settings.suspicious_activity_threshold}
|
||||
onChange={(e) => updateSetting('suspicious_activity_threshold', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Overlay Protection
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Add transparent overlays to prevent easy screenshot extraction
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_overlay_protection}
|
||||
onChange={(checked) => updateSetting('enable_overlay_protection', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Protection Warning Message
|
||||
</label>
|
||||
<Textarea
|
||||
value={settings.protection_warning_message}
|
||||
onChange={(e) => updateSetting('protection_warning_message', e.target.value)}
|
||||
placeholder="Message shown when protection is triggered"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-amber-800 mb-1">Important Notes</h4>
|
||||
<ul className="text-sm text-amber-700 space-y-1">
|
||||
<li>• Higher protection levels may impact page performance</li>
|
||||
<li>• Canvas rendering disables browser image caching</li>
|
||||
<li>• Maximum protection may cause accessibility issues</li>
|
||||
<li>• Test thoroughly with your target browsers</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadSettings}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={saveSettings}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -94,7 +94,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||
await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
// Calculate overall progress across all chunks
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const RestoreWizard: ComponentType<any>;
|
||||
@@ -37,8 +37,8 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
rows={rows}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 text-neutral-400">
|
||||
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
|
||||
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
|
||||
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,23 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
isGallery?: boolean;
|
||||
protectFromDownload?: boolean;
|
||||
slug?: string;
|
||||
photoId?: number;
|
||||
requiresToken?: boolean;
|
||||
secureUrlTemplate?: string;
|
||||
downloadUrlTemplate?: string;
|
||||
onProtectionViolation?: (violationType: string) => void;
|
||||
watermarkText?: string;
|
||||
overlayProtection?: boolean;
|
||||
fragmentGrid?: boolean;
|
||||
scrambleFragments?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
blockKeyboardShortcuts?: boolean;
|
||||
detectPrintScreen?: boolean;
|
||||
detectDevTools?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
@@ -14,8 +31,46 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
alt,
|
||||
useWatermark = false,
|
||||
isGallery = false,
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
downloadUrlTemplate,
|
||||
onProtectionViolation,
|
||||
watermarkText,
|
||||
overlayProtection,
|
||||
fragmentGrid,
|
||||
scrambleFragments,
|
||||
useCanvasRendering,
|
||||
blockKeyboardShortcuts,
|
||||
detectPrintScreen,
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
...props
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
downloadUrlTemplate,
|
||||
onProtectionViolation,
|
||||
watermarkText,
|
||||
overlayProtection,
|
||||
fragmentGrid,
|
||||
scrambleFragments,
|
||||
useCanvasRendering,
|
||||
blockKeyboardShortcuts,
|
||||
detectPrintScreen,
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection
|
||||
};
|
||||
void unusedProps;
|
||||
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -183,7 +183,7 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() => selectPassword(password)}
|
||||
>
|
||||
{t('passwordGenerator.use')}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { ProtectionLevel } from '../../hooks/useImageProtection';
|
||||
|
||||
type VendorStyle = CSSStyleDeclaration & {
|
||||
webkitUserSelect?: string;
|
||||
webkitTouchCallout?: string;
|
||||
webkitUserDrag?: string;
|
||||
};
|
||||
|
||||
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
@@ -31,7 +37,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
@@ -201,9 +206,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
// Render normal image - ensure image is valid before drawing
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Verify the image was drawn by checking a pixel
|
||||
const pixelData = ctx.getImageData(10, 10, 1, 1).data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,11 +290,12 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
}
|
||||
|
||||
// Apply CSS protection
|
||||
canvas.style.userSelect = 'none';
|
||||
canvas.style.webkitUserSelect = 'none';
|
||||
canvas.style.webkitTouchCallout = 'none';
|
||||
canvas.style.webkitUserDrag = 'none';
|
||||
canvas.style.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
||||
const canvasStyle = canvas.style as VendorStyle;
|
||||
canvasStyle.userSelect = 'none';
|
||||
canvasStyle.webkitUserSelect = 'none';
|
||||
canvasStyle.webkitTouchCallout = 'none';
|
||||
canvasStyle.webkitUserDrag = 'none';
|
||||
canvasStyle.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('contextmenu', handleContextMenu);
|
||||
|
||||
@@ -102,7 +102,7 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
<style>{`
|
||||
@keyframes shrink {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
|
||||
@@ -35,7 +35,6 @@ interface GalleryLayoutProps {
|
||||
showDownloadAll?: boolean;
|
||||
onDownloadAll?: () => void;
|
||||
isDownloading?: boolean;
|
||||
isExpired?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
menuButton?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
@@ -49,7 +48,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
showDownloadAll = false,
|
||||
onDownloadAll,
|
||||
isDownloading = false,
|
||||
isExpired = false,
|
||||
headerExtra,
|
||||
menuButton,
|
||||
children,
|
||||
@@ -63,22 +61,28 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
// Calculate logo size classes based on settings
|
||||
const getLogoSizeClass = (context: 'header' | 'hero') => {
|
||||
const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => {
|
||||
const size = brandingSettings?.logo_size || 'medium';
|
||||
const maxHeight = brandingSettings?.logo_max_height || 48;
|
||||
|
||||
if (size === 'custom') {
|
||||
return { maxHeight: `${maxHeight}px`, height: 'auto' };
|
||||
return {
|
||||
className: '',
|
||||
style: { maxHeight: `${maxHeight}px`, height: 'auto' }
|
||||
};
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
const sizeMap: Record<'small' | 'medium' | 'large' | 'xlarge', string> = {
|
||||
small: context === 'header' ? 'h-6 sm:h-8' : 'h-12 sm:h-14 lg:h-16',
|
||||
medium: context === 'header' ? 'h-8 sm:h-10 lg:h-12' : 'h-16 sm:h-20 lg:h-24',
|
||||
large: context === 'header' ? 'h-10 sm:h-12 lg:h-16' : 'h-20 sm:h-24 lg:h-32',
|
||||
xlarge: context === 'header' ? 'h-12 sm:h-16 lg:h-20' : 'h-24 sm:h-32 lg:h-40'
|
||||
};
|
||||
|
||||
return sizeMap[size] || sizeMap.medium;
|
||||
return {
|
||||
className: sizeMap[size as keyof typeof sizeMap] || sizeMap.medium,
|
||||
style: undefined
|
||||
};
|
||||
};
|
||||
|
||||
// Determine logo position classes
|
||||
@@ -109,6 +113,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
return displayMode !== 'logo_only';
|
||||
};
|
||||
|
||||
const headerLogoSize = getLogoDimensions('header');
|
||||
const heroLogoSize = getLogoDimensions('hero');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
@@ -183,8 +190,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${typeof getLogoSizeClass('header') === 'string' ? getLogoSizeClass('header') : ''} w-auto object-contain`}
|
||||
style={typeof getLogoSizeClass('header') === 'object' ? getLogoSizeClass('header') : undefined}
|
||||
className={`${headerLogoSize.className} w-auto object-contain`}
|
||||
style={headerLogoSize.style}
|
||||
/>
|
||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
|
||||
@@ -353,11 +360,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${typeof getLogoSizeClass('hero') === 'string' ? getLogoSizeClass('hero') : ''} w-auto object-contain mx-auto`}
|
||||
style={typeof getLogoSizeClass('hero') === 'object' ?
|
||||
{ ...getLogoSizeClass('hero'), filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' } :
|
||||
{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }
|
||||
}
|
||||
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||
style={{
|
||||
...(heroLogoSize.style || {}),
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
}}
|
||||
/>
|
||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
<div className="mt-3 text-xl sm:text-2xl font-semibold text-white/90" style={{ textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)' }}>
|
||||
|
||||
@@ -21,7 +21,6 @@ interface GallerySidebarProps {
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
isDownloading: boolean;
|
||||
isExpired?: boolean;
|
||||
allowDownloads?: boolean;
|
||||
photoCounts?: Record<number, number>;
|
||||
totalPhotos: number;
|
||||
@@ -52,7 +51,6 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
isDownloading,
|
||||
isExpired = false,
|
||||
allowDownloads = true,
|
||||
photoCounts = {},
|
||||
totalPhotos,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
import type { Photo } from '../../types';
|
||||
|
||||
@@ -462,7 +461,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
isExpired={isExpired}
|
||||
allowDownloads={allowDownloads}
|
||||
photoCounts={photoCounts}
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
@@ -473,7 +471,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
filterType={filterType}
|
||||
onFilterChange={setFilterType}
|
||||
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
|
||||
likeCount={data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0}
|
||||
ratedCount={data?.photos?.filter(p => (p.total_ratings || 0) > 0).length || 0}
|
||||
/>
|
||||
) : null}
|
||||
@@ -486,7 +484,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
showDownloadAll={!showSidebar && allowDownloads}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
isExpired={isExpired}
|
||||
menuButton={showSidebar ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -147,7 +147,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
error={errors.guest_name}
|
||||
size="sm"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
@@ -155,7 +154,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
error={errors.guest_email}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -73,7 +73,10 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
submitFavoriteMutation.mutate(savedIdentity || {});
|
||||
const identityPayload = savedIdentity
|
||||
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
|
||||
: {};
|
||||
submitFavoriteMutation.mutate(identityPayload);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { PhotoRating } from './PhotoRating';
|
||||
import { PhotoLikes } from './PhotoLikes';
|
||||
import { PhotoComments } from './PhotoComments';
|
||||
import { Skeleton } from '../common';
|
||||
import type { FeedbackSettings } from '../../services/feedback.service';
|
||||
|
||||
interface PhotoFeedbackProps {
|
||||
photoId: string;
|
||||
@@ -33,7 +32,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
||||
});
|
||||
|
||||
// Fetch feedback data for the photo
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||
const { data: feedbackData } = useQuery({
|
||||
queryKey: ['photo-feedback', gallerySlug, photoId],
|
||||
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
||||
enabled: !!settings?.feedback_enabled,
|
||||
|
||||
@@ -100,7 +100,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
|
||||
try {
|
||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
||||
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
} finally {
|
||||
@@ -191,6 +191,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,6 +223,7 @@ interface PhotoThumbnailProps {
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
slug: string; // Add slug as required prop
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
@@ -233,7 +235,8 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
slug
|
||||
slug,
|
||||
feedbackEnabled = false
|
||||
}) => {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
@@ -280,18 +283,18 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
/>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{feedbackEnabled && (photo.has_feedback || photo.average_rating > 0 || photo.comment_count > 0) && (
|
||||
{feedbackEnabled && (photo.has_feedback || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{photo.comment_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.average_rating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
try {
|
||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
||||
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
} finally {
|
||||
|
||||
@@ -432,12 +432,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}}
|
||||
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Toggle feedback"
|
||||
title={`Photo feedback${currentPhoto.comment_count > 0 ? ` (${currentPhoto.comment_count} comments)` : ''}`}
|
||||
title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
{(currentPhoto.comment_count > 0 || currentPhoto.average_rating > 0) && (
|
||||
{((currentPhoto.comment_count ?? 0) > 0 || (currentPhoto.average_rating ?? 0) > 0) && (
|
||||
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{currentPhoto.comment_count > 0 ? currentPhoto.comment_count : '★'}
|
||||
{(currentPhoto.comment_count ?? 0) > 0 ? currentPhoto.comment_count ?? 0 : '★'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -529,7 +529,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
<div className="p-4 flex-1 overflow-y-auto">
|
||||
<PhotoFeedback
|
||||
photoId={currentPhoto.id}
|
||||
photoId={String(currentPhoto.id)}
|
||||
gallerySlug={slug}
|
||||
showComments={true}
|
||||
className="space-y-4"
|
||||
|
||||
@@ -73,7 +73,10 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
submitLikeMutation.mutate(savedIdentity || {});
|
||||
const identityPayload = savedIdentity
|
||||
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
|
||||
: {};
|
||||
submitLikeMutation.mutate(identityPayload);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
: animationType === 'fade'
|
||||
? 'transition-opacity duration-300'
|
||||
: '';
|
||||
const likeCount = photo.like_count ?? 0;
|
||||
const averageRating = photo.average_rating ?? 0;
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,7 +103,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
detectDevTools={protectionLevel === 'maximum'}
|
||||
watermarkText={useEnhancedProtection ? 'Protected' : undefined}
|
||||
onProtectionViolation={(violationType) => {
|
||||
onProtectionViolation={(violationType: string) => {
|
||||
console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`);
|
||||
}}
|
||||
/>
|
||||
@@ -126,7 +130,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{onQuickComment && (
|
||||
{showFeedbackActions && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||
@@ -137,7 +141,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
</button>
|
||||
)}
|
||||
{/* Quick feedback actions */}
|
||||
{feedbackOptions?.allowLikes && (
|
||||
{showFeedbackActions && feedbackOptions?.allowLikes && (
|
||||
<button
|
||||
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
@@ -189,19 +193,19 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */}
|
||||
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0 || liked) && (
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}>
|
||||
{(photo.like_count > 0 || liked) && (
|
||||
{(likeCount > 0 || liked) && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{photo.average_rating > 0 && (
|
||||
{averageRating > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{photo.comment_count > 0 && (
|
||||
{commentCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
</span>
|
||||
|
||||
@@ -269,19 +269,19 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
|
||||
{(photo.like_count > 0 || likedIds.has(photo.id) || (photo.average_rating || 0) > 0 || (photo.comment_count || 0) > 0) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id) || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
|
||||
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{(photo.average_rating || 0) > 0 && (
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
|
||||
</span>
|
||||
)}
|
||||
{(photo.comment_count || 0) > 0 && (
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-blue-600 fill-current"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>
|
||||
</span>
|
||||
|
||||
@@ -72,24 +72,24 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
/>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
||||
{feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{photo.comment_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.average_rating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.like_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.like_count} likes`}>
|
||||
{(photo.like_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.like_count ?? 0} likes`}>
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.like_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.like_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -129,7 +129,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Feedback Indicators (bottom-left) */}
|
||||
{(photo.like_count > 0 || likedLocal) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedLocal) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
@@ -298,6 +298,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
className=""
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
|
||||
@@ -182,7 +182,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ProtectionLevel } from './useImageProtection';
|
||||
|
||||
type VendorStyle = CSSStyleDeclaration & {
|
||||
webkitUserSelect?: string;
|
||||
webkitTouchCallout?: string;
|
||||
webkitUserDrag?: string;
|
||||
};
|
||||
|
||||
interface UseCSSProtectionOptions {
|
||||
enabled: boolean;
|
||||
protectionLevel: ProtectionLevel;
|
||||
@@ -63,19 +69,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
|
||||
// Apply inline styles for enhanced protection
|
||||
if (options.protectionLevel === 'enhanced' || options.protectionLevel === 'maximum') {
|
||||
// Disable various browser features
|
||||
container.style.userSelect = 'none';
|
||||
container.style.webkitUserSelect = 'none';
|
||||
container.style.webkitTouchCallout = 'none';
|
||||
container.style.webkitUserDrag = 'none';
|
||||
const containerStyle = container.style as VendorStyle;
|
||||
containerStyle.userSelect = 'none';
|
||||
containerStyle.webkitUserSelect = 'none';
|
||||
containerStyle.webkitTouchCallout = 'none';
|
||||
containerStyle.webkitUserDrag = 'none';
|
||||
|
||||
// Find all img and canvas elements and protect them
|
||||
const mediaElements = container.querySelectorAll('img, canvas');
|
||||
mediaElements.forEach(element => {
|
||||
(element as HTMLElement).draggable = false;
|
||||
(element as HTMLElement).style.userSelect = 'none';
|
||||
(element as HTMLElement).style.webkitUserSelect = 'none';
|
||||
(element as HTMLElement).style.webkitUserDrag = 'none';
|
||||
(element as HTMLElement).style.webkitTouchCallout = 'none';
|
||||
const el = element as HTMLElement;
|
||||
const elStyle = el.style as VendorStyle;
|
||||
el.draggable = false;
|
||||
elStyle.userSelect = 'none';
|
||||
elStyle.webkitUserSelect = 'none';
|
||||
elStyle.webkitUserDrag = 'none';
|
||||
elStyle.webkitTouchCallout = 'none';
|
||||
|
||||
if (options.protectionLevel === 'maximum') {
|
||||
(element as HTMLElement).style.pointerEvents = 'none';
|
||||
@@ -101,19 +110,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
|
||||
}
|
||||
|
||||
// Reset inline styles
|
||||
container.style.userSelect = '';
|
||||
container.style.webkitUserSelect = '';
|
||||
container.style.webkitTouchCallout = '';
|
||||
container.style.webkitUserDrag = '';
|
||||
const containerStyle = container.style as VendorStyle;
|
||||
containerStyle.userSelect = '';
|
||||
containerStyle.webkitUserSelect = '';
|
||||
containerStyle.webkitTouchCallout = '';
|
||||
containerStyle.webkitUserDrag = '';
|
||||
|
||||
// Reset media element styles
|
||||
const mediaElements = container.querySelectorAll('img, canvas');
|
||||
mediaElements.forEach(element => {
|
||||
(element as HTMLElement).style.userSelect = '';
|
||||
(element as HTMLElement).style.webkitUserSelect = '';
|
||||
(element as HTMLElement).style.webkitUserDrag = '';
|
||||
(element as HTMLElement).style.webkitTouchCallout = '';
|
||||
(element as HTMLElement).style.pointerEvents = '';
|
||||
const el = element as HTMLElement;
|
||||
const elStyle = el.style as VendorStyle;
|
||||
elStyle.userSelect = '';
|
||||
elStyle.webkitUserSelect = '';
|
||||
elStyle.webkitUserDrag = '';
|
||||
elStyle.webkitTouchCallout = '';
|
||||
elStyle.pointerEvents = '';
|
||||
});
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -11,7 +11,6 @@ interface UseDevToolsProtectionOptions {
|
||||
export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => {
|
||||
const detectionTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastConsoleCountRef = useRef(0);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
const isDetectedRef = useRef(false);
|
||||
|
||||
const handleDevToolsDetected = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
type VendorStyle = CSSStyleDeclaration & {
|
||||
webkitUserSelect?: string;
|
||||
webkitTouchCallout?: string;
|
||||
webkitUserDrag?: string;
|
||||
MozAppearance?: string;
|
||||
webkitAppearance?: string;
|
||||
};
|
||||
|
||||
export type ProtectionLevel = 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
|
||||
interface UseImageProtectionOptions {
|
||||
@@ -16,9 +24,9 @@ interface UseImageProtectionOptions {
|
||||
}
|
||||
|
||||
export const useImageProtection = (options: UseImageProtectionOptions) => {
|
||||
const elementRef = useRef<HTMLImageElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const elementRef = useRef<HTMLImageElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const printScreenDetectorRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const printScreenIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -195,25 +203,26 @@ export const useImageProtection = (options: UseImageProtectionOptions) => {
|
||||
}
|
||||
|
||||
// CSS protection
|
||||
element.style.userSelect = 'none';
|
||||
element.style.webkitUserSelect = 'none';
|
||||
element.style.webkitTouchCallout = 'none';
|
||||
element.style.pointerEvents = 'auto';
|
||||
element.style.webkitUserDrag = 'none';
|
||||
element.style.webkitTouchCallout = 'none';
|
||||
const elementStyle = element.style as VendorStyle;
|
||||
elementStyle.userSelect = 'none';
|
||||
elementStyle.webkitUserSelect = 'none';
|
||||
elementStyle.webkitTouchCallout = 'none';
|
||||
elementStyle.pointerEvents = 'auto';
|
||||
elementStyle.webkitUserDrag = 'none';
|
||||
element.draggable = false;
|
||||
|
||||
// Enhanced CSS protection
|
||||
if (protectionLevel !== 'basic') {
|
||||
element.style.outline = 'none';
|
||||
element.style.webkitAppearance = 'none';
|
||||
element.style.MozAppearance = 'none';
|
||||
elementStyle.outline = 'none';
|
||||
elementStyle.webkitAppearance = 'none';
|
||||
elementStyle.MozAppearance = 'none';
|
||||
|
||||
// Disable text selection on parent elements
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
parent.style.userSelect = 'none';
|
||||
parent.style.webkitUserSelect = 'none';
|
||||
const parentStyle = parent.style as VendorStyle;
|
||||
parentStyle.userSelect = 'none';
|
||||
parentStyle.webkitUserSelect = 'none';
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,12 +770,66 @@
|
||||
"dateFormat": "Datumsformat",
|
||||
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
|
||||
},
|
||||
"publicSite": {
|
||||
"tabLabel": "Öffentliche Seite",
|
||||
"badge": "Öffentliche Landingpage",
|
||||
"title": "Öffentliche Landingpage",
|
||||
"subtitle": "Veröffentlichen Sie eine anpassbare Landingpage für Gäste, die Ihre Domain besuchen.",
|
||||
"loading": "Öffentliche Seite wird geladen...",
|
||||
"enabled": "Aktiv",
|
||||
"disabled": "Inaktiv",
|
||||
"htmlLabel": "Landingpage-HTML",
|
||||
"htmlPlaceholder": "HTML-Markup für Hero, Abschnitte und Handlungsaufrufe einfügen.",
|
||||
"htmlHelp": "Rohes HTML wird beim Speichern bereinigt. Unsichere Skripte und Attribute werden automatisch entfernt. Platzhalter wie {{company_name}}, {{company_tagline}} und {{support_email}} werden mit den Werten aus den Branding-Einstellungen ersetzt.",
|
||||
"cssLabel": "Benutzerdefiniertes CSS",
|
||||
"cssPlaceholder": "Optionale CSS-Anpassungen für Layout, Farben oder Typografie.",
|
||||
"cssHelp": "Import-Anweisungen und JavaScript-URLs werden entfernt. Verwenden Sie Standard-CSS-Selektoren.",
|
||||
"saveCta": "Öffentliche Seite speichern",
|
||||
"saving": "Speichern...",
|
||||
"saveSuccess": "Öffentliche Seite gespeichert.",
|
||||
"saveError": "Öffentliche Seite konnte nicht gespeichert werden.",
|
||||
"resetCta": "Auf Standard zurücksetzen",
|
||||
"resetting": "Setze zurück...",
|
||||
"resetSuccess": "Landingpage wurde auf die Standardvorlage zurückgesetzt.",
|
||||
"resetError": "Zurücksetzen auf Standard fehlgeschlagen.",
|
||||
"previewTitle": "Live-Vorschau",
|
||||
"previewSandboxed": "Sandbox-Vorschau",
|
||||
"previewDisabled": "Aktivieren Sie die öffentliche Seite, um die Live-Vorschau zu sehen.",
|
||||
"sanitizationNotice": "Skripte, iframes und unsichere Attribute werden vor der Veröffentlichung entfernt. Erlaubt sind sichere HTML-Elemente, Links und Bilder.",
|
||||
"htmlRequired": "Legen Sie HTML-Inhalt fest, bevor die öffentliche Seite aktiviert wird."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
"overview": "Speicherübersicht",
|
||||
"totalUsed": "Gesamt verwendet",
|
||||
"archiveStorage": "Archivspeicher",
|
||||
"storageLimit": "Speicherlimit",
|
||||
"storageLimitHelper": "Definieren Sie ein Limit für die Speichernutzung. Dieses Limit dient als Hinweis und wird hervorgehoben, sobald es erreicht ist.",
|
||||
"softLimitInputLabel": "Limit (GB)",
|
||||
"softLimitHelper": "Geben Sie das gewünschte maximale Speicherlimit in Gigabyte ein.",
|
||||
"recommendedSoftLimit": "Empfohlenes Limit",
|
||||
"diskCapacity": "Festplattenkapazität",
|
||||
"diskCapacityReported": "Festplattenkapazität (gemeldet)",
|
||||
"diskAvailable": "Verfügbar",
|
||||
"diskAvailableReported": "Verfügbar (gemeldet)",
|
||||
"diskFree": "Frei",
|
||||
"diskFreeReported": "Frei (gemeldet)",
|
||||
"diskMetricsUnavailable": "Speicherinformationen sind in Docker Desktop oder virtuellen Umgebungen nicht verfügbar.",
|
||||
"applyRecommended": "Empfehlung nutzen",
|
||||
"applyAvailable": "An verfügbaren Speicher anpassen",
|
||||
"invalidSoftLimit": "Bitte geben Sie eine gültige Zahl für das Limit ein.",
|
||||
"saveSoftLimit": "Limit speichern",
|
||||
"limitNotEnforced": "Dieses Limit dient nur als Hinweis und wird nicht automatisch durchgesetzt.",
|
||||
"overrideTitle": "Manuelle Kapazitätsüberschreibung",
|
||||
"diskOverrideEnvNote": "Die Speicherkapazität wird über Umgebungsvariablen gesteuert und kann hier nicht geändert werden.",
|
||||
"diskOverrideSettingsHelp": "Setzen Sie eigene Werte, wenn Docker unrealistische Zahlen meldet.",
|
||||
"overrideCapacityLabel": "Gesamtkapazität (GB)",
|
||||
"overrideCapacityHelper": "Leer lassen, um die automatische Erkennung zu verwenden.",
|
||||
"overrideAvailableLabel": "Verfügbarer Speicher (GB)",
|
||||
"overrideAvailableHelper": "Optional. Leer lassen, um aus Gesamtkapazität minus belegtem Speicher zu berechnen.",
|
||||
"saveOverride": "Kapazität speichern",
|
||||
"capacityRequiredForAvailable": "Geben Sie zuerst eine Gesamtkapazität an, bevor Sie verfügbaren Speicher setzen.",
|
||||
"availableExceedsCapacity": "Der verfügbare Speicher darf die Gesamtkapazität nicht überschreiten.",
|
||||
"storageUsage": "Speichernutzung",
|
||||
"storageByEvent": "Speicher nach Veranstaltung",
|
||||
"storageManagement": "Speicherverwaltung",
|
||||
@@ -988,7 +1042,9 @@
|
||||
"activeGalleries": "Aktive Galerien",
|
||||
"storageUsed": "Speicher verwendet",
|
||||
"totalPhotos": "Gesamte Fotos",
|
||||
"storagePercent": "{{percent}}% von {{limit}}",
|
||||
"storagePercent": "{{percent}}% des Limits {{limit}}",
|
||||
"storageSoftLimitConfigured": "Konfiguriertes Limit: {{limit}}",
|
||||
"storageSoftLimitRecommended": "Empfohlenes Limit: {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archivierte Veranstaltungen",
|
||||
"systemHealth": "Systemstatus",
|
||||
|
||||
@@ -456,12 +456,66 @@
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
|
||||
},
|
||||
"publicSite": {
|
||||
"tabLabel": "Public Site",
|
||||
"badge": "Public Landing",
|
||||
"title": "Public Landing Page",
|
||||
"subtitle": "Publish a customized landing page for guests when they visit your domain.",
|
||||
"loading": "Loading public site settings...",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"htmlLabel": "Landing Page HTML",
|
||||
"htmlPlaceholder": "Provide the HTML markup for your landing page hero, sections, and calls to action.",
|
||||
"htmlHelp": "Raw HTML is sanitized on save. Unsafe tags, inline scripts, and disallowed attributes are removed automatically. Tokens such as {{company_name}}, {{company_tagline}}, and {{support_email}} will be substituted with values from Branding settings.",
|
||||
"cssLabel": "Custom CSS",
|
||||
"cssPlaceholder": "Optional CSS overrides to adjust layout, colors, or typography.",
|
||||
"cssHelp": "Imports and JavaScript URLs are stripped. Use standard CSS selectors to override the default styling.",
|
||||
"saveCta": "Save public site",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Public site settings saved.",
|
||||
"saveError": "Failed to save public site settings.",
|
||||
"resetCta": "Reset to default",
|
||||
"resetting": "Resetting...",
|
||||
"resetSuccess": "Public site template reset to defaults.",
|
||||
"resetError": "Failed to reset public site template.",
|
||||
"previewTitle": "Live Preview",
|
||||
"previewSandboxed": "Sandboxed preview",
|
||||
"previewDisabled": "Enable the public site to see the live preview.",
|
||||
"sanitizationNotice": "Scripts, embedded frames, and unsafe attributes are removed before publishing. Only safe HTML, links, and images are allowed.",
|
||||
"htmlRequired": "Provide HTML content before enabling the public site."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"overview": "Storage Overview",
|
||||
"totalUsed": "Total Used",
|
||||
"archiveStorage": "Archive Storage",
|
||||
"storageLimit": "Storage Limit",
|
||||
"storageLimitHelper": "Set a limit for photo storage usage. This limit is advisory and will highlight when reached.",
|
||||
"softLimitInputLabel": "Limit (GB)",
|
||||
"softLimitHelper": "Enter the preferred maximum storage usage in gigabytes.",
|
||||
"recommendedSoftLimit": "Suggested Limit",
|
||||
"diskCapacity": "Disk Capacity",
|
||||
"diskCapacityReported": "Disk Capacity (reported)",
|
||||
"diskAvailable": "Available",
|
||||
"diskAvailableReported": "Available (reported)",
|
||||
"diskFree": "Free",
|
||||
"diskFreeReported": "Free (reported)",
|
||||
"diskMetricsUnavailable": "Disk metrics are not available in Docker Desktop or virtualized environments.",
|
||||
"applyRecommended": "Use recommended",
|
||||
"applyAvailable": "Match available",
|
||||
"invalidSoftLimit": "Enter a valid number for the limit.",
|
||||
"saveSoftLimit": "Save limit",
|
||||
"limitNotEnforced": "This limit is advisory only and is not enforced automatically.",
|
||||
"overrideTitle": "Manual capacity override",
|
||||
"diskOverrideEnvNote": "Disk capacity is controlled by environment variables and cannot be edited here.",
|
||||
"diskOverrideSettingsHelp": "Set custom capacity values when Docker reports unrealistic figures.",
|
||||
"overrideCapacityLabel": "Total capacity (GB)",
|
||||
"overrideCapacityHelper": "Leave blank to use the automatic reading.",
|
||||
"overrideAvailableLabel": "Available space (GB)",
|
||||
"overrideAvailableHelper": "Optional. Leave blank to calculate from total capacity minus used storage.",
|
||||
"saveOverride": "Save capacity override",
|
||||
"capacityRequiredForAvailable": "Enter a total capacity before setting available space.",
|
||||
"availableExceedsCapacity": "Available space cannot exceed the total capacity.",
|
||||
"storageUsage": "Storage Usage",
|
||||
"storageByEvent": "Storage by Event",
|
||||
"storageManagement": "Storage Management",
|
||||
@@ -732,7 +786,9 @@
|
||||
"activeGalleries": "Active Galleries",
|
||||
"storageUsed": "Storage Used",
|
||||
"totalPhotos": "Total Photos",
|
||||
"storagePercent": "{{percent}}% of {{limit}}",
|
||||
"storagePercent": "{{percent}}% of limit {{limit}}",
|
||||
"storageSoftLimitConfigured": "Configured limit: {{limit}}",
|
||||
"storageSoftLimitRecommended": "Suggested limit: {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archived Events",
|
||||
"systemHealth": "System Health",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { format, parseISO } from 'date-fns';
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
@@ -72,6 +73,11 @@ export const AnalyticsPage: React.FC = () => {
|
||||
queryFn: () => adminService.getDashboardStats(),
|
||||
});
|
||||
|
||||
const { data: storageInfo } = useQuery({
|
||||
queryKey: ['storage-info'],
|
||||
queryFn: () => settingsService.getStorageInfo(),
|
||||
});
|
||||
|
||||
// Fetch Umami config from admin settings since we're in admin panel
|
||||
useEffect(() => {
|
||||
const fetchUmamiConfig = async () => {
|
||||
@@ -154,12 +160,12 @@ export const AnalyticsPage: React.FC = () => {
|
||||
|
||||
// Get actual download data for top galleries - sort by downloads
|
||||
const topGalleriesWithDownloads = apiData.topGalleries
|
||||
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
|
||||
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
|
||||
.filter(gallery => (gallery.downloads ?? 0) > 0) // Only show galleries with downloads
|
||||
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)) // Sort by downloads
|
||||
.slice(0, 5) // Take top 5
|
||||
.map(gallery => ({
|
||||
name: gallery.event_name,
|
||||
downloads: gallery.downloads || 0
|
||||
downloads: gallery.downloads ?? 0
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -429,38 +435,68 @@ export const AnalyticsPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Storage Information */}
|
||||
{dashboardStats && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('analytics.used')}</span>
|
||||
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
|
||||
{dashboardStats && (() => {
|
||||
const softLimitBytes = storageInfo?.storage_soft_limit ?? storageInfo?.storage_limit ?? storageInfo?.recommended_soft_limit ?? null;
|
||||
const safeSoftLimit = Math.max(
|
||||
softLimitBytes ?? storageInfo?.recommended_soft_limit ?? (dashboardStats.storageUsed || 1),
|
||||
1
|
||||
);
|
||||
const usageRatio = dashboardStats.storageUsed / safeSoftLimit;
|
||||
const usagePercent = Math.round(usageRatio * 100);
|
||||
const usageWidth = Math.min(usageRatio * 100, 100);
|
||||
const overSoftLimit = softLimitBytes != null && dashboardStats.storageUsed >= softLimitBytes;
|
||||
const limitDisplay = softLimitBytes != null
|
||||
? adminService.formatBytes(softLimitBytes)
|
||||
: storageInfo?.recommended_soft_limit != null
|
||||
? adminService.formatBytes(storageInfo.recommended_soft_limit)
|
||||
: t('settings.storage.unlimited');
|
||||
const progressColor = overSoftLimit
|
||||
? 'bg-red-600'
|
||||
: usagePercent >= 90
|
||||
? 'bg-amber-500'
|
||||
: 'bg-primary-600';
|
||||
const limitDescriptor = storageInfo
|
||||
? storageInfo.soft_limit_configured
|
||||
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
|
||||
: t('admin.storageSoftLimitRecommended', { limit: limitDisplay })
|
||||
: t('admin.storageSoftLimitRecommended', { limit: limitDisplay });
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('analytics.used')}</span>
|
||||
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className={`${progressColor} h-2 rounded-full transition-all`}
|
||||
style={{ width: `${usageWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{usagePercent}% {t('analytics.of')} {limitDisplay}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${overSoftLimit ? 'text-red-600 font-semibold' : 'text-red-500 font-medium'}`}>
|
||||
{limitDescriptor}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{Math.round((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100)}% {t('analytics.of')} 10 GB
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
|
||||
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
|
||||
<span className="font-medium">{dashboardStats.activeEvents}</span>
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
|
||||
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
|
||||
<span className="font-medium">{dashboardStats.activeEvents}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupManagement: ComponentType<any>;
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, FileText, Globe } from 'lucide-react';
|
||||
import { FileText, Globe, Sparkles, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
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';
|
||||
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
|
||||
|
||||
export const CMSPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,6 +17,11 @@ export const CMSPage: React.FC = () => {
|
||||
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
|
||||
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
|
||||
const [publicSiteHtml, setPublicSiteHtml] = useState('');
|
||||
const [publicSiteCss, setPublicSiteCss] = useState('');
|
||||
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
|
||||
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
|
||||
|
||||
// Fetch CMS pages
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
@@ -22,6 +29,33 @@ export const CMSPage: React.FC = () => {
|
||||
queryFn: cmsService.getPages,
|
||||
});
|
||||
|
||||
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
|
||||
queryKey: ['public-site-defaults'],
|
||||
queryFn: () => settingsService.getPublicSiteDefaults(),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (publicSiteDefaults) {
|
||||
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
|
||||
setPublicSiteBranding(publicSiteDefaults.branding);
|
||||
}
|
||||
}, [publicSiteDefaults]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!adminSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
|
||||
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
|
||||
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
|
||||
}, [adminSettings]);
|
||||
|
||||
// Update page mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||
@@ -62,6 +96,53 @@ export const CMSPage: React.FC = () => {
|
||||
setEditForm(prev => ({ ...prev, [field]: title }));
|
||||
};
|
||||
|
||||
const publicSiteSaveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmedHtml = publicSiteHtml.trim();
|
||||
if (publicSiteEnabled && !trimmedHtml) {
|
||||
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
|
||||
}
|
||||
|
||||
await settingsService.updatePublicSite({
|
||||
enabled: publicSiteEnabled,
|
||||
html: trimmedHtml || '',
|
||||
css: publicSiteCss,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(t('settings.publicSite.saveSuccess'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
|
||||
toast.error(t('settings.publicSite.htmlRequired'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('settings.publicSite.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteResetMutation = useMutation({
|
||||
mutationFn: () => settingsService.resetPublicSite(),
|
||||
onSuccess: async (data) => {
|
||||
toast.success(t('settings.publicSite.resetSuccess'));
|
||||
setPublicSiteHtml(data.html || '');
|
||||
setPublicSiteCss(data.css || '');
|
||||
setPublicSiteBaseCss(data.baseCss || '');
|
||||
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.publicSite.resetError'));
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -72,6 +153,131 @@ export const CMSPage: React.FC = () => {
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
|
||||
ALLOWED_TAGS: [
|
||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
|
||||
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
||||
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
|
||||
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
ADD_ATTR: ['data-*'],
|
||||
}), [publicSiteHtml]);
|
||||
|
||||
const sanitizeCss = (css: string) => {
|
||||
if (!css) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = css;
|
||||
const disallowedPatterns = [
|
||||
/@import[^;]+;?/gi,
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
const MAX_LENGTH = 100 * 1024;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized.trim();
|
||||
};
|
||||
|
||||
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
|
||||
|
||||
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
|
||||
if (!html || !branding) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const tokens: Record<string, string> = {
|
||||
company_name: branding.companyName || '',
|
||||
company_tagline: branding.companyTagline || '',
|
||||
support_email: branding.supportEmail || '',
|
||||
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
|
||||
brand_primary_hex: branding.colors.primary,
|
||||
brand_accent_hex: branding.colors.accent,
|
||||
brand_background_hex: branding.colors.background,
|
||||
brand_text_hex: branding.colors.text,
|
||||
};
|
||||
|
||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
|
||||
(_, key: string) => tokens[key] || '');
|
||||
};
|
||||
|
||||
const publicSitePreview = useMemo(() => {
|
||||
const branding = publicSiteBranding || publicSiteDefaults?.branding;
|
||||
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
|
||||
const inlineStyles = [
|
||||
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
|
||||
publicSiteBaseCss,
|
||||
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
|
||||
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
|
||||
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
|
||||
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
|
||||
|
||||
const displayName = branding?.companyName || 'Celebration Stories';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#collections">Collections</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#stories">Stories</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
${substitutedHtml}
|
||||
</main>
|
||||
<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-contact">
|
||||
<span>${support}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
|
||||
|
||||
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -79,6 +285,135 @@ export const CMSPage: React.FC = () => {
|
||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-primary-600 mb-1">
|
||||
<Globe className="w-5 h-5" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
|
||||
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={publicSiteEnabled}
|
||||
onChange={() => setPublicSiteEnabled((prev) => !prev)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{publicSiteLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[240px]">
|
||||
<Loading size="lg" text={t('settings.publicSite.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.htmlLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteHtml}
|
||||
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.htmlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.cssLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteCss}
|
||||
onChange={(event) => setPublicSiteCss(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.cssHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => publicSiteSaveMutation.mutate()}
|
||||
disabled={publicSiteSaveMutation.isPending}
|
||||
isLoading={publicSiteSaveMutation.isPending}
|
||||
>
|
||||
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => publicSiteResetMutation.mutate()}
|
||||
disabled={publicSiteResetMutation.isPending}
|
||||
isLoading={publicSiteResetMutation.isPending}
|
||||
>
|
||||
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
|
||||
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
|
||||
<p>{t('settings.publicSite.htmlHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
|
||||
{t('settings.publicSite.previewTitle')}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
|
||||
</div>
|
||||
{publicSiteEnabled ? (
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
|
||||
<iframe
|
||||
title="public-site-preview"
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full h-[480px] bg-white"
|
||||
srcDoc={publicSitePreview}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
|
||||
{t('settings.publicSite.previewDisabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { debounce } from 'lodash';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
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';
|
||||
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
|
||||
|
||||
export const CMSPageEnhanced: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +21,11 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
||||
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
|
||||
const [publicSiteHtml, setPublicSiteHtml] = useState('');
|
||||
const [publicSiteCss, setPublicSiteCss] = useState('');
|
||||
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
|
||||
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
|
||||
|
||||
// Fetch CMS pages
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
@@ -26,6 +33,16 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
queryFn: cmsService.getPages,
|
||||
});
|
||||
|
||||
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
|
||||
queryKey: ['public-site-defaults'],
|
||||
queryFn: () => settingsService.getPublicSiteDefaults(),
|
||||
});
|
||||
|
||||
// Update page mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||
@@ -45,6 +62,53 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteSaveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmedHtml = publicSiteHtml.trim();
|
||||
if (publicSiteEnabled && !trimmedHtml) {
|
||||
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
|
||||
}
|
||||
|
||||
await settingsService.updatePublicSite({
|
||||
enabled: publicSiteEnabled,
|
||||
html: trimmedHtml || '',
|
||||
css: publicSiteCss,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(t('settings.publicSite.saveSuccess'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
|
||||
toast.error(t('settings.publicSite.htmlRequired'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('settings.publicSite.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteResetMutation = useMutation({
|
||||
mutationFn: () => settingsService.resetPublicSite(),
|
||||
onSuccess: async (data) => {
|
||||
toast.success(t('settings.publicSite.resetSuccess'));
|
||||
setPublicSiteHtml(data.html || '');
|
||||
setPublicSiteCss(data.css || '');
|
||||
setPublicSiteBaseCss(data.baseCss || '');
|
||||
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.publicSite.resetError'));
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-save functionality
|
||||
const autoSave = useCallback(
|
||||
debounce(() => {
|
||||
@@ -59,6 +123,23 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
[hasUnsavedChanges, editForm, selectedPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicSiteDefaults) {
|
||||
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
|
||||
setPublicSiteBranding(publicSiteDefaults.branding);
|
||||
}
|
||||
}, [publicSiteDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
|
||||
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
|
||||
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
|
||||
}, [adminSettings]);
|
||||
|
||||
// Trigger auto-save when content changes
|
||||
useEffect(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
@@ -113,6 +194,126 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
|
||||
ALLOWED_TAGS: [
|
||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
|
||||
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
||||
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
|
||||
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
ADD_ATTR: ['data-*'],
|
||||
}), [publicSiteHtml]);
|
||||
|
||||
const sanitizeCss = (css: string) => {
|
||||
if (!css) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = css;
|
||||
const disallowedPatterns = [
|
||||
/@import[^;]+;?/gi,
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
const MAX_LENGTH = 100 * 1024;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized.trim();
|
||||
};
|
||||
|
||||
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
|
||||
|
||||
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
|
||||
if (!html || !branding) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const tokens: Record<string, string> = {
|
||||
company_name: branding.companyName || '',
|
||||
company_tagline: branding.companyTagline || '',
|
||||
support_email: branding.supportEmail || '',
|
||||
};
|
||||
|
||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || '');
|
||||
};
|
||||
|
||||
const publicSitePreview = useMemo(() => {
|
||||
const branding = publicSiteBranding || publicSiteDefaults?.branding;
|
||||
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
|
||||
const inlineStyles = [
|
||||
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
|
||||
publicSiteBaseCss,
|
||||
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
|
||||
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
|
||||
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
|
||||
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
|
||||
|
||||
const displayName = branding?.companyName || 'Celebration Stories';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#collections">Collections</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#stories">Stories</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
${substitutedHtml}
|
||||
</main>
|
||||
<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-contact">
|
||||
<span>${support}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
|
||||
|
||||
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -121,8 +322,6 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -130,6 +329,135 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-primary-600 mb-1">
|
||||
<Globe className="w-5 h-5" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
|
||||
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={publicSiteEnabled}
|
||||
onChange={() => setPublicSiteEnabled((prev) => !prev)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{publicSiteLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[240px]">
|
||||
<Loading size="lg" text={t('settings.publicSite.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.htmlLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteHtml}
|
||||
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.htmlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.cssLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteCss}
|
||||
onChange={(event) => setPublicSiteCss(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.cssHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => publicSiteSaveMutation.mutate()}
|
||||
disabled={publicSiteSaveMutation.isPending}
|
||||
isLoading={publicSiteSaveMutation.isPending}
|
||||
>
|
||||
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => publicSiteResetMutation.mutate()}
|
||||
disabled={publicSiteResetMutation.isPending}
|
||||
isLoading={publicSiteResetMutation.isPending}
|
||||
>
|
||||
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
|
||||
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
|
||||
<p>{t('settings.publicSite.htmlHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
|
||||
{t('settings.publicSite.previewTitle')}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
|
||||
</div>
|
||||
{publicSiteEnabled ? (
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
|
||||
<iframe
|
||||
title="public-site-preview"
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full h-[480px] bg-white"
|
||||
srcDoc={publicSitePreview}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
|
||||
{t('settings.publicSite.previewDisabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
|
||||
@@ -69,7 +69,7 @@ const EVENT_TYPES = [
|
||||
|
||||
export const CreateEventPageEnhanced: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const isMountedRef = useRef(true);
|
||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||
|
||||
@@ -29,10 +29,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { externalMediaService } from '../../services/externalMedia.service';
|
||||
import { photosService, AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
||||
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -121,8 +120,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_moderation: true,
|
||||
show_public_stats: false
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
enable_rate_limiting: false,
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
});
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
@@ -136,10 +139,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
|
||||
// Photo filters state
|
||||
const [photoFilters, setPhotoFilters] = useState({
|
||||
const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({
|
||||
category_id: undefined as number | null | undefined,
|
||||
search: '',
|
||||
sort: 'date' as 'date' | 'name' | 'size' | 'rating',
|
||||
sort: 'date',
|
||||
order: 'desc' as 'asc' | 'desc'
|
||||
});
|
||||
|
||||
@@ -1004,9 +1007,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<PhotoFilters
|
||||
categories={categories}
|
||||
selectedCategory={photoFilters.category_id}
|
||||
searchTerm={photoFilters.search}
|
||||
sortBy={photoFilters.sort}
|
||||
sortOrder={photoFilters.order}
|
||||
searchTerm={photoFilters.search ?? ''}
|
||||
sortBy={photoFilters.sort ?? 'date'}
|
||||
sortOrder={photoFilters.order ?? 'desc'}
|
||||
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
|
||||
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
|
||||
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Download,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2
|
||||
@@ -25,7 +24,7 @@ import { FeedbackSettings } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import type { PhotoFeedback, FeedbackAnalytics } from '../../services/feedback.service';
|
||||
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
|
||||
|
||||
export const EventFeedbackPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -44,7 +43,7 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
// Fetch event details
|
||||
const { data: event, isLoading: eventLoading } = useQuery({
|
||||
queryKey: ['event', id],
|
||||
queryFn: () => eventsService.getEvent(id!),
|
||||
queryFn: () => eventsService.getEvent(Number(id)),
|
||||
enabled: !!id
|
||||
});
|
||||
|
||||
@@ -56,14 +55,14 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Fetch feedback list
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery<FeedbackResponse>({
|
||||
queryKey: ['event-feedback', id, feedbackFilter],
|
||||
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
|
||||
enabled: !!id && activeTab === 'feedback'
|
||||
});
|
||||
|
||||
// Fetch analytics
|
||||
const { data: analytics, isLoading: analyticsLoading } = useQuery({
|
||||
const { data: analytics, isLoading: analyticsLoading } = useQuery<FeedbackAnalytics>({
|
||||
queryKey: ['feedback-analytics', id],
|
||||
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
|
||||
enabled: !!id && activeTab === 'analytics'
|
||||
@@ -133,6 +132,10 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
return <div>{t('events.notFound', 'Event not found')}</div>;
|
||||
}
|
||||
|
||||
const pagination = feedbackData?.pagination;
|
||||
const perPage = pagination?.per_page ?? feedbackFilter.limit ?? 20;
|
||||
const totalPages = perPage ? Math.max(1, Math.ceil((pagination?.total ?? 0) / perPage)) : 1;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
@@ -360,7 +363,7 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{feedbackData?.pagination && feedbackData.pagination.pages > 1 && (
|
||||
{pagination && totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -373,13 +376,13 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
<span className="flex items-center px-3 text-sm text-neutral-600">
|
||||
{t('common.pageOf', 'Page {{current}} of {{total}}', {
|
||||
current: feedbackFilter.page,
|
||||
total: feedbackData.pagination.pages
|
||||
total: totalPages
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={feedbackFilter.page === feedbackData.pagination.pages}
|
||||
disabled={feedbackFilter.page >= totalPages}
|
||||
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
|
||||
>
|
||||
{t('common.next', 'Next')}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Download,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Users,
|
||||
Image,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
@@ -82,6 +84,12 @@ export const SettingsPage: React.FC = () => {
|
||||
umami_share_url: ''
|
||||
});
|
||||
|
||||
const [softLimitGb, setSoftLimitGb] = useState<number | ''>('');
|
||||
const [softLimitDirty, setSoftLimitDirty] = useState(false);
|
||||
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
||||
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
|
||||
const [overrideDirty, setOverrideDirty] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
// Set the language if it's different from current
|
||||
@@ -109,14 +117,15 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
// Extract security settings
|
||||
setSecuritySettings({
|
||||
require_password: settings.security_require_password || true,
|
||||
password_min_length: settings.security_password_min_length || 8,
|
||||
enable_2fa: settings.security_enable_2fa || false,
|
||||
session_timeout_minutes: settings.security_session_timeout_minutes || 60,
|
||||
max_login_attempts: settings.security_max_login_attempts || 5,
|
||||
enable_recaptcha: settings.security_enable_recaptcha || false,
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key || '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
|
||||
require_password: settings.security_require_password ?? true,
|
||||
password_min_length: settings.security_password_min_length ?? 8,
|
||||
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||
enable_2fa: settings.security_enable_2fa ?? false,
|
||||
session_timeout_minutes: settings.security_session_timeout_minutes ?? 60,
|
||||
max_login_attempts: settings.security_max_login_attempts ?? 5,
|
||||
enable_recaptcha: settings.security_enable_recaptcha ?? false,
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
});
|
||||
|
||||
// Extract analytics settings
|
||||
@@ -129,6 +138,66 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [settings, i18n]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!settings || overrideDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capacityOverrideBytes = settings.general_storage_capacity_override_bytes ?? null;
|
||||
const availableOverrideBytes = settings.general_storage_available_override_bytes ?? null;
|
||||
|
||||
setCapacityOverrideGb(
|
||||
capacityOverrideBytes != null
|
||||
? Number((capacityOverrideBytes / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
|
||||
setAvailableOverrideGb(
|
||||
availableOverrideBytes != null
|
||||
? Number((availableOverrideBytes / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
}, [settings, overrideDirty]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!storageInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (softLimitDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLimit = storageInfo.configured_soft_limit ?? storageInfo.storage_soft_limit ?? null;
|
||||
|
||||
if (currentLimit === null || currentLimit === undefined) {
|
||||
setSoftLimitGb('');
|
||||
return;
|
||||
}
|
||||
|
||||
const limitGb = Number((currentLimit / BYTES_PER_GB).toFixed(2));
|
||||
setSoftLimitGb(limitGb);
|
||||
}, [storageInfo, softLimitDirty]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!storageInfo || overrideDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (storageInfo.disk_override_source === 'env') {
|
||||
setCapacityOverrideGb(
|
||||
storageInfo.disk_total
|
||||
? Number((storageInfo.disk_total / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
setAvailableOverrideGb(
|
||||
storageInfo.disk_available
|
||||
? Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
}
|
||||
}, [storageInfo, overrideDirty]);
|
||||
|
||||
// Save mutations
|
||||
const saveGeneralMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -189,6 +258,103 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const saveSoftLimitMutation = useMutation({
|
||||
mutationFn: async (limitBytes: number | null) => {
|
||||
return settingsService.updateSettings({
|
||||
general_storage_soft_limit_bytes: limitBytes,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
setSoftLimitDirty(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleSaveSoftLimit = () => {
|
||||
if (saveSoftLimitMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (softLimitGb === '') {
|
||||
saveSoftLimitMutation.mutate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = Number(softLimitGb);
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
const limitBytes = Math.max(0, Math.round(numericValue * BYTES_PER_GB));
|
||||
saveSoftLimitMutation.mutate(limitBytes);
|
||||
};
|
||||
|
||||
const saveCapacityOverrideMutation = useMutation({
|
||||
mutationFn: async (payload: { capacity: number | null; available: number | null }) => {
|
||||
return settingsService.updateSettings({
|
||||
general_storage_capacity_override_bytes: payload.capacity,
|
||||
general_storage_available_override_bytes: payload.available,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
setOverrideDirty(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleSaveCapacityOverride = () => {
|
||||
if (saveCapacityOverrideMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (capacityOverrideGb === '' && availableOverrideGb !== '') {
|
||||
toast.error(t('settings.storage.capacityRequiredForAvailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const capacityValue = capacityOverrideGb === '' ? null : Number(capacityOverrideGb);
|
||||
const availableValue = availableOverrideGb === '' ? null : Number(availableOverrideGb);
|
||||
|
||||
if ((capacityValue !== null && !Number.isFinite(capacityValue)) || (availableValue !== null && !Number.isFinite(availableValue))) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (capacityValue !== null && capacityValue < 0) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableValue !== null && availableValue < 0) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
const capacityBytes = capacityValue === null ? null : Math.max(0, Math.round(capacityValue * BYTES_PER_GB));
|
||||
const availableBytes = availableValue === null ? null : Math.max(0, Math.round(availableValue * BYTES_PER_GB));
|
||||
|
||||
if (capacityBytes !== null && availableBytes !== null && availableBytes > capacityBytes) {
|
||||
toast.error(t('settings.storage.availableExceedsCapacity'));
|
||||
return;
|
||||
}
|
||||
|
||||
saveCapacityOverrideMutation.mutate({ capacity: capacityBytes, available: availableBytes });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -455,52 +621,298 @@ export const SettingsPage: React.FC = () => {
|
||||
{activeTab === 'status' && (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Overview */}
|
||||
{storageInfo && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5" />
|
||||
{t('settings.systemStatus.storageOverview')}
|
||||
</h2>
|
||||
{storageInfo && (() => {
|
||||
const configuredSoftLimit = storageInfo.configured_soft_limit ?? null;
|
||||
const effectiveSoftLimit = storageInfo.storage_soft_limit || storageInfo.storage_limit || storageInfo.recommended_soft_limit || 1;
|
||||
const safeEffectiveSoftLimit = Math.max(effectiveSoftLimit, 1);
|
||||
const usageRatio = storageInfo.total_used / safeEffectiveSoftLimit;
|
||||
const usagePercentage = Math.round(usageRatio * 100);
|
||||
const usageWidth = Math.min(usageRatio * 100, 100);
|
||||
const overSoftLimit = configuredSoftLimit != null
|
||||
? storageInfo.total_used >= configuredSoftLimit
|
||||
: usagePercentage >= 100;
|
||||
const limitDisplayBytes = configuredSoftLimit ?? storageInfo.storage_soft_limit ?? storageInfo.storage_limit ?? null;
|
||||
const limitDisplay = limitDisplayBytes != null
|
||||
? settingsService.formatBytes(limitDisplayBytes)
|
||||
: t('settings.storage.unlimited');
|
||||
const diskCapacityBytes = storageInfo.disk_total ?? storageInfo.disk_total_raw ?? null;
|
||||
const diskAvailableBytes = storageInfo.disk_available ?? storageInfo.disk_available_raw ?? null;
|
||||
const diskFreeBytes = storageInfo.disk_free ?? storageInfo.disk_free_raw ?? null;
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.archive_storage)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.storage_limit)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
const diskCapacityDisplay = diskCapacityBytes != null
|
||||
? settingsService.formatBytes(diskCapacityBytes)
|
||||
: null;
|
||||
const diskAvailableDisplay = diskAvailableBytes != null
|
||||
? settingsService.formatBytes(diskAvailableBytes)
|
||||
: null;
|
||||
const diskFreeDisplay = diskFreeBytes != null
|
||||
? settingsService.formatBytes(diskFreeBytes)
|
||||
: null;
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
||||
<span className="font-medium">
|
||||
{Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100)}%
|
||||
</span>
|
||||
const recommendedDisplay = storageInfo.recommended_soft_limit != null
|
||||
? settingsService.formatBytes(storageInfo.recommended_soft_limit)
|
||||
: null;
|
||||
const progressColor = overSoftLimit
|
||||
? 'bg-red-600'
|
||||
: usagePercentage >= 90
|
||||
? 'bg-amber-500'
|
||||
: 'bg-primary-600';
|
||||
const limitCardClass = overSoftLimit ? 'bg-amber-50 border border-amber-200' : 'bg-neutral-50';
|
||||
const limitValueClass = overSoftLimit ? 'text-amber-700' : 'text-neutral-900';
|
||||
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 font-semibold' : 'text-neutral-600';
|
||||
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
|
||||
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
|
||||
const overrideSource = storageInfo.disk_override_source;
|
||||
const overrideControlled = overrideSource === 'env';
|
||||
|
||||
const diskSummaryCards: Array<{ label: string; value: string }> = [];
|
||||
if (diskCapacityDisplay && (diskMetricsReliable || overrideSource)) {
|
||||
const label = storageInfo.disk_total != null
|
||||
? t('settings.storage.diskCapacity')
|
||||
: t('settings.storage.diskCapacityReported');
|
||||
diskSummaryCards.push({ label, value: diskCapacityDisplay });
|
||||
}
|
||||
if (diskAvailableDisplay && (diskMetricsReliable || overrideSource)) {
|
||||
const label = storageInfo.disk_available != null
|
||||
? t('settings.storage.diskAvailable')
|
||||
: t('settings.storage.diskAvailableReported');
|
||||
diskSummaryCards.push({ label, value: diskAvailableDisplay });
|
||||
}
|
||||
if (diskFreeDisplay && storageInfo.disk_free == null && (diskMetricsReliable || overrideSource)) {
|
||||
diskSummaryCards.push({
|
||||
label: t('settings.storage.diskFreeReported'),
|
||||
value: diskFreeDisplay
|
||||
});
|
||||
}
|
||||
if (recommendedDisplay) {
|
||||
diskSummaryCards.push({
|
||||
label: t('settings.storage.recommendedSoftLimit'),
|
||||
value: recommendedDisplay
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5" />
|
||||
{t('settings.systemStatus.storageOverview')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.archive_storage)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 ${limitCardClass}`}>
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
|
||||
<p className={`text-2xl font-bold ${limitValueClass}`}>
|
||||
{limitDisplay}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${limitDescriptorClass}`}>
|
||||
{storageInfo.soft_limit_configured
|
||||
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
|
||||
: t('admin.storageSoftLimitRecommended', { limit: recommendedDescriptorValue })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min((storageInfo.total_used / storageInfo.storage_limit) * 100, 100)}%`
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
||||
<span className={`font-medium ${overSoftLimit ? 'text-red-600' : 'text-neutral-900'}`}>
|
||||
{usagePercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||
<div
|
||||
className={`${progressColor} h-3 rounded-full transition-all`}
|
||||
style={{ width: `${usageWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('settings.storage.storageLimitHelper')}
|
||||
</p>
|
||||
|
||||
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{diskSummaryCards.map((card) => (
|
||||
<div key={card.label} className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-xs text-neutral-500 uppercase tracking-wide">{card.label}</p>
|
||||
<p className="text-lg font-semibold text-neutral-900 mt-1">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!diskMetricsReliable && !overrideSource && (
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t('settings.storage.diskMetricsUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)]">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={softLimitGb === '' ? '' : softLimitGb}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSoftLimitDirty(true);
|
||||
if (value === '') {
|
||||
setSoftLimitGb('');
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return;
|
||||
}
|
||||
setSoftLimitGb(numeric);
|
||||
}}
|
||||
label={t('settings.storage.softLimitInputLabel')}
|
||||
helperText={t('settings.storage.softLimitHelper')}
|
||||
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t('settings.storage.limitNotEnforced')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (storageInfo.recommended_soft_limit != null) {
|
||||
const value = Number((storageInfo.recommended_soft_limit / BYTES_PER_GB).toFixed(2));
|
||||
setSoftLimitGb(value);
|
||||
setSoftLimitDirty(true);
|
||||
}
|
||||
}}
|
||||
disabled={storageInfo.recommended_soft_limit == null}
|
||||
>
|
||||
{t('settings.storage.applyRecommended')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (storageInfo.disk_available != null) {
|
||||
const value = Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2));
|
||||
setSoftLimitGb(value);
|
||||
setSoftLimitDirty(true);
|
||||
}
|
||||
}}
|
||||
disabled={storageInfo.disk_available == null}
|
||||
>
|
||||
{t('settings.storage.applyAvailable')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveSoftLimit}
|
||||
isLoading={saveSoftLimitMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('settings.storage.saveSoftLimit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700">{t('settings.storage.overrideTitle')}</p>
|
||||
{overrideControlled ? (
|
||||
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideEnvNote')}</p>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideSettingsHelp')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={capacityOverrideGb === '' ? '' : capacityOverrideGb}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setOverrideDirty(true);
|
||||
if (value === '') {
|
||||
setCapacityOverrideGb('');
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return;
|
||||
}
|
||||
setCapacityOverrideGb(numeric);
|
||||
}}
|
||||
label={t('settings.storage.overrideCapacityLabel')}
|
||||
helperText={t('settings.storage.overrideCapacityHelper')}
|
||||
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||
disabled={overrideControlled}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={availableOverrideGb === '' ? '' : availableOverrideGb}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setOverrideDirty(true);
|
||||
if (value === '') {
|
||||
setAvailableOverrideGb('');
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return;
|
||||
}
|
||||
setAvailableOverrideGb(numeric);
|
||||
}}
|
||||
label={t('settings.storage.overrideAvailableLabel')}
|
||||
helperText={t('settings.storage.overrideAvailableHelper')}
|
||||
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||
disabled={overrideControlled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveCapacityOverride}
|
||||
isLoading={saveCapacityOverrideMutation.isPending}
|
||||
disabled={overrideControlled}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('settings.storage.saveOverride')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* System Information */}
|
||||
{systemStatus && (
|
||||
|
||||
@@ -32,7 +32,7 @@ export const PreviewPage: React.FC = () => {
|
||||
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 [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
|
||||
|
||||
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
|
||||
const mockEvent = {
|
||||
@@ -77,6 +77,8 @@ export const PreviewPage: React.FC = () => {
|
||||
return a.filename.localeCompare(b.filename);
|
||||
case 'size':
|
||||
return b.size - a.size;
|
||||
case 'rating':
|
||||
return 0;
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||
@@ -129,7 +131,7 @@ export const PreviewPage: React.FC = () => {
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
onSortChange={(sort) => setSortBy(sort)}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface AnalyticsData {
|
||||
event_name: string;
|
||||
slug: string;
|
||||
views: number;
|
||||
downloads?: number;
|
||||
uniqueVisitors?: number;
|
||||
}>;
|
||||
devices: {
|
||||
desktop: number;
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PhotoFeedback {
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
comment?: string;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
is_approved: boolean;
|
||||
@@ -29,6 +30,7 @@ export interface PhotoFeedback {
|
||||
updated_at?: string;
|
||||
filename?: string;
|
||||
path?: string;
|
||||
photo_filename?: string;
|
||||
is_mine?: boolean;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,11 @@ export interface FeedbackResponse {
|
||||
feedback: PhotoFeedback[];
|
||||
summary: FeedbackSummary;
|
||||
my_feedback: MyFeedback;
|
||||
pagination?: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FeedbackAnalytics {
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface PhotoFilters {
|
||||
category_id?: number | null;
|
||||
type?: string;
|
||||
search?: string;
|
||||
sort?: 'date' | 'name' | 'size';
|
||||
sort?: 'date' | 'name' | 'size' | 'rating';
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,6 @@ class SecureTokenService {
|
||||
* Clear expired tokens from cache
|
||||
*/
|
||||
clearExpiredTokens(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, entry] of this.tokenCache.entries()) {
|
||||
if (!this.isTokenValid(entry)) {
|
||||
this.tokenCache.delete(key);
|
||||
|
||||
@@ -53,6 +53,18 @@ export interface StorageInfo {
|
||||
size: number;
|
||||
}>;
|
||||
storage_limit: number;
|
||||
storage_soft_limit: number;
|
||||
configured_soft_limit: number | null;
|
||||
recommended_soft_limit: number | null;
|
||||
soft_limit_configured: boolean;
|
||||
disk_total: number | null;
|
||||
disk_free: number | null;
|
||||
disk_available: number | null;
|
||||
disk_total_raw: number | null;
|
||||
disk_free_raw: number | null;
|
||||
disk_available_raw: number | null;
|
||||
disk_metrics_reliable: boolean;
|
||||
disk_override_source: 'env' | 'settings' | null;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
@@ -102,6 +114,20 @@ export interface SystemStatus {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface PublicSiteBranding {
|
||||
companyName: string | null;
|
||||
companyTagline: string | null;
|
||||
supportEmail: string | null;
|
||||
logoUrl: string | null;
|
||||
footerText: string | null;
|
||||
colors: {
|
||||
primary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const settingsService = {
|
||||
// Get all settings
|
||||
async getAllSettings(): Promise<Record<string, any>> {
|
||||
@@ -120,6 +146,28 @@ export const settingsService = {
|
||||
await api.put('/admin/settings/branding', settings);
|
||||
},
|
||||
|
||||
async getPublicSiteDefaults(): Promise<{ html: string; css: string; baseCss: string; branding?: PublicSiteBranding; meta?: { title?: string } }> {
|
||||
const response = await api.get('/admin/settings/public-site/default');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resetPublicSite(): Promise<{ html: string; css: string; baseCss?: string; branding?: PublicSiteBranding }> {
|
||||
const response = await api.post('/admin/settings/public-site/reset');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updatePublicSite(settings: {
|
||||
enabled: boolean;
|
||||
html: string;
|
||||
css: string;
|
||||
}): Promise<void> {
|
||||
await api.put('/admin/settings/general', {
|
||||
general_public_site_enabled: settings.enabled,
|
||||
general_public_site_html: settings.html,
|
||||
general_public_site_custom_css: settings.css,
|
||||
});
|
||||
},
|
||||
|
||||
// Upload logo
|
||||
async uploadLogo(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface Event {
|
||||
total_views?: number;
|
||||
total_downloads?: number;
|
||||
unique_visitors?: number;
|
||||
source_mode?: 'managed' | 'reference' | string;
|
||||
external_path?: string | null;
|
||||
}
|
||||
|
||||
export interface GalleryInfo {
|
||||
|
||||
@@ -24,5 +24,8 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"src/**/__tests__/**"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user