Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b | |||
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d | |||
| cab5b0d795 | |||
| ba95aad3c6 | |||
| c1be7d6785 | |||
| 0024686dc2 | |||
| 96b8b77792 | |||
| 9d2726b3d3 | |||
| 8d6ddd257d | |||
| e0865b81b6 | |||
| d4404e39bd | |||
| 8611206396 | |||
| 39d2244e1e | |||
| eb626be22c | |||
| aaaf59817b | |||
| 2a4d38813f |
@@ -1,10 +1,7 @@
|
|||||||
name: Mirror to GitHub
|
name: Mirror to GitHub
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch: # Allow manual triggering only
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch: # Allow manual triggering
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mirror:
|
mirror:
|
||||||
@@ -132,3 +129,4 @@ jobs:
|
|||||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||||
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
||||||
echo "🔒 Sensitive files have been removed from the mirror"
|
echo "🔒 Sensitive files have been removed from the mirror"
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
name: Version and Release
|
name: Version and Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [ main ]
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
- '.gitea/**'
|
|
||||||
- '.drone.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version-bump:
|
version-bump:
|
||||||
@@ -72,8 +67,8 @@ jobs:
|
|||||||
echo "Root files changed: $ROOT_CHANGED"
|
echo "Root files changed: $ROOT_CHANGED"
|
||||||
|
|
||||||
# Get current versions
|
# Get current versions
|
||||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.1.0")
|
||||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.0")
|
||||||
|
|
||||||
echo "Current backend version: $BACKEND_VERSION"
|
echo "Current backend version: $BACKEND_VERSION"
|
||||||
echo "Current frontend version: $FRONTEND_VERSION"
|
echo "Current frontend version: $FRONTEND_VERSION"
|
||||||
@@ -133,10 +128,17 @@ jobs:
|
|||||||
MINOR="${version_parts[1]}"
|
MINOR="${version_parts[1]}"
|
||||||
PATCH="${version_parts[2]}"
|
PATCH="${version_parts[2]}"
|
||||||
|
|
||||||
# Increment patch version
|
# Increment patch version and ensure tag uniqueness
|
||||||
|
git fetch --tags --quiet || true
|
||||||
NEW_PATCH=$((PATCH + 1))
|
NEW_PATCH=$((PATCH + 1))
|
||||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||||
|
|
||||||
|
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
|
||||||
|
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
|
||||||
|
NEW_PATCH=$((NEW_PATCH + 1))
|
||||||
|
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||||
|
done
|
||||||
|
|
||||||
echo "New version: $NEW_VERSION"
|
echo "New version: $NEW_VERSION"
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||||
|
|||||||
+20
-1
@@ -201,6 +201,15 @@ openssl rand -base64 32 | tr -d '$'
|
|||||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
- 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)
|
### Backend Configuration (.env)
|
||||||
Update `.env` with:
|
Update `.env` with:
|
||||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||||
@@ -411,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
|
|||||||
|
|
||||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||||
|
|
||||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
|
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Native reinstall example
|
||||||
|
sudo ./setup.sh --native --force-admin-password-reset
|
||||||
|
|
||||||
|
# Docker reinstall example
|
||||||
|
sudo ./setup.sh --docker --force-admin-password-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||||
|
|
||||||
#### Configuring Admin Email
|
#### Configuring Admin Email
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
|||||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||||
|
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||||
|
|
||||||
### For Clients
|
### For Clients
|
||||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||||
@@ -89,6 +90,18 @@ Note on Docker file permissions (PUID/PGID)
|
|||||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
- 📋 [**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
|
## 🎯 Use Cases
|
||||||
|
|
||||||
Perfect for:
|
Perfect for:
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# Add build arguments
|
# Add build arguments
|
||||||
ARG CACHEBUST=1
|
ARG CACHEBUST=1
|
||||||
@@ -23,7 +23,7 @@ RUN npm ci --only=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const fsPromises = fs.promises;
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('Admin settings logo upload flow', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let router;
|
||||||
|
let app;
|
||||||
|
let settingsStore;
|
||||||
|
|
||||||
|
const resetModules = () => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
|
||||||
|
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||||
|
process.env.STORAGE_PATH = tmpDir;
|
||||||
|
|
||||||
|
settingsStore = new Map();
|
||||||
|
|
||||||
|
const buildQuery = (table) => {
|
||||||
|
const filters = [];
|
||||||
|
const applyFilters = (rows) => {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((row) =>
|
||||||
|
filters.every(({ column, value }) => row[column] === value)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRow = (row) => ({ ...row });
|
||||||
|
|
||||||
|
return {
|
||||||
|
where(column, value) {
|
||||||
|
filters.push({ column, value });
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
first() {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||||
|
return Promise.resolve(rows[0]);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
select() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
sum() {
|
||||||
|
return Promise.resolve({ total: 0 });
|
||||||
|
},
|
||||||
|
join() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
groupBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
orderBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
limit() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
insert(payload) {
|
||||||
|
const rows = Array.isArray(payload) ? payload : [payload];
|
||||||
|
const upsert = (row, overrides = {}) => {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const key = row.setting_key;
|
||||||
|
const existing = settingsStore.get(key) || {};
|
||||||
|
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onConflict() {
|
||||||
|
return {
|
||||||
|
merge(overrides) {
|
||||||
|
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbMock = jest.fn((table) => buildQuery(table));
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.transaction = async (handler) => handler({
|
||||||
|
commit: async () => {},
|
||||||
|
rollback: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock('../src/database/db', () => ({
|
||||||
|
db: dbMock,
|
||||||
|
logActivity: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/auth', () => ({
|
||||||
|
adminAuth: (req, res, next) => {
|
||||||
|
req.admin = { id: 1, username: 'tester' };
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/publicSiteService', () => ({
|
||||||
|
clearPublicSiteCache: jest.fn(),
|
||||||
|
getDefaultPublicSitePayload: jest.fn(),
|
||||||
|
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/rateLimitService', () => ({
|
||||||
|
clearSettingsCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/maintenance', () => ({
|
||||||
|
maintenanceMiddleware: (req, res, next) => next(),
|
||||||
|
clearMaintenanceCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
router = require('../src/routes/adminSettings');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/settings', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
if (tmpDir) {
|
||||||
|
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = null;
|
||||||
|
}
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||||
|
const fileBuffer = Buffer.from('fake image data');
|
||||||
|
|
||||||
|
const uploadResponse = await request(app)
|
||||||
|
.post('/api/admin/settings/logo')
|
||||||
|
.attach('logo', fileBuffer, 'logo.png');
|
||||||
|
|
||||||
|
expect(uploadResponse.status).toBe(200);
|
||||||
|
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||||
|
const logoUrl = uploadResponse.body.logoUrl;
|
||||||
|
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||||
|
|
||||||
|
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||||
|
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/settings/branding')
|
||||||
|
.send({
|
||||||
|
company_name: 'Test Co',
|
||||||
|
company_tagline: 'Tagline',
|
||||||
|
support_email: 'test@example.com',
|
||||||
|
footer_text: 'Footer',
|
||||||
|
watermark_enabled: false,
|
||||||
|
watermark_position: 'bottom-right',
|
||||||
|
watermark_opacity: 0.5,
|
||||||
|
watermark_size: 'medium',
|
||||||
|
favicon_url: null,
|
||||||
|
logo_url: '',
|
||||||
|
watermark_logo_url: null,
|
||||||
|
logo_size: 'medium',
|
||||||
|
logo_max_height: 120,
|
||||||
|
logo_position: 'left',
|
||||||
|
logo_display_header: true,
|
||||||
|
logo_display_hero: false,
|
||||||
|
logo_display_mode: 'default'
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
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 mockFs = require('mock-fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
process.env.JWT_SECRET = 'test-secret';
|
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';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+72
-3
@@ -3,6 +3,54 @@ require('dotenv').config();
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// Database configuration for different environments
|
// Database configuration for different environments
|
||||||
|
const resolveSqliteFilename = (filenameEnv) => {
|
||||||
|
const fallback = path.join(__dirname, './data/photo_sharing.db');
|
||||||
|
|
||||||
|
if (!filenameEnv) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = String(filenameEnv).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved;
|
||||||
|
if (path.isAbsolute(trimmed)) {
|
||||||
|
resolved = trimmed;
|
||||||
|
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||||
|
resolved = path.resolve(__dirname, trimmed);
|
||||||
|
} else {
|
||||||
|
resolved = path.join(__dirname, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.normalize(resolved);
|
||||||
|
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
|
||||||
|
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||||
|
|
||||||
|
if (normalized.includes(duplicatePattern)) {
|
||||||
|
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sqliteConnection = (filenameEnv) => ({
|
||||||
|
filename: resolveSqliteFilename(filenameEnv)
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseSqliteConfig = {
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: sqliteConnection(),
|
||||||
|
useNullAsDefault: true,
|
||||||
|
migrations: {
|
||||||
|
directory: './migrations'
|
||||||
|
},
|
||||||
|
seeds: {
|
||||||
|
directory: './seeds'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
development: {
|
development: {
|
||||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||||
@@ -13,7 +61,7 @@ const config = {
|
|||||||
password: process.env.DB_PASSWORD || 'postgres',
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
database: process.env.DB_NAME || 'photo_sharing'
|
database: process.env.DB_NAME || 'photo_sharing'
|
||||||
} : {
|
} : {
|
||||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||||
},
|
},
|
||||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||||
migrations: {
|
migrations: {
|
||||||
@@ -24,6 +72,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: {
|
production: {
|
||||||
client: process.env.DATABASE_CLIENT || 'pg',
|
client: process.env.DATABASE_CLIENT || 'pg',
|
||||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||||
@@ -42,7 +110,7 @@ const config = {
|
|||||||
keepAliveInitialDelayMillis: 0
|
keepAliveInitialDelayMillis: 0
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||||
},
|
},
|
||||||
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
||||||
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||||
@@ -63,5 +131,6 @@ const config = {
|
|||||||
acquireConnectionTimeout: 60000
|
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();
|
||||||
|
};
|
||||||
@@ -120,7 +120,9 @@ async function runMigrations() {
|
|||||||
|
|
||||||
// Check if this is a new deployment
|
// Check if this is a new deployment
|
||||||
// It's new if no essential tables exist OR no migrations have been applied
|
// It's new if no essential tables exist OR no migrations have been applied
|
||||||
const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0;
|
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
|
||||||
|
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
|
||||||
|
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
|
||||||
|
|
||||||
// Only detect existing schema for truly existing deployments
|
// Only detect existing schema for truly existing deployments
|
||||||
if (!isNewDeployment) {
|
if (!isNewDeployment) {
|
||||||
|
|||||||
Generated
+184
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.129",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.129",
|
"version": "1.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"nodemailer": "7.0.5",
|
"nodemailer": "7.0.5",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "0.34.3",
|
"sharp": "0.34.3",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
|
"mock-fs": "^5.5.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
}
|
}
|
||||||
@@ -4921,7 +4923,6 @@
|
|||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5015,6 +5016,61 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -5130,6 +5186,18 @@
|
|||||||
"once": "^1.4.0"
|
"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": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -5221,7 +5289,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -6169,6 +6236,25 @@
|
|||||||
"void-elements": "3.1.0"
|
"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": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@@ -6563,6 +6649,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
@@ -8014,6 +8109,16 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -8038,6 +8143,24 @@
|
|||||||
"node": ">= 10.16.0"
|
"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": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -8692,7 +8821,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -8787,6 +8915,34 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -9378,6 +9534,20 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
@@ -9750,6 +9920,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/source-map-support": {
|
||||||
"version": "0.5.13",
|
"version": "0.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.129",
|
"version": "1.1.5",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"nodemailer": "7.0.5",
|
"nodemailer": "7.0.5",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "0.34.3",
|
"sharp": "0.34.3",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
|
"mock-fs": "^5.5.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const fsp = fs.promises;
|
||||||
|
|
||||||
|
async function pathExists(location) {
|
||||||
|
try {
|
||||||
|
await fsp.access(location);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFile(source, destination) {
|
||||||
|
await fsp.mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
try {
|
||||||
|
await fsp.rename(source, destination);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EXDEV') {
|
||||||
|
await fsp.copyFile(source, destination);
|
||||||
|
await fsp.unlink(source);
|
||||||
|
} else if (error.code === 'EEXIST') {
|
||||||
|
console.warn(`Destination already exists, leaving original in place: ${destination}`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const backendRoot = path.resolve(__dirname, '..');
|
||||||
|
const defaultStorage = path.resolve(backendRoot, '../storage');
|
||||||
|
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
|
||||||
|
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
|
||||||
|
const targetUploadsRoot = path.join(targetStorage, 'uploads');
|
||||||
|
|
||||||
|
if (legacyUploadsRoot === targetUploadsRoot) {
|
||||||
|
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(legacyUploadsRoot)) {
|
||||||
|
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['logos', 'favicons'];
|
||||||
|
let migratedCounter = 0;
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const legacyDir = path.join(legacyUploadsRoot, category);
|
||||||
|
if (!fs.existsSync(legacyDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = path.join(targetUploadsRoot, category);
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(legacyDir, entry.name);
|
||||||
|
const destinationPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (await pathExists(destinationPath)) {
|
||||||
|
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveFile(sourcePath, destinationPath);
|
||||||
|
migratedCounter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await fsp.readdir(legacyDir);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
await fsp.rm(legacyDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migratedCounter === 0) {
|
||||||
|
console.log('No legacy logo or favicon files needed migration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
|
||||||
|
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((error) => {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
const args = process.argv.slice(2);
|
||||||
|
const hasFlag = (flag) => args.includes(flag);
|
||||||
|
const getOption = (name) => {
|
||||||
|
const index = args.indexOf(`--${name}`);
|
||||||
|
if (index !== -1 && index + 1 < args.length) {
|
||||||
|
return args[index + 1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
|
||||||
|
const credentialsFileArg = getOption('credentials-file');
|
||||||
|
const resolvedCredentialsFile = credentialsFileArg
|
||||||
|
? path.resolve(process.cwd(), credentialsFileArg)
|
||||||
|
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||||
|
|
||||||
|
const rl = force ? null : readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
output: process.stdout
|
output: process.stdout
|
||||||
});
|
});
|
||||||
|
|
||||||
async function question(prompt) {
|
async function ask(prompt) {
|
||||||
|
if (force) {
|
||||||
|
return 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
rl.question(prompt, resolve);
|
rl.question(prompt, resolve);
|
||||||
});
|
});
|
||||||
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
|
|||||||
|
|
||||||
console.log('Found admin user:', admin.username);
|
console.log('Found admin user:', admin.username);
|
||||||
console.log('Email:', admin.email);
|
console.log('Email:', admin.email);
|
||||||
console.log('\nThis will reset the password for this admin account.');
|
if (!force) {
|
||||||
|
console.log('\nThis will reset the password for this admin account.');
|
||||||
|
}
|
||||||
|
|
||||||
const confirm = await question('\nDo you want to continue? (yes/no): ');
|
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||||
|
|
||||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
if (!force) {
|
||||||
console.log('\n❌ Password reset cancelled.');
|
const normalized = confirm.trim().toLowerCase();
|
||||||
process.exit(0);
|
if (normalized !== 'yes' && normalized !== 'y') {
|
||||||
|
console.log('\n❌ Password reset cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new password
|
// Generate new password
|
||||||
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
const credentialsDir = path.dirname(resolvedCredentialsFile);
|
||||||
|
await fs.mkdir(credentialsDir, { recursive: true });
|
||||||
|
|
||||||
|
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
|
||||||
const resetInfo = `
|
const resetInfo = `
|
||||||
========================================
|
========================================
|
||||||
PicPeak Admin Password Reset
|
PicPeak Admin Credentials
|
||||||
========================================
|
========================================
|
||||||
|
|
||||||
Password has been reset for admin account:
|
Your admin account has been reset with these credentials:
|
||||||
|
|
||||||
Username: admin
|
Username: ${admin.username}
|
||||||
New Password: ${newPassword}
|
Email: ${admin.email}
|
||||||
|
Password: ${newPassword}
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT SECURITY NOTES:
|
||||||
1. You MUST change this password on next login
|
1. You MUST change this password after first login
|
||||||
2. This file contains sensitive information
|
2. This file contains sensitive information
|
||||||
3. Delete this file after noting the password
|
3. Delete this file after noting the password
|
||||||
|
|
||||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
Login URL: ${adminUrl}
|
||||||
|
|
||||||
Reset performed on: ${new Date().toISOString()}
|
Reset performed on: ${new Date().toISOString()}
|
||||||
========================================
|
========================================
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
|
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
|
||||||
|
|
||||||
console.log('\n✅ Password reset successful!\n');
|
console.log('\n✅ Password reset successful!\n');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('New Credentials:');
|
console.log('New Credentials:');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('Username: admin');
|
console.log(`Username: ${admin.username}`);
|
||||||
|
console.log(`Email: ${admin.email}`);
|
||||||
console.log(`Password: ${newPassword}`);
|
console.log(`Password: ${newPassword}`);
|
||||||
console.log('\n⚠️ IMPORTANT:');
|
console.log('\n⚠️ IMPORTANT:');
|
||||||
console.log('1. You will be required to change this password on next login');
|
console.log('1. You will be required to change this password on next login');
|
||||||
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
|
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
|
||||||
console.log('3. Delete the file after noting the password');
|
console.log('3. Delete the file after noting the password');
|
||||||
console.log('========================================\n');
|
console.log('========================================\n');
|
||||||
|
|
||||||
@@ -100,7 +130,9 @@ Reset performed on: ${new Date().toISOString()}
|
|||||||
console.error('❌ Error resetting password:', error.message);
|
console.error('❌ Error resetting password:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
rl.close();
|
if (rl) {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-7
@@ -26,6 +26,7 @@ const { startScheduledBackups } = require('./src/services/databaseBackup');
|
|||||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||||
|
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const {
|
const {
|
||||||
getAdminTokenFromRequest,
|
getAdminTokenFromRequest,
|
||||||
@@ -74,17 +75,37 @@ if (enableHsts) {
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (!req.headers.authorization) {
|
if (req.headers.authorization) {
|
||||||
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
return next();
|
||||||
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
}
|
||||||
const galleryToken = getGalleryTokenFromRequest(req, slug);
|
|
||||||
const adminToken = getAdminTokenFromRequest(req);
|
|
||||||
|
|
||||||
|
const path = req.path || '';
|
||||||
|
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
||||||
|
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||||
|
const adminToken = getAdminTokenFromRequest(req);
|
||||||
|
const galleryToken = getGalleryTokenFromRequest(req, slug);
|
||||||
|
|
||||||
|
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
|
||||||
|
const isGalleryRequest = Boolean(slugMatch)
|
||||||
|
|| path.startsWith('/api/gallery')
|
||||||
|
|| path.startsWith('/gallery')
|
||||||
|
|| path.startsWith('/api/secure-images');
|
||||||
|
|
||||||
|
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
|
||||||
|
if (isAdminRequest) {
|
||||||
|
if (adminToken) {
|
||||||
|
req.headers.authorization = `Bearer ${adminToken}`;
|
||||||
|
}
|
||||||
|
} else if (isGalleryRequest) {
|
||||||
if (galleryToken) {
|
if (galleryToken) {
|
||||||
req.headers.authorization = `Bearer ${galleryToken}`;
|
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||||
} else if (adminToken) {
|
} else if (adminToken) {
|
||||||
req.headers.authorization = `Bearer ${adminToken}`;
|
req.headers.authorization = `Bearer ${adminToken}`;
|
||||||
}
|
}
|
||||||
|
} else if (adminToken) {
|
||||||
|
req.headers.authorization = `Bearer ${adminToken}`;
|
||||||
|
} else if (galleryToken) {
|
||||||
|
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -150,6 +171,145 @@ app.options('/api/*', cors(corsOptions));
|
|||||||
let generalRateLimiter;
|
let generalRateLimiter;
|
||||||
let authRateLimiter;
|
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
|
// Function to initialize rate limiters
|
||||||
async function initializeRateLimiters() {
|
async function initializeRateLimiters() {
|
||||||
generalRateLimiter = await createRateLimiter();
|
generalRateLimiter = await createRateLimiter();
|
||||||
@@ -288,13 +448,23 @@ try {
|
|||||||
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
||||||
if (shouldServe) {
|
if (shouldServe) {
|
||||||
logger.info(`Serving frontend from ${frontendDir}`);
|
logger.info(`Serving frontend from ${frontendDir}`);
|
||||||
|
// Serve pre-built assets
|
||||||
app.use(express.static(frontendDir));
|
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);
|
res.sendFile(indexPath);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
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) {
|
} catch (e) {
|
||||||
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
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,
|
||||||
|
};
|
||||||
@@ -396,7 +396,9 @@ router.put('/:id', adminAuth, [
|
|||||||
body('allow_downloads').optional().isBoolean(),
|
body('allow_downloads').optional().isBoolean(),
|
||||||
body('disable_right_click').optional().isBoolean(),
|
body('disable_right_click').optional().isBoolean(),
|
||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim()
|
body('watermark_text').optional().trim(),
|
||||||
|
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||||
|
body('external_path').optional({ nullable: true }).isString().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -407,7 +409,24 @@ router.put('/:id', adminAuth, [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = req.body;
|
const updates = { ...req.body };
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
||||||
|
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) {
|
||||||
|
const trimmedPath = updates.external_path ? String(updates.external_path).trim() : '';
|
||||||
|
updates.external_path = trimmedPath || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.source_mode === 'managed') {
|
||||||
|
updates.external_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) {
|
||||||
|
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||||
|
}
|
||||||
|
|
||||||
// Log the update request for debugging
|
// Log the update request for debugging
|
||||||
console.log('Update event request:', {
|
console.log('Update event request:', {
|
||||||
|
|||||||
@@ -62,11 +62,38 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
|||||||
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
||||||
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
||||||
|
|
||||||
let imported = 0;
|
// Prepare file metadata and deduplicate by filename within type (keep largest)
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
const preparedFiles = [];
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(f.full);
|
||||||
|
const segs = f.rel.split(path.sep);
|
||||||
|
let type = 'individual';
|
||||||
|
if (segs[0] === map.collages) type = 'collage';
|
||||||
|
if (segs[0] === map.individual) type = 'individual';
|
||||||
|
preparedFiles.push({ ...f, type, size: stats.size });
|
||||||
|
} catch (err) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeMap = new Map();
|
||||||
|
for (const file of preparedFiles) {
|
||||||
|
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
|
||||||
|
const existing = dedupeMap.get(dedupeKey);
|
||||||
|
if (!existing || file.size > existing.size) {
|
||||||
|
if (existing) skipped++;
|
||||||
|
dedupeMap.set(dedupeKey, file);
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
// Insert photos
|
// Insert photos
|
||||||
for (const f of files) {
|
for (const f of dedupeMap.values()) {
|
||||||
// Infer type by subfolder names
|
// Infer type by subfolder names
|
||||||
const segs = f.rel.split(path.sep);
|
const segs = f.rel.split(path.sep);
|
||||||
let type = 'individual';
|
let type = 'individual';
|
||||||
@@ -79,7 +106,6 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
|||||||
.where({ event_id: eventId, external_relpath: f.rel })
|
.where({ event_id: eventId, external_relpath: f.rel })
|
||||||
.first();
|
.first();
|
||||||
if (exists) { skipped++; continue; }
|
if (exists) { skipped++; continue; }
|
||||||
|
|
||||||
const stats = await fs.stat(f.full);
|
const stats = await fs.stat(f.full);
|
||||||
const inserted = await db('photos')
|
const inserted = await db('photos')
|
||||||
.insert({
|
.insert({
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
// Configure multer for logo uploads
|
// Configure multer for logo uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
destination: async (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, '../../storage/uploads/logos');
|
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
@@ -43,7 +55,7 @@ const upload = multer({
|
|||||||
// Configure multer for favicon uploads
|
// Configure multer for favicon uploads
|
||||||
const faviconStorage = multer.diskStorage({
|
const faviconStorage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
destination: async (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
|
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
@@ -218,7 +230,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||||||
// Delete the file from filesystem
|
// Delete the file from filesystem
|
||||||
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', ''));
|
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||||||
|
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(faviconPath);
|
await fs.unlink(faviconPath);
|
||||||
console.log('Deleted favicon file:', faviconPath);
|
console.log('Deleted favicon file:', faviconPath);
|
||||||
@@ -248,7 +261,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||||||
// Delete the file from filesystem
|
// Delete the file from filesystem
|
||||||
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', ''));
|
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||||||
|
const logoPath = path.join(getStoragePath(), relativePath);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(logoPath);
|
await fs.unlink(logoPath);
|
||||||
console.log('Deleted logo file:', logoPath);
|
console.log('Deleted logo file:', logoPath);
|
||||||
@@ -284,6 +298,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
metadata: JSON.stringify({ company_name })
|
metadata: JSON.stringify({ company_name })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearPublicSiteCache();
|
||||||
|
|
||||||
res.json({ message: 'Branding settings updated successfully' });
|
res.json({ message: 'Branding settings updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Branding update error:', error);
|
console.error('Branding update error:', error);
|
||||||
@@ -443,6 +459,8 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearPublicSiteCache();
|
||||||
|
|
||||||
res.json({ message: 'Theme settings updated successfully' });
|
res.json({ message: 'Theme settings updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Theme update error:', error);
|
console.error('Theme update error:', error);
|
||||||
@@ -453,7 +471,39 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
// Update general settings
|
// Update general settings
|
||||||
router.put('/general', adminAuth, async (req, res) => {
|
router.put('/general', adminAuth, async (req, res) => {
|
||||||
try {
|
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
|
// Update or insert each setting
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
@@ -476,6 +526,10 @@ router.put('/general', adminAuth, async (req, res) => {
|
|||||||
clearMaintenanceCache();
|
clearMaintenanceCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (publicSiteKeysTouched) {
|
||||||
|
clearPublicSiteCache();
|
||||||
|
}
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'general_settings_updated',
|
activity_type: 'general_settings_updated',
|
||||||
@@ -593,7 +647,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
for (const archive of archives) {
|
for (const archive of archives) {
|
||||||
if (archive.archive_path) {
|
if (archive.archive_path) {
|
||||||
try {
|
try {
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = getStoragePath();
|
||||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||||
const stats = await fs.stat(fullArchivePath);
|
const stats = await fs.stat(fullArchivePath);
|
||||||
archiveStorage += stats.size;
|
archiveStorage += stats.size;
|
||||||
@@ -603,11 +657,190 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||||
|
const storagePath = getStoragePath();
|
||||||
|
|
||||||
|
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({
|
res.json({
|
||||||
total_used: totalStorage.total || 0,
|
total_used: totalUsed,
|
||||||
archive_storage: archiveStorage,
|
archive_storage: archiveStorage,
|
||||||
storage_by_event: storageByEvent,
|
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) {
|
} catch (error) {
|
||||||
console.error('Storage info error:', error);
|
console.error('Storage info error:', error);
|
||||||
@@ -717,4 +950,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;
|
module.exports = router;
|
||||||
@@ -107,38 +107,82 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
.select('photos.*')
|
.select('photos.*')
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
// Apply filtering if requested (global, based on aggregate counts)
|
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const f = String(filter).toLowerCase();
|
const filterTokens = new Set(
|
||||||
const parts = f.split(',').map(s => s.trim());
|
String(filter)
|
||||||
const include = new Set();
|
.toLowerCase()
|
||||||
|
.split(',')
|
||||||
|
.map(token => token.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
// Helper to include IDs for a predicate
|
if (filterTokens.size > 0) {
|
||||||
const includeBy = (predicate) => {
|
// Treat "saved" / "favorite" synonyms as favorites
|
||||||
photos.forEach(p => { if (predicate(p)) include.add(p.id); });
|
if (filterTokens.has('saved')) {
|
||||||
};
|
filterTokens.add('favorited');
|
||||||
|
}
|
||||||
|
if (filterTokens.has('favorite')) {
|
||||||
|
filterTokens.add('favorited');
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.includes('liked')) {
|
const include = new Set();
|
||||||
includeBy(p => (p.like_count || 0) > 0);
|
|
||||||
}
|
|
||||||
if (parts.includes('favorited')) {
|
|
||||||
includeBy(p => (p.favorite_count || 0) > 0);
|
|
||||||
}
|
|
||||||
if (parts.includes('rated')) {
|
|
||||||
includeBy(p => (p.average_rating || 0) > 0);
|
|
||||||
}
|
|
||||||
if (parts.includes('commented')) {
|
|
||||||
// Query commented photo IDs
|
|
||||||
const commented = await db('photo_feedback')
|
|
||||||
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
|
||||||
.groupBy('photo_id')
|
|
||||||
.select('photo_id');
|
|
||||||
const commentedIds = new Set(commented.map(c => c.photo_id));
|
|
||||||
includeBy(p => commentedIds.has(p.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (include.size > 0) {
|
const includeBy = (predicate) => {
|
||||||
photos = photos.filter(p => include.has(p.id));
|
photos.forEach(photo => {
|
||||||
|
if (predicate(photo)) {
|
||||||
|
include.add(photo.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let guestFeedbackByType = null;
|
||||||
|
if (guest_id) {
|
||||||
|
const guestFeedbackRows = await db('photo_feedback')
|
||||||
|
.where({ event_id: req.event.id, guest_identifier: guest_id })
|
||||||
|
.select('photo_id', 'feedback_type');
|
||||||
|
|
||||||
|
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
|
||||||
|
if (!acc[row.feedback_type]) {
|
||||||
|
acc[row.feedback_type] = new Set();
|
||||||
|
}
|
||||||
|
acc[row.feedback_type].add(row.photo_id);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeGuestMatches = (type) => {
|
||||||
|
const ids = guestFeedbackByType?.[type];
|
||||||
|
if (ids && ids.size > 0) {
|
||||||
|
ids.forEach(id => include.add(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filterTokens.has('liked')) {
|
||||||
|
includeGuestMatches('like');
|
||||||
|
includeBy(photo => (photo.like_count || 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterTokens.has('favorited')) {
|
||||||
|
includeGuestMatches('favorite');
|
||||||
|
includeBy(photo => (photo.favorite_count || 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterTokens.has('rated')) {
|
||||||
|
includeGuestMatches('rating');
|
||||||
|
includeBy(photo => (photo.average_rating || 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterTokens.has('commented')) {
|
||||||
|
includeGuestMatches('comment');
|
||||||
|
const commentedRows = await db('photo_feedback')
|
||||||
|
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
||||||
|
.groupBy('photo_id')
|
||||||
|
.select('photo_id');
|
||||||
|
commentedRows.forEach(row => include.add(row.photo_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
photos = photos.filter(photo => include.has(photo.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,12 +757,6 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
|||||||
// Process uploaded photos
|
// Process uploaded photos
|
||||||
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
|
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
|
||||||
|
|
||||||
// Clean up temp files
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
for (const file of req.files) {
|
|
||||||
await fs.unlink(file.path).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Photos uploaded successfully',
|
message: 'Photos uploaded successfully',
|
||||||
count: results.length,
|
count: results.length,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,31 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
|||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
function normalizeFiles(files) {
|
||||||
|
if (!files) return [];
|
||||||
|
if (Array.isArray(files)) return files.filter(Boolean);
|
||||||
|
|
||||||
|
// Multer may expose files as an iterable object
|
||||||
|
if (typeof files[Symbol.iterator] === 'function') {
|
||||||
|
return Array.from(files).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof files === 'object') {
|
||||||
|
return Object.values(files)
|
||||||
|
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
|
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
|
||||||
const uploadedPhotos = [];
|
const uploadedPhotos = [];
|
||||||
|
const fileList = normalizeFiles(files);
|
||||||
|
|
||||||
|
if (fileList.length === 0) {
|
||||||
|
return uploadedPhotos;
|
||||||
|
}
|
||||||
|
|
||||||
// Get event details
|
// Get event details
|
||||||
const event = await db('events').where({ id: eventId }).first();
|
const event = await db('events').where({ id: eventId }).first();
|
||||||
@@ -17,7 +40,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process each file
|
// Process each file
|
||||||
for (const file of files) {
|
for (const file of fileList) {
|
||||||
const trx = await db.transaction();
|
const trx = await db.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +59,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
counter = (existingCount.count || 0) + 1;
|
const existingCountValue = Number(existingCount?.count ?? 0);
|
||||||
|
counter = existingCountValue + 1;
|
||||||
|
|
||||||
// Generate new filename
|
// Generate new filename
|
||||||
const extension = path.extname(file.originalname);
|
const extension = path.extname(file.originalname);
|
||||||
@@ -53,9 +77,24 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
await fs.mkdir(destPath, { recursive: true });
|
await fs.mkdir(destPath, { recursive: true });
|
||||||
|
|
||||||
const newPath = path.join(destPath, newFilename);
|
const newPath = path.join(destPath, newFilename);
|
||||||
|
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
|
||||||
|
|
||||||
|
if (!tempPath) {
|
||||||
|
throw new Error('Uploaded file is missing a temporary path');
|
||||||
|
}
|
||||||
|
|
||||||
// Use copyFile and unlink instead of rename to avoid cross-device issues
|
// Use copyFile and unlink instead of rename to avoid cross-device issues
|
||||||
await fs.copyFile(file.path, newPath);
|
try {
|
||||||
await fs.unlink(file.path);
|
await fs.copyFile(tempPath, newPath);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempPath);
|
||||||
|
} catch (unlinkErr) {
|
||||||
|
if (unlinkErr?.code !== 'ENOENT') {
|
||||||
|
console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate thumbnail
|
// Generate thumbnail
|
||||||
const thumbnailPath = await generateThumbnail(newPath);
|
const thumbnailPath = await generateThumbnail(newPath);
|
||||||
@@ -66,14 +105,45 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||||
|
|
||||||
// Add to database with uploaded_by field
|
// Add to database with uploaded_by field
|
||||||
const [photoId] = await trx('photos').insert({
|
let insertResult;
|
||||||
event_id: eventId,
|
const clientName = trx?.client?.config?.client;
|
||||||
filename: newFilename,
|
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||||
path: relativePath,
|
|
||||||
thumbnail_path: relativeThumbPath,
|
if (supportsReturning) {
|
||||||
type: photoType,
|
insertResult = await trx('photos')
|
||||||
size_bytes: file.size
|
.insert({
|
||||||
});
|
event_id: eventId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: relativePath,
|
||||||
|
thumbnail_path: relativeThumbPath,
|
||||||
|
type: photoType,
|
||||||
|
size_bytes: file.size,
|
||||||
|
uploaded_by: uploadedBy,
|
||||||
|
source_origin: 'managed'
|
||||||
|
})
|
||||||
|
.returning('id');
|
||||||
|
} else {
|
||||||
|
insertResult = await trx('photos').insert({
|
||||||
|
event_id: eventId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: relativePath,
|
||||||
|
thumbnail_path: relativeThumbPath,
|
||||||
|
type: photoType,
|
||||||
|
size_bytes: file.size,
|
||||||
|
uploaded_by: uploadedBy,
|
||||||
|
source_origin: 'managed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedId = Array.isArray(insertResult)
|
||||||
|
? (insertResult[0]?.id ?? insertResult[0])
|
||||||
|
: insertResult;
|
||||||
|
|
||||||
|
const photoId = typeof insertedId === 'object' ? insertedId.id : insertedId;
|
||||||
|
|
||||||
|
if (photoId === undefined || photoId === null) {
|
||||||
|
throw new Error('Failed to determine inserted photo ID');
|
||||||
|
}
|
||||||
|
|
||||||
// Commit transaction
|
// Commit transaction
|
||||||
await trx.commit();
|
await trx.commit();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { resolveExternalPath } = require('./externalMediaService');
|
const { resolveExternalPath } = require('./externalMediaService');
|
||||||
|
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
@@ -33,10 +34,15 @@ function resolvePhotoFilePath(event, photo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
|
const eventsRoot = path.join(storagePath, 'events/active');
|
||||||
|
|
||||||
if (photo.path && photo.path.startsWith('events/active/')) {
|
if (photo.path && photo.path.startsWith('events/active/')) {
|
||||||
return path.join(storagePath, photo.path);
|
// Legacy paths already include prefix; normalize via safe join
|
||||||
|
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
|
||||||
}
|
}
|
||||||
return path.join(storagePath, 'events/active', photo.path || '');
|
|
||||||
|
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
|
||||||
|
return safePathJoin(eventsRoot, relativeSegment);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -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 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 { Upload } = require('@aws-sdk/lib-storage');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
@@ -25,6 +25,10 @@ describe('S3StorageAdapter', () => {
|
|||||||
};
|
};
|
||||||
S3Client.mockImplementation(() => mockS3Client);
|
S3Client.mockImplementation(() => mockS3Client);
|
||||||
|
|
||||||
|
HeadBucketCommand.mockImplementation((input) => ({ input }));
|
||||||
|
HeadObjectCommand.mockImplementation((input) => ({ input }));
|
||||||
|
ListObjectsV2Command.mockImplementation((input) => ({ input }));
|
||||||
|
|
||||||
// Create adapter instance
|
// Create adapter instance
|
||||||
s3Storage = new S3StorageAdapter({
|
s3Storage = new S3StorageAdapter({
|
||||||
bucket: 'test-bucket',
|
bucket: 'test-bucket',
|
||||||
@@ -70,11 +74,7 @@ describe('S3StorageAdapter', () => {
|
|||||||
const result = await s3Storage.testConnection();
|
const result = await s3Storage.testConnection();
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(HeadBucketCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket' });
|
||||||
expect.objectContaining({
|
|
||||||
input: { Bucket: 'test-bucket' }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error on connection failure', async () => {
|
it('should throw error on connection failure', async () => {
|
||||||
@@ -132,22 +132,23 @@ describe('S3StorageAdapter', () => {
|
|||||||
|
|
||||||
it('should track upload progress', async () => {
|
it('should track upload progress', async () => {
|
||||||
const onProgress = jest.fn();
|
const onProgress = jest.fn();
|
||||||
let progressCallback;
|
Upload.mockImplementation(() => {
|
||||||
|
const uploadInstance = {
|
||||||
mockUpload.on.mockImplementation((event, callback) => {
|
on: jest.fn((event, handler) => {
|
||||||
if (event === 'httpUploadProgress') {
|
if (event === 'httpUploadProgress') {
|
||||||
progressCallback = callback;
|
handler({ loaded: 512, total: 1024 });
|
||||||
}
|
}
|
||||||
return mockUpload;
|
return uploadInstance;
|
||||||
|
}),
|
||||||
|
done: mockDone
|
||||||
|
};
|
||||||
|
return uploadInstance;
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
|
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
|
||||||
onProgress
|
onProgress
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate progress
|
|
||||||
progressCallback({ loaded: 512, total: 1024 });
|
|
||||||
|
|
||||||
await uploadPromise;
|
await uploadPromise;
|
||||||
|
|
||||||
expect(onProgress).toHaveBeenCalledWith(512, 1024);
|
expect(onProgress).toHaveBeenCalledWith(512, 1024);
|
||||||
@@ -177,11 +178,10 @@ describe('S3StorageAdapter', () => {
|
|||||||
const result = await s3Storage.exists('test-key');
|
const result = await s3Storage.exists('test-key');
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(HeadObjectCommand).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({
|
Bucket: 'test-bucket',
|
||||||
input: { Bucket: 'test-bucket', Key: 'test-key' }
|
Key: 'test-key'
|
||||||
})
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if object does not exist', async () => {
|
it('should return false if object does not exist', async () => {
|
||||||
@@ -221,57 +221,50 @@ describe('S3StorageAdapter', () => {
|
|||||||
const retryableError = new Error('Connection reset');
|
const retryableError = new Error('Connection reset');
|
||||||
retryableError.code = 'ECONNRESET';
|
retryableError.code = 'ECONNRESET';
|
||||||
|
|
||||||
// First attempt fails, second succeeds
|
const operation = jest.fn()
|
||||||
mockSend
|
|
||||||
.mockRejectedValueOnce(retryableError)
|
.mockRejectedValueOnce(retryableError)
|
||||||
.mockResolvedValueOnce({});
|
.mockResolvedValueOnce('success');
|
||||||
|
|
||||||
// Mock setTimeout to speed up test
|
const originalRandom = Math.random;
|
||||||
jest.useFakeTimers();
|
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
|
expect(result).toBe('success');
|
||||||
jest.runAllTimers();
|
expect(operation).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
const result = await promise;
|
Math.random = originalRandom;
|
||||||
|
s3Storage.config.retryDelay = originalDelay;
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not retry on non-retryable errors', async () => {
|
it('should not retry on non-retryable errors', async () => {
|
||||||
const nonRetryableError = new Error('Invalid credentials');
|
const nonRetryableError = new Error('Invalid credentials');
|
||||||
nonRetryableError.code = 'InvalidCredentials';
|
nonRetryableError.code = 'InvalidCredentials';
|
||||||
|
|
||||||
mockSend.mockRejectedValueOnce(nonRetryableError);
|
const operation = jest.fn().mockRejectedValueOnce(nonRetryableError);
|
||||||
|
|
||||||
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials');
|
await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Invalid credentials');
|
||||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
expect(operation).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop retrying after max attempts', async () => {
|
it('should stop retrying after max attempts', async () => {
|
||||||
const retryableError = new Error('Service unavailable');
|
const retryableError = new Error('Service unavailable');
|
||||||
retryableError.code = 'ServiceUnavailable';
|
retryableError.code = 'ServiceUnavailable';
|
||||||
|
|
||||||
mockSend.mockRejectedValue(retryableError);
|
const operation = jest.fn().mockRejectedValue(retryableError);
|
||||||
|
|
||||||
// Mock setTimeout to speed up test
|
const originalRandom = Math.random;
|
||||||
jest.useFakeTimers();
|
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
|
Math.random = originalRandom;
|
||||||
for (let i = 0; i < 4; i++) {
|
s3Storage.config.retryDelay = originalDelay;
|
||||||
jest.runAllTimers();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow('Service unavailable');
|
|
||||||
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
|
||||||
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
|
|||||||
- Check the console output from when you ran migrations
|
- Check the console output from when you ran migrations
|
||||||
- File is created in the backend directory root
|
- File is created in the backend directory root
|
||||||
- File might have been deleted for security (as recommended)
|
- File might have been deleted for security (as recommended)
|
||||||
|
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||||
|
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ server {
|
|||||||
proxy_cache_valid 404 1m;
|
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
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.128",
|
"version": "1.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.128",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.128",
|
"version": "1.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
AnalyticsPage,
|
AnalyticsPage,
|
||||||
BrandingPage,
|
BrandingPage,
|
||||||
SettingsPage,
|
SettingsPage,
|
||||||
CMSPage,
|
|
||||||
BackupManagement
|
BackupManagement
|
||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
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">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{photos.map((photo, index) => {
|
{photos.map((photo, index) => {
|
||||||
const isDeleting = deletingPhotos.has(photo.id);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
@@ -258,18 +261,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
|
{/* 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">
|
<div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
|
||||||
{photo.average_rating > 0 && (
|
{averageRating > 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)}`}>
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.comment_count > 0 && (
|
{commentCount > 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`}>
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import React, { useState } from 'react';
|
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 { format } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { AdminPhoto } from '../../services/photos.service';
|
import { AdminPhoto } from '../../services/photos.service';
|
||||||
import { photosService } 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 { Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
|
|
||||||
|
type AdminFeedbackResponse = {
|
||||||
|
feedback: PhotoFeedback[];
|
||||||
|
summary?: FeedbackSummary;
|
||||||
|
};
|
||||||
|
|
||||||
interface AdminPhotoViewerProps {
|
interface AdminPhotoViewerProps {
|
||||||
photos: AdminPhoto[];
|
photos: AdminPhoto[];
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
@@ -34,9 +39,16 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const currentPhoto = photos[currentIndex];
|
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
|
// Fetch feedback for current photo
|
||||||
const { data: feedbackData } = useQuery({
|
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
|
||||||
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
||||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||||
photoId: currentPhoto?.id.toString(),
|
photoId: currentPhoto?.id.toString(),
|
||||||
@@ -45,6 +57,8 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
enabled: !!currentPhoto
|
enabled: !!currentPhoto
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const comments = (feedbackData?.feedback ?? []).filter((item): item is PhotoFeedback => item.feedback_type === 'comment');
|
||||||
|
|
||||||
const goToPrevious = () => {
|
const goToPrevious = () => {
|
||||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||||
};
|
};
|
||||||
@@ -304,41 +318,41 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
|
|
||||||
{/* Feedback Stats */}
|
{/* Feedback Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<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="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-yellow-400 mb-1">
|
<div className="flex items-center gap-1 text-yellow-400 mb-1">
|
||||||
<Star className="w-4 h-4" fill="currentColor" />
|
<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>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Avg Rating</p>
|
<p className="text-xs text-neutral-400">Avg Rating</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPhoto.like_count > 0 && (
|
{likeCount > 0 && (
|
||||||
<div className="bg-neutral-800 rounded-lg p-3">
|
<div className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-red-400 mb-1">
|
<div className="flex items-center gap-1 text-red-400 mb-1">
|
||||||
<Heart className="w-4 h-4" fill="currentColor" />
|
<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>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Likes</p>
|
<p className="text-xs text-neutral-400">Likes</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPhoto.favorite_count > 0 && (
|
{favoriteCount > 0 && (
|
||||||
<div className="bg-neutral-800 rounded-lg p-3">
|
<div className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-blue-400 mb-1">
|
<div className="flex items-center gap-1 text-blue-400 mb-1">
|
||||||
<Star className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Favorites</p>
|
<p className="text-xs text-neutral-400">Favorites</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{feedbackData.feedback && (
|
{comments.length > 0 && (
|
||||||
<div className="bg-neutral-800 rounded-lg p-3">
|
<div className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-green-400 mb-1">
|
<div className="flex items-center gap-1 text-green-400 mb-1">
|
||||||
<MessageSquare className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Comments</p>
|
<p className="text-xs text-neutral-400">Comments</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,20 +360,18 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments List */}
|
{/* Comments List */}
|
||||||
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && (
|
{comments.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedComments(!expandedComments)}
|
onClick={() => setExpandedComments(!expandedComments)}
|
||||||
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
|
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>
|
</button>
|
||||||
|
|
||||||
{expandedComments && (
|
{expandedComments && (
|
||||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||||
{feedbackData.feedback
|
{comments.map((comment) => (
|
||||||
.filter(f => f.feedback_type === 'comment')
|
|
||||||
.map((comment) => (
|
|
||||||
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
|
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -459,7 +471,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No feedback message */}
|
{/* 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>
|
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="p-4 border-t border-neutral-200">
|
<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">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
||||||
<span className="font-medium text-neutral-900">
|
<span className="font-medium text-neutral-900">
|
||||||
@@ -134,12 +143,12 @@ const StorageInfo: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
||||||
<div
|
<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)}%` }}
|
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-600 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 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 StarterKit from '@tiptap/starter-kit';
|
||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
import HardBreak from '@tiptap/extension-hard-break';
|
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 updateCounts = useCallback((editorInstance: Editor) => {
|
||||||
const text = editor.state.doc.textContent;
|
const textContent = editorInstance.state.doc.textContent;
|
||||||
setCharCount(editor.storage.characterCount.characters());
|
setCharCount(editorInstance.storage.characterCount.characters());
|
||||||
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
|
const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0);
|
||||||
|
setWordCount(words.length);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update editor content when prop changes
|
// Update editor content when prop changes
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
|
||||||
User
|
User
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
@@ -16,7 +13,7 @@ import { toast } from 'react-toastify';
|
|||||||
|
|
||||||
import { Card, Loading, Button } from '../common';
|
import { Card, Loading, Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
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';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
|
|
||||||
interface FeedbackModerationPanelProps {
|
interface FeedbackModerationPanelProps {
|
||||||
@@ -38,7 +35,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
|||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
// Fetch pending feedback
|
// Fetch pending feedback
|
||||||
const { data: feedbackData, isLoading } = useQuery({
|
const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
|
||||||
queryKey: ['event-feedback-moderation', eventId],
|
queryKey: ['event-feedback-moderation', eventId],
|
||||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||||
type: 'comment',
|
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;
|
const hasPending = pendingComments.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<div className="p-6">
|
<div className={compact ? 'p-4' : 'p-6'}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">
|
<h2 className="text-lg font-semibold text-neutral-900">
|
||||||
{t('feedback.pendingModeration', 'Pending Moderation')}
|
{t('feedback.pendingModeration', 'Pending Moderation')}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Camera } from 'lucide-react';
|
import { Camera } from 'lucide-react';
|
||||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||||
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
|
||||||
|
interface GalleryPreviewBranding {
|
||||||
|
company_name?: string;
|
||||||
|
company_tagline?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||||
|
logo_position?: 'left' | 'center' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
interface GalleryPreviewProps {
|
interface GalleryPreviewProps {
|
||||||
theme: ThemeConfig;
|
theme: ThemeConfig;
|
||||||
|
branding?: GalleryPreviewBranding;
|
||||||
layoutType?: GalleryLayoutType;
|
layoutType?: GalleryLayoutType;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -56,6 +66,7 @@ const PreviewPhoto: React.FC<{
|
|||||||
|
|
||||||
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||||
theme,
|
theme,
|
||||||
|
branding,
|
||||||
layoutType,
|
layoutType,
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
@@ -64,6 +75,23 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
// Use the provided layoutType or fallback to theme's gallery layout
|
// Use the provided layoutType or fallback to theme's gallery layout
|
||||||
const activeLayout = layoutType || theme.galleryLayout || 'grid';
|
const activeLayout = layoutType || theme.galleryLayout || 'grid';
|
||||||
|
|
||||||
|
const displayMode = branding?.logo_display_mode || 'logo_and_text';
|
||||||
|
const showLogo = displayMode === 'logo_only' || displayMode === 'logo_and_text';
|
||||||
|
const showText = displayMode === 'text_only' || displayMode === 'logo_and_text';
|
||||||
|
const brandName = branding?.company_name?.trim() || 'Your Studio';
|
||||||
|
const brandTagline = branding?.company_tagline?.trim() || '';
|
||||||
|
const resolvedLogoUrl = showLogo && branding?.logo_url
|
||||||
|
? (branding.logo_url.startsWith('http')
|
||||||
|
? branding.logo_url
|
||||||
|
: buildResourceUrl(branding.logo_url))
|
||||||
|
: null;
|
||||||
|
const logoPosition = branding?.logo_position || 'left';
|
||||||
|
const brandFlexClass = logoPosition === 'center'
|
||||||
|
? 'justify-center text-center'
|
||||||
|
: logoPosition === 'right'
|
||||||
|
? 'justify-end text-right flex-row-reverse'
|
||||||
|
: 'justify-start text-left';
|
||||||
|
|
||||||
const renderLayout = () => {
|
const renderLayout = () => {
|
||||||
const spacing = theme.gallerySettings?.spacing || 'normal';
|
const spacing = theme.gallerySettings?.spacing || 'normal';
|
||||||
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
|
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
|
||||||
@@ -163,14 +191,41 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
>
|
>
|
||||||
{/* Preview Header */}
|
{/* Preview Header */}
|
||||||
<div
|
<div
|
||||||
className="px-4 py-3 border-b"
|
className="px-4 py-3 border-b space-y-2"
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-medium">
|
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
|
||||||
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout
|
{showLogo && (
|
||||||
</h3>
|
resolvedLogoUrl ? (
|
||||||
|
<img
|
||||||
|
src={resolvedLogoUrl}
|
||||||
|
alt={brandName}
|
||||||
|
className="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
|
||||||
|
<Camera className="w-4 h-4 text-neutral-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{showText && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold leading-tight">{brandName}</p>
|
||||||
|
{brandTagline && (
|
||||||
|
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!showLogo && !showText && (
|
||||||
|
<p className="text-sm font-semibold">{brandName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500 flex justify-between">
|
||||||
|
<span>Gallery preview</span>
|
||||||
|
<span className="capitalize">{activeLayout} layout</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Content */}
|
{/* Preview Content */}
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
if (progressEvent.total) {
|
if (progressEvent.total) {
|
||||||
// Calculate overall progress across all chunks
|
// 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}
|
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"
|
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">
|
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
|
||||||
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
|
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
|
|||||||
fallbackSrc?: string;
|
fallbackSrc?: string;
|
||||||
useWatermark?: boolean;
|
useWatermark?: boolean;
|
||||||
isGallery?: 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> = ({
|
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||||
@@ -14,8 +31,46 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
alt,
|
alt,
|
||||||
useWatermark = false,
|
useWatermark = false,
|
||||||
isGallery = false,
|
isGallery = false,
|
||||||
|
protectFromDownload,
|
||||||
|
slug,
|
||||||
|
photoId,
|
||||||
|
requiresToken,
|
||||||
|
secureUrlTemplate,
|
||||||
|
downloadUrlTemplate,
|
||||||
|
onProtectionViolation,
|
||||||
|
watermarkText,
|
||||||
|
overlayProtection,
|
||||||
|
fragmentGrid,
|
||||||
|
scrambleFragments,
|
||||||
|
useCanvasRendering,
|
||||||
|
blockKeyboardShortcuts,
|
||||||
|
detectPrintScreen,
|
||||||
|
detectDevTools,
|
||||||
|
protectionLevel,
|
||||||
|
useEnhancedProtection,
|
||||||
...props
|
...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 [imageSrc, setImageSrc] = useState<string>('');
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="sm"
|
||||||
onClick={() => selectPassword(password)}
|
onClick={() => selectPassword(password)}
|
||||||
>
|
>
|
||||||
{t('passwordGenerator.use')}
|
{t('passwordGenerator.use')}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { ProtectionLevel } from '../../hooks/useImageProtection';
|
import { ProtectionLevel } from '../../hooks/useImageProtection';
|
||||||
|
|
||||||
|
type VendorStyle = CSSStyleDeclaration & {
|
||||||
|
webkitUserSelect?: string;
|
||||||
|
webkitTouchCallout?: string;
|
||||||
|
webkitUserDrag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
|
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
@@ -31,7 +37,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
@@ -201,9 +206,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
|||||||
// Render normal image - ensure image is valid before drawing
|
// Render normal image - ensure image is valid before drawing
|
||||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
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
|
// Apply CSS protection
|
||||||
canvas.style.userSelect = 'none';
|
const canvasStyle = canvas.style as VendorStyle;
|
||||||
canvas.style.webkitUserSelect = 'none';
|
canvasStyle.userSelect = 'none';
|
||||||
canvas.style.webkitTouchCallout = 'none';
|
canvasStyle.webkitUserSelect = 'none';
|
||||||
canvas.style.webkitUserDrag = 'none';
|
canvasStyle.webkitTouchCallout = 'none';
|
||||||
canvas.style.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
canvasStyle.webkitUserDrag = 'none';
|
||||||
|
canvasStyle.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener('contextmenu', handleContextMenu);
|
canvas.removeEventListener('contextmenu', handleContextMenu);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style jsx>{`
|
<style>{`
|
||||||
@keyframes shrink {
|
@keyframes shrink {
|
||||||
from { width: 100%; }
|
from { width: 100%; }
|
||||||
to { width: 0%; }
|
to { width: 0%; }
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Heart, Star, MessageSquare } from 'lucide-react';
|
import { Heart, Star, MessageSquare, Bookmark } from 'lucide-react';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type FilterType = 'all' | 'liked' | 'rated' | 'commented';
|
export type FilterType = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
|
||||||
|
|
||||||
interface GalleryFilterProps {
|
interface GalleryFilterProps {
|
||||||
currentFilter: FilterType;
|
currentFilter: FilterType;
|
||||||
onFilterChange: (filter: FilterType) => void;
|
onFilterChange: (filter: FilterType) => void;
|
||||||
feedbackEnabled: boolean;
|
feedbackEnabled: boolean;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
|
favoriteCount?: number;
|
||||||
ratedCount?: number;
|
ratedCount?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -21,6 +22,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
feedbackEnabled,
|
feedbackEnabled,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
|
favoriteCount = 0,
|
||||||
ratedCount = 0,
|
ratedCount = 0,
|
||||||
className = '',
|
className = '',
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
@@ -59,6 +61,15 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
>
|
>
|
||||||
<Heart className="w-3.5 h-3.5" />
|
<Heart className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.favorited', 'Saved')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -111,6 +122,16 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
|
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3 h-3" />
|
||||||
|
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorited', 'Saved')}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -153,6 +174,21 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="text-xs sm:text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
<span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span>
|
||||||
|
{favoriteCount > 0 && (
|
||||||
|
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
|
||||||
|
{favoriteCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ interface GalleryLayoutProps {
|
|||||||
showDownloadAll?: boolean;
|
showDownloadAll?: boolean;
|
||||||
onDownloadAll?: () => void;
|
onDownloadAll?: () => void;
|
||||||
isDownloading?: boolean;
|
isDownloading?: boolean;
|
||||||
isExpired?: boolean;
|
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
menuButton?: React.ReactNode;
|
menuButton?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -49,7 +48,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
showDownloadAll = false,
|
showDownloadAll = false,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
isExpired = false,
|
|
||||||
headerExtra,
|
headerExtra,
|
||||||
menuButton,
|
menuButton,
|
||||||
children,
|
children,
|
||||||
@@ -63,22 +61,28 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||||
|
|
||||||
// Calculate logo size classes based on settings
|
// 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 size = brandingSettings?.logo_size || 'medium';
|
||||||
const maxHeight = brandingSettings?.logo_max_height || 48;
|
const maxHeight = brandingSettings?.logo_max_height || 48;
|
||||||
|
|
||||||
if (size === 'custom') {
|
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',
|
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',
|
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',
|
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'
|
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
|
// Determine logo position classes
|
||||||
@@ -109,6 +113,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
return displayMode !== 'logo_only';
|
return displayMode !== 'logo_only';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerLogoSize = getLogoDimensions('header');
|
||||||
|
const heroLogoSize = getLogoDimensions('hero');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50">
|
<div className="min-h-screen bg-neutral-50">
|
||||||
{/* Dynamic Favicon */}
|
{/* Dynamic Favicon */}
|
||||||
@@ -183,8 +190,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
'/picpeak-logo-transparent.png'
|
'/picpeak-logo-transparent.png'
|
||||||
}
|
}
|
||||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
className={`${typeof getLogoSizeClass('header') === 'string' ? getLogoSizeClass('header') : ''} w-auto object-contain`}
|
className={`${headerLogoSize.className} w-auto object-contain`}
|
||||||
style={typeof getLogoSizeClass('header') === 'object' ? getLogoSizeClass('header') : undefined}
|
style={headerLogoSize.style}
|
||||||
/>
|
/>
|
||||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||||
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
|
<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'
|
'/picpeak-logo-transparent.png'
|
||||||
}
|
}
|
||||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
className={`${typeof getLogoSizeClass('hero') === 'string' ? getLogoSizeClass('hero') : ''} w-auto object-contain mx-auto`}
|
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||||
style={typeof getLogoSizeClass('hero') === 'object' ?
|
style={{
|
||||||
{ ...getLogoSizeClass('hero'), filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' } :
|
...(heroLogoSize.style || {}),
|
||||||
{ 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))'
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
{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)' }}>
|
<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;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
isExpired?: boolean;
|
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
photoCounts?: Record<number, number>;
|
photoCounts?: Record<number, number>;
|
||||||
totalPhotos: number;
|
totalPhotos: number;
|
||||||
@@ -33,6 +32,7 @@ interface GallerySidebarProps {
|
|||||||
filterType?: FilterType;
|
filterType?: FilterType;
|
||||||
onFilterChange?: (filter: FilterType) => void;
|
onFilterChange?: (filter: FilterType) => void;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
|
favoriteCount?: number;
|
||||||
ratedCount?: number;
|
ratedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,6 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
isExpired = false,
|
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
photoCounts = {},
|
photoCounts = {},
|
||||||
totalPhotos,
|
totalPhotos,
|
||||||
@@ -64,6 +63,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
filterType = 'all',
|
filterType = 'all',
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
|
favoriteCount = 0,
|
||||||
ratedCount = 0
|
ratedCount = 0
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -223,6 +223,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
likeCount={likeCount}
|
likeCount={likeCount}
|
||||||
|
favoriteCount={favoriteCount}
|
||||||
ratedCount={ratedCount}
|
ratedCount={ratedCount}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
|||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { Upload, Menu } from 'lucide-react';
|
import { Upload, Menu } from 'lucide-react';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
import { feedbackService } from '../../services/feedback.service';
|
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
|
|
||||||
@@ -271,6 +270,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
case 'liked':
|
case 'liked':
|
||||||
photos = photos.filter(photo => (photo.like_count || 0) > 0);
|
photos = photos.filter(photo => (photo.like_count || 0) > 0);
|
||||||
break;
|
break;
|
||||||
|
case 'favorited':
|
||||||
|
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
|
||||||
|
break;
|
||||||
case 'rated':
|
case 'rated':
|
||||||
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
||||||
break;
|
break;
|
||||||
@@ -315,6 +317,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
return photos;
|
return photos;
|
||||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
|
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
|
||||||
|
|
||||||
|
const likeCount = useMemo(
|
||||||
|
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
|
||||||
|
[data?.photos]
|
||||||
|
);
|
||||||
|
|
||||||
|
const favoriteCount = useMemo(
|
||||||
|
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
|
||||||
|
[data?.photos]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ratedCount = useMemo(
|
||||||
|
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
|
||||||
|
[data?.photos]
|
||||||
|
);
|
||||||
|
|
||||||
// Check if downloads are allowed (both event setting and not expired)
|
// Check if downloads are allowed (both event setting and not expired)
|
||||||
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
||||||
|
|
||||||
@@ -462,7 +479,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
onDownloadAll={handleDownloadAll}
|
onDownloadAll={handleDownloadAll}
|
||||||
onDownloadSelected={handleDownloadSelected}
|
onDownloadSelected={handleDownloadSelected}
|
||||||
isDownloading={downloadAllMutation.isPending}
|
isDownloading={downloadAllMutation.isPending}
|
||||||
isExpired={isExpired}
|
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
photoCounts={photoCounts}
|
photoCounts={photoCounts}
|
||||||
totalPhotos={data?.photos.length || 0}
|
totalPhotos={data?.photos.length || 0}
|
||||||
@@ -473,8 +489,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
onFilterChange={setFilterType}
|
onFilterChange={setFilterType}
|
||||||
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
|
likeCount={likeCount}
|
||||||
ratedCount={data?.photos?.filter(p => (p.total_ratings || 0) > 0).length || 0}
|
favoriteCount={favoriteCount}
|
||||||
|
ratedCount={ratedCount}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -486,7 +503,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
showDownloadAll={!showSidebar && allowDownloads}
|
showDownloadAll={!showSidebar && allowDownloads}
|
||||||
onDownloadAll={handleDownloadAll}
|
onDownloadAll={handleDownloadAll}
|
||||||
isDownloading={downloadAllMutation.isPending}
|
isDownloading={downloadAllMutation.isPending}
|
||||||
isExpired={isExpired}
|
|
||||||
menuButton={showSidebar ? (
|
menuButton={showSidebar ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
value={guestName}
|
value={guestName}
|
||||||
onChange={(e) => setGuestName(e.target.value)}
|
onChange={(e) => setGuestName(e.target.value)}
|
||||||
error={errors.guest_name}
|
error={errors.guest_name}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -155,7 +154,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
value={guestEmail}
|
value={guestEmail}
|
||||||
onChange={(e) => setGuestEmail(e.target.value)}
|
onChange={(e) => setGuestEmail(e.target.value)}
|
||||||
error={errors.guest_email}
|
error={errors.guest_email}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
if (requireNameEmail && !savedIdentity) {
|
if (requireNameEmail && !savedIdentity) {
|
||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
} else {
|
} else {
|
||||||
submitFavoriteMutation.mutate(savedIdentity || {});
|
const identityPayload = savedIdentity
|
||||||
|
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
|
||||||
|
: {};
|
||||||
|
submitFavoriteMutation.mutate(identityPayload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { feedbackService } from '../../services/feedback.service';
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
import { PhotoRating } from './PhotoRating';
|
import { PhotoRating } from './PhotoRating';
|
||||||
import { PhotoLikes } from './PhotoLikes';
|
import { PhotoLikes } from './PhotoLikes';
|
||||||
|
import { PhotoFavorites } from './PhotoFavorites';
|
||||||
import { PhotoComments } from './PhotoComments';
|
import { PhotoComments } from './PhotoComments';
|
||||||
import { Skeleton } from '../common';
|
import { Skeleton } from '../common';
|
||||||
import type { FeedbackSettings } from '../../services/feedback.service';
|
|
||||||
|
|
||||||
interface PhotoFeedbackProps {
|
interface PhotoFeedbackProps {
|
||||||
photoId: string;
|
photoId: string;
|
||||||
@@ -33,7 +33,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch feedback data for the photo
|
// Fetch feedback data for the photo
|
||||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
const { data: feedbackData } = useQuery({
|
||||||
queryKey: ['photo-feedback', gallerySlug, photoId],
|
queryKey: ['photo-feedback', gallerySlug, photoId],
|
||||||
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
||||||
enabled: !!settings?.feedback_enabled,
|
enabled: !!settings?.feedback_enabled,
|
||||||
@@ -43,13 +43,17 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
const [currentRating, setCurrentRating] = useState(0);
|
const [currentRating, setCurrentRating] = useState(0);
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
const [likeCount, setLikeCount] = useState(0);
|
const [likeCount, setLikeCount] = useState(0);
|
||||||
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
|
const [favoriteCount, setFavoriteCount] = useState(0);
|
||||||
|
|
||||||
// Update local state when data loads
|
// Update local state when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedbackData) {
|
if (feedbackData) {
|
||||||
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
||||||
setIsLiked(feedbackData.my_feedback.liked);
|
setIsLiked(Boolean(feedbackData.my_feedback.liked));
|
||||||
setLikeCount(feedbackData.summary.like_count);
|
setLikeCount(Number(feedbackData.summary.like_count) || 0);
|
||||||
|
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
|
||||||
|
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
|
||||||
}
|
}
|
||||||
}, [feedbackData]);
|
}, [feedbackData]);
|
||||||
|
|
||||||
@@ -65,6 +69,12 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFavoriteChange = (favorited: boolean) => {
|
||||||
|
setIsFavorited(favorited);
|
||||||
|
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
|
||||||
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
if (settingsLoading) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
@@ -79,7 +89,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
||||||
settings.allow_comments;
|
settings.allow_comments || settings.allow_favorites;
|
||||||
|
|
||||||
if (!hasAnyFeedbackType) {
|
if (!hasAnyFeedbackType) {
|
||||||
return null;
|
return null;
|
||||||
@@ -102,8 +112,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{settings.allow_likes && (
|
{(settings.allow_likes || settings.allow_favorites) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{settings.allow_likes && (
|
{settings.allow_likes && (
|
||||||
<PhotoLikes
|
<PhotoLikes
|
||||||
photoId={photoId}
|
photoId={photoId}
|
||||||
@@ -115,6 +125,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
onLikeChange={handleLikeChange}
|
onLikeChange={handleLikeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{settings.allow_favorites && (
|
||||||
|
<PhotoFavorites
|
||||||
|
photoId={photoId}
|
||||||
|
gallerySlug={gallerySlug}
|
||||||
|
isFavorited={isFavorited}
|
||||||
|
favoriteCount={favoriteCount}
|
||||||
|
isEnabled={true}
|
||||||
|
requireNameEmail={settings.require_name_email || false}
|
||||||
|
onFavoriteChange={handleFavoriteChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Search, SortAsc, Grid, Heart, Star, MessageSquare } from 'lucide-react';
|
import { Search, SortAsc, Grid, Heart, Star, MessageSquare, Bookmark } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Input } from '../common';
|
import { Button, Input } from '../common';
|
||||||
import type { FilterType } from './GalleryFilter';
|
import type { FilterType } from './GalleryFilter';
|
||||||
@@ -50,7 +50,6 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search and Sort */}
|
{/* Search and Sort */}
|
||||||
@@ -195,6 +194,15 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
>
|
>
|
||||||
<Heart className="w-3.5 h-3.5" />
|
<Heart className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.favorited', 'Saved')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -248,6 +256,15 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
>
|
>
|
||||||
<Heart className="w-3.5 h-3.5" />
|
<Heart className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.favorited', 'Saved')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
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) {
|
} catch (error) {
|
||||||
toastify.error(t('gallery.downloadError'));
|
toastify.error(t('gallery.downloadError'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,6 +191,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
|||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,6 +223,7 @@ interface PhotoThumbnailProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
slug: string; // Add slug as required prop
|
slug: string; // Add slug as required prop
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||||
@@ -233,7 +235,8 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
slug
|
slug,
|
||||||
|
feedbackEnabled = false
|
||||||
}) => {
|
}) => {
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
triggerOnce: true,
|
triggerOnce: true,
|
||||||
@@ -280,18 +283,18 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Feedback Indicators */}
|
{/* 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">
|
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||||
{photo.comment_count > 0 && (
|
{(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} comments`}>
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.average_rating > 0 && (
|
{(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).toFixed(1)}`}>
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
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) {
|
} catch (error) {
|
||||||
toastify.error(t('gallery.downloadError'));
|
toastify.error(t('gallery.downloadError'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -291,6 +291,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
initialShowFeedback={openFeedbackInitially}
|
initialShowFeedback={openFeedbackInitially}
|
||||||
|
onFeedbackChange={onFeedbackChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PhotoLightboxProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
initialShowFeedback?: boolean;
|
initialShowFeedback?: boolean;
|
||||||
|
onFeedbackChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||||
@@ -30,6 +31,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
initialShowFeedback = false,
|
initialShowFeedback = false,
|
||||||
|
onFeedbackChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
@@ -432,12 +434,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||||
aria-label="Toggle feedback"
|
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" />
|
<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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -529,10 +531,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex-1 overflow-y-auto">
|
<div className="p-4 flex-1 overflow-y-auto">
|
||||||
<PhotoFeedback
|
<PhotoFeedback
|
||||||
photoId={currentPhoto.id}
|
photoId={String(currentPhoto.id)}
|
||||||
gallerySlug={slug}
|
gallerySlug={slug}
|
||||||
showComments={true}
|
showComments={true}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
onFeedbackUpdate={() => {
|
||||||
|
if (onFeedbackChange) onFeedbackChange();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
|||||||
if (requireNameEmail && !savedIdentity) {
|
if (requireNameEmail && !savedIdentity) {
|
||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
} else {
|
} 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'
|
: animationType === 'fade'
|
||||||
? 'transition-opacity duration-300'
|
? '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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -99,7 +103,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
detectPrintScreen={useEnhancedProtection}
|
detectPrintScreen={useEnhancedProtection}
|
||||||
detectDevTools={protectionLevel === 'maximum'}
|
detectDevTools={protectionLevel === 'maximum'}
|
||||||
watermarkText={useEnhancedProtection ? 'Protected' : undefined}
|
watermarkText={useEnhancedProtection ? 'Protected' : undefined}
|
||||||
onProtectionViolation={(violationType) => {
|
onProtectionViolation={(violationType: string) => {
|
||||||
console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`);
|
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" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onQuickComment && (
|
{showFeedbackActions && onQuickComment && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||||
@@ -137,7 +141,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Quick feedback actions */}
|
{/* Quick feedback actions */}
|
||||||
{feedbackOptions?.allowLikes && (
|
{showFeedbackActions && feedbackOptions?.allowLikes && (
|
||||||
<button
|
<button
|
||||||
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
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) => {
|
onClick={async (e) => {
|
||||||
@@ -189,19 +193,19 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */}
|
{/* 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`}>
|
<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">
|
<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" />
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
</span>
|
</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">
|
<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" />
|
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||||
</span>
|
</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">
|
<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" />
|
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -269,19 +269,19 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
|
{/* 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`}>
|
<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">
|
<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" />
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
</span>
|
</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">
|
<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>
|
<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>
|
</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">
|
<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>
|
<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>
|
</span>
|
||||||
|
|||||||
@@ -72,24 +72,24 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Feedback Indicators */}
|
{/* 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">
|
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||||
{photo.comment_count > 0 && (
|
{(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} comments`}>
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.average_rating > 0 && (
|
{(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).toFixed(1)}`}>
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
{photo.like_count > 0 && (
|
{(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} likes`}>
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feedback Indicators (bottom-left) */}
|
{/* 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`}>
|
<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">
|
<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" />
|
<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}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||||
onDownload={(e) => onDownload(photo, e)}
|
onDownload={(e) => onDownload(photo, e)}
|
||||||
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
className=""
|
className=""
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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`}>
|
<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">
|
<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" />
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios, { AxiosHeaders } from 'axios';
|
||||||
|
import {
|
||||||
|
getActiveGallerySlug,
|
||||||
|
getGalleryToken,
|
||||||
|
inferGallerySlugFromLocation,
|
||||||
|
resolveSlugFromRequestUrl,
|
||||||
|
} from '../utils/galleryAuthStorage';
|
||||||
|
|
||||||
// Maintenance mode callback
|
// Maintenance mode callback
|
||||||
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
||||||
@@ -23,6 +29,60 @@ api.interceptors.request.use(
|
|||||||
delete config.headers?.['Content-Type'];
|
delete config.headers?.['Content-Type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const pathSlug = resolveSlugFromRequestUrl(config.url || '');
|
||||||
|
const params = config.params as Record<string, unknown> | undefined;
|
||||||
|
const paramSlug = typeof params?.slug === 'string' ? (params.slug as string) : null;
|
||||||
|
|
||||||
|
const rawPath = (() => {
|
||||||
|
if (!config.url) return '';
|
||||||
|
try {
|
||||||
|
if (config.url.startsWith('http://') || config.url.startsWith('https://')) {
|
||||||
|
return new URL(config.url).pathname;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return config.url;
|
||||||
|
}
|
||||||
|
return config.url;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
||||||
|
|
||||||
|
const isGalleryEndpoint = /^\/gallery\//.test(pathname)
|
||||||
|
|| /^\/secure-images\//.test(pathname)
|
||||||
|
|| /^\/auth\/gallery\//.test(pathname);
|
||||||
|
|
||||||
|
const isGallerySessionCheck = pathname === '/auth/session'
|
||||||
|
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
|
||||||
|
|
||||||
|
if (isGalleryEndpoint || isGallerySessionCheck) {
|
||||||
|
const fallbackSlug = getActiveGallerySlug()
|
||||||
|
|| inferGallerySlugFromLocation();
|
||||||
|
const slug = pathSlug || paramSlug || fallbackSlug;
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
const token = getGalleryToken(slug);
|
||||||
|
if (token) {
|
||||||
|
if (!config.headers) {
|
||||||
|
config.headers = new AxiosHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.headers instanceof AxiosHeaders) {
|
||||||
|
const existing = config.headers.get('Authorization');
|
||||||
|
if (!existing) {
|
||||||
|
config.headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const headersRecord = config.headers as Record<string, string | undefined>;
|
||||||
|
if (!headersRecord.Authorization) {
|
||||||
|
headersRecord.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import type { ReactNode } from 'react';
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import { authService, galleryService } from '../services';
|
import { authService, galleryService } from '../services';
|
||||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||||
|
import {
|
||||||
|
clearActiveGallerySlug,
|
||||||
|
clearGalleryToken,
|
||||||
|
setActiveGallerySlug,
|
||||||
|
storeGalleryToken,
|
||||||
|
} from '../utils/galleryAuthStorage';
|
||||||
|
|
||||||
interface GalleryEvent {
|
interface GalleryEvent {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -55,6 +61,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanupOldGalleryAuth();
|
cleanupOldGalleryAuth();
|
||||||
|
|
||||||
|
const slugAtMount = getCurrentGallerySlug();
|
||||||
|
if (slugAtMount) {
|
||||||
|
setActiveGallerySlug(slugAtMount);
|
||||||
|
} else {
|
||||||
|
clearActiveGallerySlug();
|
||||||
|
}
|
||||||
|
|
||||||
const initialise = async () => {
|
const initialise = async () => {
|
||||||
const currentSlug = getCurrentGallerySlug();
|
const currentSlug = getCurrentGallerySlug();
|
||||||
|
|
||||||
@@ -63,6 +76,8 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActiveGallerySlug(currentSlug);
|
||||||
|
|
||||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||||
if (storedEvent) {
|
if (storedEvent) {
|
||||||
try {
|
try {
|
||||||
@@ -109,6 +124,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
setEvent(response.event);
|
setEvent(response.event);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
|
||||||
|
if (response.token) {
|
||||||
|
storeGalleryToken(currentSlug, response.token);
|
||||||
|
}
|
||||||
|
setActiveGallerySlug(currentSlug);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,16 +137,21 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
|
clearGalleryToken(currentSlug);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
|
clearGalleryToken(currentSlug);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initialise();
|
initialise();
|
||||||
|
return () => {
|
||||||
|
clearActiveGallerySlug();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
|
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
|
||||||
@@ -137,6 +161,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||||
setEvent(response.event);
|
setEvent(response.event);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
if (response.token) {
|
||||||
|
storeGalleryToken(slug, response.token);
|
||||||
|
}
|
||||||
|
setActiveGallerySlug(slug);
|
||||||
|
|
||||||
// Store event data for quick reloads (non-sensitive)
|
// Store event data for quick reloads (non-sensitive)
|
||||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
|
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
|
||||||
@@ -152,12 +180,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
const currentSlug = getCurrentGallerySlug();
|
const currentSlug = getCurrentGallerySlug();
|
||||||
if (currentSlug) {
|
if (currentSlug) {
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
|
clearGalleryToken(currentSlug);
|
||||||
}
|
}
|
||||||
authService.galleryLogout(currentSlug || undefined);
|
authService.galleryLogout(currentSlug || undefined);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
}
|
clearActiveGallerySlug();
|
||||||
;
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GalleryAuthContext.Provider
|
<GalleryAuthContext.Provider
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { ProtectionLevel } from './useImageProtection';
|
import { ProtectionLevel } from './useImageProtection';
|
||||||
|
|
||||||
|
type VendorStyle = CSSStyleDeclaration & {
|
||||||
|
webkitUserSelect?: string;
|
||||||
|
webkitTouchCallout?: string;
|
||||||
|
webkitUserDrag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface UseCSSProtectionOptions {
|
interface UseCSSProtectionOptions {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
protectionLevel: ProtectionLevel;
|
protectionLevel: ProtectionLevel;
|
||||||
@@ -63,19 +69,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
|
|||||||
// Apply inline styles for enhanced protection
|
// Apply inline styles for enhanced protection
|
||||||
if (options.protectionLevel === 'enhanced' || options.protectionLevel === 'maximum') {
|
if (options.protectionLevel === 'enhanced' || options.protectionLevel === 'maximum') {
|
||||||
// Disable various browser features
|
// Disable various browser features
|
||||||
container.style.userSelect = 'none';
|
const containerStyle = container.style as VendorStyle;
|
||||||
container.style.webkitUserSelect = 'none';
|
containerStyle.userSelect = 'none';
|
||||||
container.style.webkitTouchCallout = 'none';
|
containerStyle.webkitUserSelect = 'none';
|
||||||
container.style.webkitUserDrag = 'none';
|
containerStyle.webkitTouchCallout = 'none';
|
||||||
|
containerStyle.webkitUserDrag = 'none';
|
||||||
|
|
||||||
// Find all img and canvas elements and protect them
|
// Find all img and canvas elements and protect them
|
||||||
const mediaElements = container.querySelectorAll('img, canvas');
|
const mediaElements = container.querySelectorAll('img, canvas');
|
||||||
mediaElements.forEach(element => {
|
mediaElements.forEach(element => {
|
||||||
(element as HTMLElement).draggable = false;
|
const el = element as HTMLElement;
|
||||||
(element as HTMLElement).style.userSelect = 'none';
|
const elStyle = el.style as VendorStyle;
|
||||||
(element as HTMLElement).style.webkitUserSelect = 'none';
|
el.draggable = false;
|
||||||
(element as HTMLElement).style.webkitUserDrag = 'none';
|
elStyle.userSelect = 'none';
|
||||||
(element as HTMLElement).style.webkitTouchCallout = 'none';
|
elStyle.webkitUserSelect = 'none';
|
||||||
|
elStyle.webkitUserDrag = 'none';
|
||||||
|
elStyle.webkitTouchCallout = 'none';
|
||||||
|
|
||||||
if (options.protectionLevel === 'maximum') {
|
if (options.protectionLevel === 'maximum') {
|
||||||
(element as HTMLElement).style.pointerEvents = 'none';
|
(element as HTMLElement).style.pointerEvents = 'none';
|
||||||
@@ -101,19 +110,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset inline styles
|
// Reset inline styles
|
||||||
container.style.userSelect = '';
|
const containerStyle = container.style as VendorStyle;
|
||||||
container.style.webkitUserSelect = '';
|
containerStyle.userSelect = '';
|
||||||
container.style.webkitTouchCallout = '';
|
containerStyle.webkitUserSelect = '';
|
||||||
container.style.webkitUserDrag = '';
|
containerStyle.webkitTouchCallout = '';
|
||||||
|
containerStyle.webkitUserDrag = '';
|
||||||
|
|
||||||
// Reset media element styles
|
// Reset media element styles
|
||||||
const mediaElements = container.querySelectorAll('img, canvas');
|
const mediaElements = container.querySelectorAll('img, canvas');
|
||||||
mediaElements.forEach(element => {
|
mediaElements.forEach(element => {
|
||||||
(element as HTMLElement).style.userSelect = '';
|
const el = element as HTMLElement;
|
||||||
(element as HTMLElement).style.webkitUserSelect = '';
|
const elStyle = el.style as VendorStyle;
|
||||||
(element as HTMLElement).style.webkitUserDrag = '';
|
elStyle.userSelect = '';
|
||||||
(element as HTMLElement).style.webkitTouchCallout = '';
|
elStyle.webkitUserSelect = '';
|
||||||
(element as HTMLElement).style.pointerEvents = '';
|
elStyle.webkitUserDrag = '';
|
||||||
|
elStyle.webkitTouchCallout = '';
|
||||||
|
elStyle.pointerEvents = '';
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ interface UseDevToolsProtectionOptions {
|
|||||||
export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => {
|
export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => {
|
||||||
const detectionTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const detectionTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastConsoleCountRef = useRef(0);
|
const lastConsoleCountRef = useRef(0);
|
||||||
const startTimeRef = useRef<number>(Date.now());
|
|
||||||
const isDetectedRef = useRef(false);
|
const isDetectedRef = useRef(false);
|
||||||
|
|
||||||
const handleDevToolsDetected = useCallback(() => {
|
const handleDevToolsDetected = useCallback(() => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const useGalleryInfo = (slug: string, token?: string) => {
|
|||||||
|
|
||||||
export const useGalleryPhotos = (
|
export const useGalleryPhotos = (
|
||||||
slug: string,
|
slug: string,
|
||||||
filter?: 'liked' | 'commented' | 'rated' | 'all',
|
filter?: 'liked' | 'favorited' | 'commented' | 'rated' | 'all',
|
||||||
guestId?: string,
|
guestId?: string,
|
||||||
enabled: boolean = true
|
enabled: boolean = true
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
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';
|
export type ProtectionLevel = 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
|
|
||||||
interface UseImageProtectionOptions {
|
interface UseImageProtectionOptions {
|
||||||
@@ -16,9 +24,9 @@ interface UseImageProtectionOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useImageProtection = (options: UseImageProtectionOptions) => {
|
export const useImageProtection = (options: UseImageProtectionOptions) => {
|
||||||
const elementRef = useRef<HTMLImageElement>(null);
|
const elementRef = useRef<HTMLImageElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
const printScreenDetectorRef = useRef<HTMLCanvasElement | null>(null);
|
const printScreenDetectorRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const printScreenIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const printScreenIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
@@ -195,25 +203,26 @@ export const useImageProtection = (options: UseImageProtectionOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CSS protection
|
// CSS protection
|
||||||
element.style.userSelect = 'none';
|
const elementStyle = element.style as VendorStyle;
|
||||||
element.style.webkitUserSelect = 'none';
|
elementStyle.userSelect = 'none';
|
||||||
element.style.webkitTouchCallout = 'none';
|
elementStyle.webkitUserSelect = 'none';
|
||||||
element.style.pointerEvents = 'auto';
|
elementStyle.webkitTouchCallout = 'none';
|
||||||
element.style.webkitUserDrag = 'none';
|
elementStyle.pointerEvents = 'auto';
|
||||||
element.style.webkitTouchCallout = 'none';
|
elementStyle.webkitUserDrag = 'none';
|
||||||
element.draggable = false;
|
element.draggable = false;
|
||||||
|
|
||||||
// Enhanced CSS protection
|
// Enhanced CSS protection
|
||||||
if (protectionLevel !== 'basic') {
|
if (protectionLevel !== 'basic') {
|
||||||
element.style.outline = 'none';
|
elementStyle.outline = 'none';
|
||||||
element.style.webkitAppearance = 'none';
|
elementStyle.webkitAppearance = 'none';
|
||||||
element.style.MozAppearance = 'none';
|
elementStyle.MozAppearance = 'none';
|
||||||
|
|
||||||
// Disable text selection on parent elements
|
// Disable text selection on parent elements
|
||||||
let parent = element.parentElement;
|
let parent = element.parentElement;
|
||||||
while (parent) {
|
while (parent) {
|
||||||
parent.style.userSelect = 'none';
|
const parentStyle = parent.style as VendorStyle;
|
||||||
parent.style.webkitUserSelect = 'none';
|
parentStyle.userSelect = 'none';
|
||||||
|
parentStyle.webkitUserSelect = 'none';
|
||||||
parent = parent.parentElement;
|
parent = parent.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,8 +478,14 @@
|
|||||||
"sortByDate": "Nach Datum sortieren",
|
"sortByDate": "Nach Datum sortieren",
|
||||||
"sortByName": "Nach Name sortieren",
|
"sortByName": "Nach Name sortieren",
|
||||||
"sortBySize": "Nach Größe sortieren",
|
"sortBySize": "Nach Größe sortieren",
|
||||||
"allPhotos": "Alle Fotos",
|
"allPhotos": "Alle Fotos",
|
||||||
"shareGallery": "Galerie teilen",
|
"feedbackFilter": "Feedback-Filter",
|
||||||
|
"all": "Alle",
|
||||||
|
"liked": "Gefallen",
|
||||||
|
"favorited": "Favorisiert",
|
||||||
|
"rated": "Bewertet",
|
||||||
|
"commented": "Kommentiert",
|
||||||
|
"shareGallery": "Galerie teilen",
|
||||||
"needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter",
|
"needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter",
|
||||||
"noPhotosFound": "Keine Fotos gefunden",
|
"noPhotosFound": "Keine Fotos gefunden",
|
||||||
"failedToLoad": "Fotos konnten nicht geladen werden",
|
"failedToLoad": "Fotos konnten nicht geladen werden",
|
||||||
@@ -588,6 +594,13 @@
|
|||||||
"photos": "Fotos",
|
"photos": "Fotos",
|
||||||
"categories": "Kategorien",
|
"categories": "Kategorien",
|
||||||
"eventInformation": "Veranstaltungsinformationen",
|
"eventInformation": "Veranstaltungsinformationen",
|
||||||
|
"sourceMode": "Quellenmodus",
|
||||||
|
"sourceModeManaged": "Verwaltet (Upload nach PicPeak)",
|
||||||
|
"sourceModeReference": "Externen Ordner referenzieren",
|
||||||
|
"sourceModeHelp": "Nutzen Sie den verwalteten Modus für direkte Uploads oder verweisen Sie auf einen gemounteten /external-media Ordner.",
|
||||||
|
"externalFolder": "Externer Ordner",
|
||||||
|
"externalFolderHint": "Diese Ordner stammen aus dem /external-media Mount innerhalb des Containers oder Hosts.",
|
||||||
|
"externalFolderRequired": "Bitte wählen Sie vor dem Speichern einen externen Ordner aus.",
|
||||||
"welcomeMessage": "Willkommensnachricht",
|
"welcomeMessage": "Willkommensnachricht",
|
||||||
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
|
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
|
||||||
"noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt",
|
"noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt",
|
||||||
@@ -770,12 +783,66 @@
|
|||||||
"dateFormat": "Datumsformat",
|
"dateFormat": "Datumsformat",
|
||||||
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
|
"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": {
|
"storage": {
|
||||||
"title": "Speicher",
|
"title": "Speicher",
|
||||||
"overview": "Speicherübersicht",
|
"overview": "Speicherübersicht",
|
||||||
"totalUsed": "Gesamt verwendet",
|
"totalUsed": "Gesamt verwendet",
|
||||||
"archiveStorage": "Archivspeicher",
|
"archiveStorage": "Archivspeicher",
|
||||||
"storageLimit": "Speicherlimit",
|
"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",
|
"storageUsage": "Speichernutzung",
|
||||||
"storageByEvent": "Speicher nach Veranstaltung",
|
"storageByEvent": "Speicher nach Veranstaltung",
|
||||||
"storageManagement": "Speicherverwaltung",
|
"storageManagement": "Speicherverwaltung",
|
||||||
@@ -988,7 +1055,9 @@
|
|||||||
"activeGalleries": "Aktive Galerien",
|
"activeGalleries": "Aktive Galerien",
|
||||||
"storageUsed": "Speicher verwendet",
|
"storageUsed": "Speicher verwendet",
|
||||||
"totalPhotos": "Gesamte Fotos",
|
"totalPhotos": "Gesamte Fotos",
|
||||||
"storagePercent": "{{percent}}% von {{limit}}",
|
"storagePercent": "{{percent}}% des Limits {{limit}}",
|
||||||
|
"storageSoftLimitConfigured": "Konfiguriertes Limit: {{limit}}",
|
||||||
|
"storageSoftLimitRecommended": "Empfohlenes Limit: {{limit}}",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"archivedEvents": "Archivierte Veranstaltungen",
|
"archivedEvents": "Archivierte Veranstaltungen",
|
||||||
"systemHealth": "Systemstatus",
|
"systemHealth": "Systemstatus",
|
||||||
|
|||||||
@@ -278,6 +278,13 @@
|
|||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"eventInformation": "Event Information",
|
"eventInformation": "Event Information",
|
||||||
|
"sourceMode": "Source Mode",
|
||||||
|
"sourceModeManaged": "Managed (upload to PicPeak)",
|
||||||
|
"sourceModeReference": "Reference external folder",
|
||||||
|
"sourceModeHelp": "Use managed mode for direct uploads or point to a mounted /external-media folder when using local storage.",
|
||||||
|
"externalFolder": "External Folder",
|
||||||
|
"externalFolderHint": "These folders are read from the /external-media mount inside your container or host.",
|
||||||
|
"externalFolderRequired": "Please select an external folder before saving.",
|
||||||
"welcomeMessage": "Welcome Message",
|
"welcomeMessage": "Welcome Message",
|
||||||
"noWelcomeMessage": "No welcome message set",
|
"noWelcomeMessage": "No welcome message set",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
@@ -456,12 +463,66 @@
|
|||||||
"dateFormat": "Date Format",
|
"dateFormat": "Date Format",
|
||||||
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
|
"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": {
|
"storage": {
|
||||||
"title": "Storage",
|
"title": "Storage",
|
||||||
"overview": "Storage Overview",
|
"overview": "Storage Overview",
|
||||||
"totalUsed": "Total Used",
|
"totalUsed": "Total Used",
|
||||||
"archiveStorage": "Archive Storage",
|
"archiveStorage": "Archive Storage",
|
||||||
"storageLimit": "Storage Limit",
|
"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",
|
"storageUsage": "Storage Usage",
|
||||||
"storageByEvent": "Storage by Event",
|
"storageByEvent": "Storage by Event",
|
||||||
"storageManagement": "Storage Management",
|
"storageManagement": "Storage Management",
|
||||||
@@ -732,7 +793,9 @@
|
|||||||
"activeGalleries": "Active Galleries",
|
"activeGalleries": "Active Galleries",
|
||||||
"storageUsed": "Storage Used",
|
"storageUsed": "Storage Used",
|
||||||
"totalPhotos": "Total Photos",
|
"totalPhotos": "Total Photos",
|
||||||
"storagePercent": "{{percent}}% of {{limit}}",
|
"storagePercent": "{{percent}}% of limit {{limit}}",
|
||||||
|
"storageSoftLimitConfigured": "Configured limit: {{limit}}",
|
||||||
|
"storageSoftLimitRecommended": "Suggested limit: {{limit}}",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"archivedEvents": "Archived Events",
|
"archivedEvents": "Archived Events",
|
||||||
"systemHealth": "System Health",
|
"systemHealth": "System Health",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { format, parseISO } from 'date-fns';
|
|||||||
import { Button, Card, Loading } from '../../components/common';
|
import { Button, Card, Loading } from '../../components/common';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { adminService } from '../../services/admin.service';
|
import { adminService } from '../../services/admin.service';
|
||||||
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
@@ -72,6 +73,11 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
queryFn: () => adminService.getDashboardStats(),
|
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
|
// Fetch Umami config from admin settings since we're in admin panel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUmamiConfig = async () => {
|
const fetchUmamiConfig = async () => {
|
||||||
@@ -154,12 +160,12 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Get actual download data for top galleries - sort by downloads
|
// Get actual download data for top galleries - sort by downloads
|
||||||
const topGalleriesWithDownloads = apiData.topGalleries
|
const topGalleriesWithDownloads = apiData.topGalleries
|
||||||
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
|
.filter(gallery => (gallery.downloads ?? 0) > 0) // Only show galleries with downloads
|
||||||
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
|
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)) // Sort by downloads
|
||||||
.slice(0, 5) // Take top 5
|
.slice(0, 5) // Take top 5
|
||||||
.map(gallery => ({
|
.map(gallery => ({
|
||||||
name: gallery.event_name,
|
name: gallery.event_name,
|
||||||
downloads: gallery.downloads || 0
|
downloads: gallery.downloads ?? 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -429,38 +435,68 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Storage Information */}
|
{/* Storage Information */}
|
||||||
{dashboardStats && (
|
{dashboardStats && (() => {
|
||||||
<Card padding="md">
|
const softLimitBytes = storageInfo?.storage_soft_limit ?? storageInfo?.storage_limit ?? storageInfo?.recommended_soft_limit ?? null;
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
|
const safeSoftLimit = Math.max(
|
||||||
<div className="space-y-4">
|
softLimitBytes ?? storageInfo?.recommended_soft_limit ?? (dashboardStats.storageUsed || 1),
|
||||||
<div>
|
1
|
||||||
<div className="flex justify-between text-sm mb-1">
|
);
|
||||||
<span className="text-neutral-600">{t('analytics.used')}</span>
|
const usageRatio = dashboardStats.storageUsed / safeSoftLimit;
|
||||||
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
|
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>
|
||||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
<div className="pt-2 border-t">
|
||||||
<div
|
<div className="flex justify-between text-sm">
|
||||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
|
||||||
style={{ width: `${Math.min((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100, 100)}%` }}
|
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="flex justify-between text-sm mt-2">
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
|
||||||
{Math.round((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100)}% {t('analytics.of')} 10 GB
|
<span className="font-medium">{dashboardStats.activeEvents}</span>
|
||||||
</p>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
);
|
||||||
)}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
export const BackupManagement: ComponentType<any>;
|
||||||
@@ -150,7 +150,13 @@ export const BrandingPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const logoUrl = await settingsService.uploadLogo(file);
|
const logoUrl = await settingsService.uploadLogo(file);
|
||||||
setBrandingSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
setBrandingSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
||||||
setCurrentTheme(prev => ({ ...prev, logoUrl }));
|
setCurrentTheme(prev => {
|
||||||
|
const updated = { ...prev, logoUrl };
|
||||||
|
if (isPreviewMode) {
|
||||||
|
setTheme(updated);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
toast.success(t('toast.uploadSuccess'));
|
toast.success(t('toast.uploadSuccess'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload logo:', error);
|
console.error('Failed to upload logo:', error);
|
||||||
@@ -159,6 +165,17 @@ export const BrandingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveLogo = () => {
|
||||||
|
setBrandingSettings(prev => ({ ...prev, logo_url: '' }));
|
||||||
|
setCurrentTheme(prev => {
|
||||||
|
const updated = { ...prev, logoUrl: '' };
|
||||||
|
if (isPreviewMode) {
|
||||||
|
setTheme(updated);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -351,7 +368,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleBrandingChange('logo_url', '')}
|
onClick={handleRemoveLogo}
|
||||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -686,6 +703,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<GalleryPreview
|
<GalleryPreview
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
|
branding={brandingSettings}
|
||||||
className="shadow-lg"
|
className="shadow-lg"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
import { Button, Card, Input, Loading } from '../../components/common';
|
import { Button, Card, Input, Loading } from '../../components/common';
|
||||||
import { CMSEditor } from '../../components/admin/CMSEditor';
|
import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||||
import { cmsService } from '../../services/cms.service';
|
import { cmsService } from '../../services/cms.service';
|
||||||
import type { CMSPage as CMSPageType } 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 = () => {
|
export const CMSPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -15,6 +17,11 @@ export const CMSPage: React.FC = () => {
|
|||||||
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
||||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||||
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
|
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
|
// Fetch CMS pages
|
||||||
const { data: pages, isLoading } = useQuery({
|
const { data: pages, isLoading } = useQuery({
|
||||||
@@ -22,6 +29,33 @@ export const CMSPage: React.FC = () => {
|
|||||||
queryFn: cmsService.getPages,
|
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
|
// Update page mutation
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||||
@@ -62,6 +96,53 @@ export const CMSPage: React.FC = () => {
|
|||||||
setEditForm(prev => ({ ...prev, [field]: title }));
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<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 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -79,6 +285,135 @@ export const CMSPage: React.FC = () => {
|
|||||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
{/* Page Selection */}
|
{/* Page Selection */}
|
||||||
<div className="lg:col-span-1">
|
<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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
import { Button, Card, Input, Loading } from '../../components/common';
|
import { Button, Card, Input, Loading } from '../../components/common';
|
||||||
import { CMSEditor } from '../../components/admin/CMSEditor';
|
import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||||
import { cmsService } from '../../services/cms.service';
|
import { cmsService } from '../../services/cms.service';
|
||||||
import type { CMSPage as CMSPageType } 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 = () => {
|
export const CMSPageEnhanced: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -19,6 +21,11 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
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
|
// Fetch CMS pages
|
||||||
const { data: pages, isLoading } = useQuery({
|
const { data: pages, isLoading } = useQuery({
|
||||||
@@ -26,6 +33,16 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
queryFn: cmsService.getPages,
|
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
|
// Update page mutation
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
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
|
// Auto-save functionality
|
||||||
const autoSave = useCallback(
|
const autoSave = useCallback(
|
||||||
debounce(() => {
|
debounce(() => {
|
||||||
@@ -59,6 +123,23 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
[hasUnsavedChanges, editForm, selectedPage]
|
[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
|
// Trigger auto-save when content changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
@@ -113,6 +194,126 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
}, [hasUnsavedChanges]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -130,6 +329,135 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
{/* Page Selection */}
|
{/* Page Selection */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const EVENT_TYPES = [
|
|||||||
|
|
||||||
export const CreateEventPageEnhanced: React.FC = () => {
|
export const CreateEventPageEnhanced: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||||
@@ -226,6 +226,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const feedbackSettings = formData.feedback_settings;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
event_type: formData.event_type,
|
event_type: formData.event_type,
|
||||||
event_name: formData.event_name,
|
event_name: formData.event_name,
|
||||||
@@ -239,7 +241,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
expiration_days: formData.expires_in_days,
|
expiration_days: formData.expires_in_days,
|
||||||
allow_user_uploads: formData.allow_user_uploads,
|
allow_user_uploads: formData.allow_user_uploads,
|
||||||
upload_category_id: formData.upload_category_id,
|
upload_category_id: formData.upload_category_id,
|
||||||
feedback_settings: formData.feedback_settings,
|
feedback_enabled: feedbackSettings.feedback_enabled,
|
||||||
|
allow_ratings: feedbackSettings.allow_ratings,
|
||||||
|
allow_likes: feedbackSettings.allow_likes,
|
||||||
|
allow_comments: feedbackSettings.allow_comments,
|
||||||
|
allow_favorites: feedbackSettings.allow_favorites,
|
||||||
|
require_name_email: feedbackSettings.require_name_email,
|
||||||
|
moderate_comments: feedbackSettings.moderate_comments,
|
||||||
|
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
||||||
};
|
};
|
||||||
|
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(payload);
|
||||||
|
|||||||
@@ -29,10 +29,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
import { externalMediaService } from '../../services/externalMedia.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 { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
|
||||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
const resolveShareLink = (link: string): string => {
|
||||||
|
if (!link) return '#';
|
||||||
|
if (link.startsWith('http')) return link;
|
||||||
|
if (link.startsWith('/')) return link;
|
||||||
|
return `/gallery/${link}`;
|
||||||
|
};
|
||||||
|
|
||||||
const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
|
const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -105,15 +111,29 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [id, navigate]);
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
type EditFormState = {
|
||||||
|
welcome_message: string;
|
||||||
|
color_theme: string;
|
||||||
|
expires_at: string;
|
||||||
|
allow_user_uploads: boolean;
|
||||||
|
upload_category_id: number | null;
|
||||||
|
hero_photo_id: number | null;
|
||||||
|
host_name: string;
|
||||||
|
source_mode: 'managed' | 'reference';
|
||||||
|
external_path: string;
|
||||||
|
};
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState<EditFormState>({
|
||||||
welcome_message: '',
|
welcome_message: '',
|
||||||
color_theme: '',
|
color_theme: '',
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
allow_user_uploads: false,
|
allow_user_uploads: false,
|
||||||
upload_category_id: null as number | null,
|
upload_category_id: null,
|
||||||
hero_photo_id: null as number | null,
|
hero_photo_id: null,
|
||||||
host_name: '',
|
host_name: '',
|
||||||
|
source_mode: 'managed',
|
||||||
|
external_path: '',
|
||||||
});
|
});
|
||||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||||
feedback_enabled: false,
|
feedback_enabled: false,
|
||||||
@@ -121,8 +141,12 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
allow_likes: true,
|
allow_likes: true,
|
||||||
allow_comments: true,
|
allow_comments: true,
|
||||||
allow_favorites: true,
|
allow_favorites: true,
|
||||||
require_moderation: true,
|
require_name_email: false,
|
||||||
show_public_stats: 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 [copiedLink, setCopiedLink] = useState(false);
|
||||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||||
@@ -136,10 +160,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||||
|
|
||||||
// Photo filters state
|
// Photo filters state
|
||||||
const [photoFilters, setPhotoFilters] = useState({
|
const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({
|
||||||
category_id: undefined as number | null | undefined,
|
category_id: undefined as number | null | undefined,
|
||||||
search: '',
|
search: '',
|
||||||
sort: 'date' as 'date' | 'name' | 'size' | 'rating',
|
sort: 'date',
|
||||||
order: 'desc' as 'asc' | 'desc'
|
order: 'desc' as 'asc' | 'desc'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,6 +272,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
upload_category_id: event.upload_category_id || null,
|
upload_category_id: event.upload_category_id || null,
|
||||||
hero_photo_id: event.hero_photo_id || null,
|
hero_photo_id: event.hero_photo_id || null,
|
||||||
host_name: event.host_name || '',
|
host_name: event.host_name || '',
|
||||||
|
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||||
|
external_path: event.external_path || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set feedback settings if available
|
// Set feedback settings if available
|
||||||
@@ -296,6 +322,13 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
themeToSave = currentPresetName;
|
themeToSave = currentPresetName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const externalPathToSave = editForm.external_path?.trim() || '';
|
||||||
|
|
||||||
|
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
||||||
|
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up the data - remove undefined values
|
// Clean up the data - remove undefined values
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
expires_at: editForm.expires_at,
|
expires_at: editForm.expires_at,
|
||||||
@@ -315,6 +348,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
if (editForm.hero_photo_id !== undefined) {
|
if (editForm.hero_photo_id !== undefined) {
|
||||||
updateData.hero_photo_id = editForm.hero_photo_id;
|
updateData.hero_photo_id = editForm.hero_photo_id;
|
||||||
}
|
}
|
||||||
|
updateData.source_mode = editForm.source_mode;
|
||||||
|
updateData.external_path = editForm.source_mode === 'reference'
|
||||||
|
? externalPathToSave
|
||||||
|
: null;
|
||||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||||
updateData.host_name = editForm.host_name;
|
updateData.host_name = editForm.host_name;
|
||||||
}
|
}
|
||||||
@@ -458,11 +495,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{event.share_link && !isEditing && (
|
{event.share_link && !isEditing && (
|
||||||
<a
|
<a
|
||||||
href={
|
href={resolveShareLink(event.share_link)}
|
||||||
event.share_link.startsWith('http')
|
|
||||||
? event.share_link
|
|
||||||
: `/gallery/${event.share_link}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
|
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
|
||||||
@@ -608,6 +641,47 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.sourceMode', 'Source Mode')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editForm.source_mode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const mode = e.target.value as 'managed' | 'reference';
|
||||||
|
setEditForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
source_mode: mode,
|
||||||
|
external_path: mode === 'reference'
|
||||||
|
? (prev.external_path || event.external_path || '')
|
||||||
|
: ''
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
|
||||||
|
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editForm.source_mode === 'reference' && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
{t('events.externalFolder', 'External Folder')}
|
||||||
|
</label>
|
||||||
|
<ExternalFolderPicker
|
||||||
|
value={editForm.external_path || ''}
|
||||||
|
onChange={(folder) => setEditForm(prev => ({ ...prev, external_path: folder }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@@ -1004,9 +1078,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<PhotoFilters
|
<PhotoFilters
|
||||||
categories={categories}
|
categories={categories}
|
||||||
selectedCategory={photoFilters.category_id}
|
selectedCategory={photoFilters.category_id}
|
||||||
searchTerm={photoFilters.search}
|
searchTerm={photoFilters.search ?? ''}
|
||||||
sortBy={photoFilters.sort}
|
sortBy={photoFilters.sort ?? 'date'}
|
||||||
sortOrder={photoFilters.order}
|
sortOrder={photoFilters.order ?? 'desc'}
|
||||||
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
|
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
|
||||||
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
|
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
|
||||||
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
|
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Shield,
|
Shield,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Trash2
|
Trash2
|
||||||
@@ -25,7 +24,7 @@ import { FeedbackSettings } from '../../components/admin';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
import { feedbackService } from '../../services/feedback.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 = () => {
|
export const EventFeedbackPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -44,7 +43,7 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
// Fetch event details
|
// Fetch event details
|
||||||
const { data: event, isLoading: eventLoading } = useQuery({
|
const { data: event, isLoading: eventLoading } = useQuery({
|
||||||
queryKey: ['event', id],
|
queryKey: ['event', id],
|
||||||
queryFn: () => eventsService.getEvent(id!),
|
queryFn: () => eventsService.getEvent(Number(id)),
|
||||||
enabled: !!id
|
enabled: !!id
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,14 +55,14 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch feedback list
|
// Fetch feedback list
|
||||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
const { data: feedbackData, isLoading: feedbackLoading } = useQuery<FeedbackResponse>({
|
||||||
queryKey: ['event-feedback', id, feedbackFilter],
|
queryKey: ['event-feedback', id, feedbackFilter],
|
||||||
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
|
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
|
||||||
enabled: !!id && activeTab === 'feedback'
|
enabled: !!id && activeTab === 'feedback'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch analytics
|
// Fetch analytics
|
||||||
const { data: analytics, isLoading: analyticsLoading } = useQuery({
|
const { data: analytics, isLoading: analyticsLoading } = useQuery<FeedbackAnalytics>({
|
||||||
queryKey: ['feedback-analytics', id],
|
queryKey: ['feedback-analytics', id],
|
||||||
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
|
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
|
||||||
enabled: !!id && activeTab === 'analytics'
|
enabled: !!id && activeTab === 'analytics'
|
||||||
@@ -133,6 +132,10 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
return <div>{t('events.notFound', 'Event not found')}</div>;
|
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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -360,7 +363,7 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{feedbackData?.pagination && feedbackData.pagination.pages > 1 && (
|
{pagination && totalPages > 1 && (
|
||||||
<div className="flex justify-center gap-2 mt-4">
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -373,13 +376,13 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
<span className="flex items-center px-3 text-sm text-neutral-600">
|
<span className="flex items-center px-3 text-sm text-neutral-600">
|
||||||
{t('common.pageOf', 'Page {{current}} of {{total}}', {
|
{t('common.pageOf', 'Page {{current}} of {{total}}', {
|
||||||
current: feedbackFilter.page,
|
current: feedbackFilter.page,
|
||||||
total: feedbackData.pagination.pages
|
total: totalPages
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={feedbackFilter.page === feedbackData.pagination.pages}
|
disabled={feedbackFilter.page >= totalPages}
|
||||||
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
|
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
|
||||||
>
|
>
|
||||||
{t('common.next', 'Next')}
|
{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 { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Trash2,
|
Trash2,
|
||||||
Calendar,
|
Calendar,
|
||||||
Users,
|
|
||||||
Image,
|
Image,
|
||||||
Activity
|
Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -26,6 +25,13 @@ import { eventsService } from '../../services/events.service';
|
|||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const resolveShareLink = (link: string): string => {
|
||||||
|
if (!link) return '#';
|
||||||
|
if (link.startsWith('http')) return link;
|
||||||
|
if (link.startsWith('/')) return link;
|
||||||
|
return `/gallery/${link}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const EventsListPage: React.FC = () => {
|
export const EventsListPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
@@ -479,11 +485,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
{event.share_link ? (
|
{event.share_link ? (
|
||||||
<a
|
<a
|
||||||
href={
|
href={resolveShareLink(event.share_link)}
|
||||||
event.share_link.startsWith('http')
|
|
||||||
? event.share_link
|
|
||||||
: `/gallery/${event.share_link}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
|
|||||||
@@ -21,6 +21,37 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (Number.isNaN(value)) return defaultValue;
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.toLowerCase().trim();
|
||||||
|
if (normalized === 'true' || normalized === '1') return true;
|
||||||
|
if (normalized === 'false' || normalized === '0') return false;
|
||||||
|
if (normalized === '') return defaultValue;
|
||||||
|
return Boolean(normalized);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumber = (value: unknown, defaultValue: number): number => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -63,7 +94,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Security settings state
|
// Security settings state
|
||||||
const [securitySettings, setSecuritySettings] = useState({
|
const [securitySettings, setSecuritySettings] = useState({
|
||||||
require_password: true,
|
|
||||||
password_min_length: 8,
|
password_min_length: 8,
|
||||||
password_complexity: 'moderate',
|
password_complexity: 'moderate',
|
||||||
enable_2fa: false,
|
enable_2fa: false,
|
||||||
@@ -82,6 +112,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
umami_share_url: ''
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
// Set the language if it's different from current
|
// Set the language if it's different from current
|
||||||
@@ -92,13 +128,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
// Extract general settings
|
// Extract general settings
|
||||||
setGeneralSettings({
|
setGeneralSettings({
|
||||||
site_url: settings.general_site_url || '',
|
site_url: settings.general_site_url || '',
|
||||||
default_expiration_days: settings.general_default_expiration_days || 30,
|
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||||
max_file_size_mb: settings.general_max_file_size_mb || 50,
|
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
||||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||||
enable_watermark: settings.general_enable_watermark || false,
|
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||||
enable_analytics: settings.general_enable_analytics || true,
|
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||||
enable_registration: settings.general_enable_registration || false,
|
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||||
maintenance_mode: settings.general_maintenance_mode || false,
|
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||||
default_language: settings.general_default_language || 'en',
|
default_language: settings.general_default_language || 'en',
|
||||||
date_format: settings.general_date_format
|
date_format: settings.general_date_format
|
||||||
? (typeof settings.general_date_format === 'string'
|
? (typeof settings.general_date_format === 'string'
|
||||||
@@ -109,19 +145,19 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Extract security settings
|
// Extract security settings
|
||||||
setSecuritySettings({
|
setSecuritySettings({
|
||||||
require_password: settings.security_require_password || true,
|
password_min_length: toNumber(settings.security_password_min_length, 8),
|
||||||
password_min_length: settings.security_password_min_length || 8,
|
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||||
enable_2fa: settings.security_enable_2fa || false,
|
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||||
session_timeout_minutes: settings.security_session_timeout_minutes || 60,
|
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
||||||
max_login_attempts: settings.security_max_login_attempts || 5,
|
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
||||||
enable_recaptcha: settings.security_enable_recaptcha || false,
|
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||||
recaptcha_site_key: settings.security_recaptcha_site_key || '',
|
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||||
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
|
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract analytics settings
|
// Extract analytics settings
|
||||||
setAnalyticsSettings({
|
setAnalyticsSettings({
|
||||||
umami_enabled: settings.analytics_umami_enabled || false,
|
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
|
||||||
umami_url: settings.analytics_umami_url || '',
|
umami_url: settings.analytics_umami_url || '',
|
||||||
umami_website_id: settings.analytics_umami_website_id || '',
|
umami_website_id: settings.analytics_umami_website_id || '',
|
||||||
umami_share_url: settings.analytics_umami_share_url || ''
|
umami_share_url: settings.analytics_umami_share_url || ''
|
||||||
@@ -129,6 +165,66 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [settings, i18n]);
|
}, [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
|
// Save mutations
|
||||||
const saveGeneralMutation = useMutation({
|
const saveGeneralMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -189,6 +285,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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -455,52 +648,298 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Storage Overview */}
|
{/* Storage Overview */}
|
||||||
{storageInfo && (
|
{storageInfo && (() => {
|
||||||
<Card padding="md">
|
const configuredSoftLimit = storageInfo.configured_soft_limit ?? null;
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
const effectiveSoftLimit = storageInfo.storage_soft_limit || storageInfo.storage_limit || storageInfo.recommended_soft_limit || 1;
|
||||||
<HardDrive className="w-5 h-5" />
|
const safeEffectiveSoftLimit = Math.max(effectiveSoftLimit, 1);
|
||||||
{t('settings.systemStatus.storageOverview')}
|
const usageRatio = storageInfo.total_used / safeEffectiveSoftLimit;
|
||||||
</h2>
|
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">
|
const diskCapacityDisplay = diskCapacityBytes != null
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
? settingsService.formatBytes(diskCapacityBytes)
|
||||||
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
|
: null;
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
const diskAvailableDisplay = diskAvailableBytes != null
|
||||||
{settingsService.formatBytes(storageInfo.total_used)}
|
? settingsService.formatBytes(diskAvailableBytes)
|
||||||
</p>
|
: null;
|
||||||
</div>
|
const diskFreeDisplay = diskFreeBytes != null
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
? settingsService.formatBytes(diskFreeBytes)
|
||||||
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
|
: null;
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
const recommendedDisplay = storageInfo.recommended_soft_limit != null
|
||||||
<div className="flex justify-between text-sm mb-1">
|
? settingsService.formatBytes(storageInfo.recommended_soft_limit)
|
||||||
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
: null;
|
||||||
<span className="font-medium">
|
const progressColor = overSoftLimit
|
||||||
{Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100)}%
|
? 'bg-red-600'
|
||||||
</span>
|
: 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>
|
||||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
|
||||||
<div
|
<div className="mb-4">
|
||||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
<div className="flex justify-between text-sm mb-1">
|
||||||
style={{
|
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
||||||
width: `${Math.min((storageInfo.total_used / storageInfo.storage_limit) * 100, 100)}%`
|
<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>
|
||||||
</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 */}
|
{/* System Information */}
|
||||||
{systemStatus && (
|
{systemStatus && (
|
||||||
@@ -664,16 +1103,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={securitySettings.require_password}
|
|
||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, require_password: e.target.checked }))}
|
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.requirePassword')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('settings.security.minPasswordLength')}
|
{t('settings.security.minPasswordLength')}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const PreviewPage: React.FC = () => {
|
|||||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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 mockPhotos = useMemo(() => generateMockPhotos(12), []);
|
||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
@@ -77,6 +77,8 @@ export const PreviewPage: React.FC = () => {
|
|||||||
return a.filename.localeCompare(b.filename);
|
return a.filename.localeCompare(b.filename);
|
||||||
case 'size':
|
case 'size':
|
||||||
return b.size - a.size;
|
return b.size - a.size;
|
||||||
|
case 'rating':
|
||||||
|
return 0;
|
||||||
case 'date':
|
case 'date':
|
||||||
default:
|
default:
|
||||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||||
@@ -129,7 +131,7 @@ export const PreviewPage: React.FC = () => {
|
|||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
onSortChange={setSortBy}
|
onSortChange={(sort) => setSortBy(sort)}
|
||||||
photoCount={filteredPhotos.length}
|
photoCount={filteredPhotos.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface AnalyticsData {
|
|||||||
event_name: string;
|
event_name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
views: number;
|
views: number;
|
||||||
|
downloads?: number;
|
||||||
|
uniqueVisitors?: number;
|
||||||
}>;
|
}>;
|
||||||
devices: {
|
devices: {
|
||||||
desktop: number;
|
desktop: number;
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ interface CreateEventData {
|
|||||||
expiration_days: number;
|
expiration_days: number;
|
||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
|
feedback_enabled?: boolean;
|
||||||
|
allow_ratings?: boolean;
|
||||||
|
allow_likes?: boolean;
|
||||||
|
allow_comments?: boolean;
|
||||||
|
allow_favorites?: boolean;
|
||||||
|
require_name_email?: boolean;
|
||||||
|
moderate_comments?: boolean;
|
||||||
|
show_feedback_to_guests?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateEventData {
|
interface UpdateEventData {
|
||||||
@@ -28,6 +36,8 @@ interface UpdateEventData {
|
|||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
hero_photo_id?: number | null;
|
hero_photo_id?: number | null;
|
||||||
|
source_mode?: 'managed' | 'reference';
|
||||||
|
external_path?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventsListResponse {
|
interface EventsListResponse {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface PhotoFeedback {
|
|||||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||||
rating?: number;
|
rating?: number;
|
||||||
comment_text?: string;
|
comment_text?: string;
|
||||||
|
comment?: string;
|
||||||
guest_name?: string;
|
guest_name?: string;
|
||||||
guest_email?: string;
|
guest_email?: string;
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
@@ -29,6 +30,7 @@ export interface PhotoFeedback {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
photo_filename?: string;
|
||||||
is_mine?: boolean;
|
is_mine?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +52,11 @@ export interface FeedbackResponse {
|
|||||||
feedback: PhotoFeedback[];
|
feedback: PhotoFeedback[];
|
||||||
summary: FeedbackSummary;
|
summary: FeedbackSummary;
|
||||||
my_feedback: MyFeedback;
|
my_feedback: MyFeedback;
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackAnalytics {
|
export interface FeedbackAnalytics {
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ export const galleryService = {
|
|||||||
// Get gallery photos (requires auth)
|
// Get gallery photos (requires auth)
|
||||||
async getGalleryPhotos(
|
async getGalleryPhotos(
|
||||||
slug: string,
|
slug: string,
|
||||||
filter?: 'liked' | 'commented' | 'rated' | 'all',
|
filter?: 'liked' | 'favorited' | 'commented' | 'rated' | 'all',
|
||||||
guestId?: string
|
guestId?: string
|
||||||
): Promise<GalleryData> {
|
): Promise<GalleryData> {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (filter && filter !== 'all' && guestId) {
|
if (filter && filter !== 'all') {
|
||||||
params.filter = filter;
|
params.filter = filter;
|
||||||
params.guest_id = guestId;
|
if (guestId) {
|
||||||
|
params.guest_id = guestId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface PhotoFilters {
|
|||||||
category_id?: number | null;
|
category_id?: number | null;
|
||||||
type?: string;
|
type?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: 'date' | 'name' | 'size';
|
sort?: 'date' | 'name' | 'size' | 'rating';
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ class SecureTokenService {
|
|||||||
* Clear expired tokens from cache
|
* Clear expired tokens from cache
|
||||||
*/
|
*/
|
||||||
clearExpiredTokens(): void {
|
clearExpiredTokens(): void {
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const [key, entry] of this.tokenCache.entries()) {
|
for (const [key, entry] of this.tokenCache.entries()) {
|
||||||
if (!this.isTokenValid(entry)) {
|
if (!this.isTokenValid(entry)) {
|
||||||
this.tokenCache.delete(key);
|
this.tokenCache.delete(key);
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ export interface StorageInfo {
|
|||||||
size: number;
|
size: number;
|
||||||
}>;
|
}>;
|
||||||
storage_limit: 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 {
|
export interface SystemStatus {
|
||||||
@@ -102,6 +114,20 @@ export interface SystemStatus {
|
|||||||
timestamp: string;
|
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 = {
|
export const settingsService = {
|
||||||
// Get all settings
|
// Get all settings
|
||||||
async getAllSettings(): Promise<Record<string, any>> {
|
async getAllSettings(): Promise<Record<string, any>> {
|
||||||
@@ -120,6 +146,28 @@ export const settingsService = {
|
|||||||
await api.put('/admin/settings/branding', settings);
|
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
|
// Upload logo
|
||||||
async uploadLogo(file: File): Promise<string> {
|
async uploadLogo(file: File): Promise<string> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface Event {
|
|||||||
total_views?: number;
|
total_views?: number;
|
||||||
total_downloads?: number;
|
total_downloads?: number;
|
||||||
unique_visitors?: number;
|
unique_visitors?: number;
|
||||||
|
source_mode?: 'managed' | 'reference' | string;
|
||||||
|
external_path?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GalleryInfo {
|
export interface GalleryInfo {
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ export const cleanupOldGalleryAuth = () => {
|
|||||||
// Also clear session storage
|
// Also clear session storage
|
||||||
sessionStorage.removeItem('gallery_event');
|
sessionStorage.removeItem('gallery_event');
|
||||||
sessionStorage.removeItem('gallery_token');
|
sessionStorage.removeItem('gallery_token');
|
||||||
|
sessionStorage.removeItem('gallery_active_slug');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const TOKEN_STORAGE_PREFIX = 'gallery_token_';
|
||||||
|
const ACTIVE_SLUG_KEY = 'gallery_active_slug';
|
||||||
|
|
||||||
|
const isBrowser = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
const getSessionStorage = (): Storage | null => {
|
||||||
|
if (!isBrowser) return null;
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Session storage unavailable', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSlugFromPath = (path: string): string | null => {
|
||||||
|
if (!path) return null;
|
||||||
|
const match = path.match(/\/gallery\/([^\/?#]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inferGallerySlugFromLocation = (): string | null => {
|
||||||
|
if (!isBrowser) return null;
|
||||||
|
return extractSlugFromPath(window.location.pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setActiveGallerySlug = (slug: string | null) => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
if (slug) {
|
||||||
|
storage.setItem(ACTIVE_SLUG_KEY, slug);
|
||||||
|
} else {
|
||||||
|
storage.removeItem(ACTIVE_SLUG_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveGallerySlug = (): string | null => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
return storage.getItem(ACTIVE_SLUG_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearActiveGallerySlug = () => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
storage.removeItem(ACTIVE_SLUG_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeGalleryToken = (slug: string, token: string) => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage || !slug) return;
|
||||||
|
storage.setItem(`${TOKEN_STORAGE_PREFIX}${slug}`, token);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGalleryToken = (slug?: string | null): string | null => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
const resolvedSlug = slug || getActiveGallerySlug() || inferGallerySlugFromLocation();
|
||||||
|
if (!resolvedSlug) return null;
|
||||||
|
return storage.getItem(`${TOKEN_STORAGE_PREFIX}${resolvedSlug}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearGalleryToken = (slug?: string | null) => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${slug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = storage.getItem(ACTIVE_SLUG_KEY);
|
||||||
|
if (active) {
|
||||||
|
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${active}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllGalleryTokens = () => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < storage.length; i += 1) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (key && key.startsWith(TOKEN_STORAGE_PREFIX)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach((key) => storage.removeItem(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSlugFromRequestUrl = (url?: string | null): string | null => {
|
||||||
|
if (!url) return null;
|
||||||
|
let pathname = url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
pathname = new URL(url).pathname;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Leave pathname as provided if URL parsing fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathname.startsWith('/')) {
|
||||||
|
pathname = `/${pathname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractSlugFromPath(pathname);
|
||||||
|
};
|
||||||
@@ -24,5 +24,8 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/__tests__/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-3
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
|
|||||||
UNATTENDED=false
|
UNATTENDED=false
|
||||||
UPDATE_MODE=false
|
UPDATE_MODE=false
|
||||||
UNINSTALL_MODE=false
|
UNINSTALL_MODE=false
|
||||||
|
FORCE_ADMIN_PASSWORD_RESET=false
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
@@ -132,6 +133,37 @@ command_exists() {
|
|||||||
command -v "$1" >/dev/null 2>&1
|
command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_storage_layout() {
|
||||||
|
local base_dir="$1"
|
||||||
|
local storage_root="$base_dir/storage"
|
||||||
|
local storage_events_dir="$storage_root/events"
|
||||||
|
|
||||||
|
mkdir -p "$storage_events_dir/active" \
|
||||||
|
"$storage_events_dir/archived" \
|
||||||
|
"$storage_root/thumbnails" \
|
||||||
|
"$storage_root/tmp"
|
||||||
|
|
||||||
|
local legacy_dir="$base_dir/events"
|
||||||
|
if [[ -d "$legacy_dir" ]]; then
|
||||||
|
log_step "Migrating legacy events directory to storage/events..."
|
||||||
|
mkdir -p "$storage_events_dir"
|
||||||
|
|
||||||
|
local existing=""
|
||||||
|
if [[ -d "$storage_events_dir" ]]; then
|
||||||
|
existing=$(ls -A "$storage_events_dir" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$storage_events_dir" || -z "$existing" ]]; then
|
||||||
|
rm -rf "$storage_events_dir"
|
||||||
|
mv "$legacy_dir" "$storage_events_dir"
|
||||||
|
else
|
||||||
|
cp -a "$legacy_dir/." "$storage_events_dir/"
|
||||||
|
rm -rf "$legacy_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
mkdir -p "$storage_events_dir/active" "$storage_events_dir/archived"
|
||||||
|
}
|
||||||
|
|
||||||
generate_password() {
|
generate_password() {
|
||||||
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
|
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
|
||||||
}
|
}
|
||||||
@@ -457,6 +489,15 @@ EOF
|
|||||||
log_step "Running database migrations..."
|
log_step "Running database migrations..."
|
||||||
docker compose exec -T backend npm run migrate
|
docker compose exec -T backend npm run migrate
|
||||||
|
|
||||||
|
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||||
|
log_step "Resetting admin credentials..."
|
||||||
|
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
|
||||||
|
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
log_success "Docker installation completed!"
|
log_success "Docker installation completed!"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,7 +643,8 @@ setup_native_installation() {
|
|||||||
|
|
||||||
# Create application directory
|
# Create application directory
|
||||||
log_step "Creating application directory..."
|
log_step "Creating application directory..."
|
||||||
mkdir -p "$NATIVE_APP_DIR"/{app,events/{active,archived},logs,config}
|
mkdir -p "$NATIVE_APP_DIR"/{app,logs,config}
|
||||||
|
ensure_storage_layout "$NATIVE_APP_DIR"
|
||||||
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
||||||
|
|
||||||
# Clone repository
|
# Clone repository
|
||||||
@@ -668,7 +710,7 @@ DATABASE_CLIENT=sqlite3
|
|||||||
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
|
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
|
||||||
|
|
||||||
# Storage root (thumbnails/uploads live under this path)
|
# Storage root (thumbnails/uploads live under this path)
|
||||||
STORAGE_PATH=$NATIVE_APP_DIR
|
STORAGE_PATH=$NATIVE_APP_DIR/storage
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
SMTP_ENABLED=${SMTP_HOST:+true}
|
SMTP_ENABLED=${SMTP_HOST:+true}
|
||||||
@@ -706,6 +748,13 @@ EOF
|
|||||||
cd "$NATIVE_APP_DIR/app/backend"
|
cd "$NATIVE_APP_DIR/app/backend"
|
||||||
run_as_user "npm run migrate"
|
run_as_user "npm run migrate"
|
||||||
|
|
||||||
|
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||||
|
log_step "Resetting admin credentials..."
|
||||||
|
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
|
||||||
|
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Create systemd services
|
# Create systemd services
|
||||||
create_systemd_services
|
create_systemd_services
|
||||||
|
|
||||||
@@ -957,7 +1006,7 @@ print_success_message() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||||
echo -e "Password: ${YELLOW}(credentials file not found)${NC}"
|
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||||
@@ -1102,6 +1151,17 @@ update_native_installation() {
|
|||||||
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
|
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ensure_storage_layout "$NATIVE_APP_DIR"
|
||||||
|
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR/storage"
|
||||||
|
|
||||||
|
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
|
||||||
|
if grep -q '^STORAGE_PATH=' "$NATIVE_APP_DIR/app/backend/.env"; then
|
||||||
|
sed -i "s|^STORAGE_PATH=.*|STORAGE_PATH=$NATIVE_APP_DIR/storage|" "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
|
else
|
||||||
|
echo "STORAGE_PATH=$NATIVE_APP_DIR/storage" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
systemctl restart picpeak-backend
|
systemctl restart picpeak-backend
|
||||||
|
|
||||||
@@ -1213,6 +1273,10 @@ parse_arguments() {
|
|||||||
SMTP_PASS="$2"
|
SMTP_PASS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--force-admin-password-reset)
|
||||||
|
FORCE_ADMIN_PASSWORD_RESET=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--enable-ssl)
|
--enable-ssl)
|
||||||
ENABLE_SSL=true
|
ENABLE_SSL=true
|
||||||
shift
|
shift
|
||||||
@@ -1258,6 +1322,7 @@ Options:
|
|||||||
--smtp-port PORT SMTP server port
|
--smtp-port PORT SMTP server port
|
||||||
--smtp-user USER SMTP username
|
--smtp-user USER SMTP username
|
||||||
--smtp-pass PASS SMTP password
|
--smtp-pass PASS SMTP password
|
||||||
|
--force-admin-password-reset Regenerate admin credentials after setup
|
||||||
--enable-ssl Enable HTTPS with Let's Encrypt
|
--enable-ssl Enable HTTPS with Let's Encrypt
|
||||||
--port PORT Custom port (native only)
|
--port PORT Custom port (native only)
|
||||||
--update Update existing installation
|
--update Update existing installation
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
|
||||||
|
|
||||||
|
async function createExternalGallery(page) {
|
||||||
|
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||||
|
data: {
|
||||||
|
username: ADMIN_EMAIL,
|
||||||
|
password: ADMIN_PASSWORD,
|
||||||
|
},
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
expect(loginResponse.ok()).toBeTruthy();
|
||||||
|
const { token } = await loginResponse.json();
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
const eventName = `External Media Playwright ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const createResponse = await page.request.post('/api/admin/events', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: eventName,
|
||||||
|
event_date: eventDate,
|
||||||
|
host_name: 'External Host',
|
||||||
|
host_email: 'host@example.com',
|
||||||
|
admin_email: ADMIN_EMAIL,
|
||||||
|
password: GALLERY_PASSWORD,
|
||||||
|
expiration_days: 30,
|
||||||
|
allow_user_uploads: false,
|
||||||
|
allow_downloads: true,
|
||||||
|
disable_right_click: false,
|
||||||
|
watermark_downloads: false,
|
||||||
|
feedback_enabled: true,
|
||||||
|
allow_ratings: true,
|
||||||
|
allow_likes: true,
|
||||||
|
allow_comments: true,
|
||||||
|
allow_favorites: true,
|
||||||
|
require_name_email: false,
|
||||||
|
moderate_comments: false,
|
||||||
|
show_feedback_to_guests: true,
|
||||||
|
source_mode: 'reference',
|
||||||
|
external_path: 'picsum-demo'
|
||||||
|
},
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.ok()) {
|
||||||
|
const bodyText = await createResponse.text();
|
||||||
|
throw new Error(`Failed to create event: ${createResponse.status()} ${bodyText}`);
|
||||||
|
}
|
||||||
|
const createdEvent = await createResponse.json();
|
||||||
|
expect(createdEvent?.id).toBeTruthy();
|
||||||
|
|
||||||
|
const importResponse = await page.request.post(`/api/admin/external-media/events/${createdEvent.id}/import-external`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
external_path: 'picsum-demo',
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.ok()).toBeTruthy();
|
||||||
|
const importBody = await importResponse.json();
|
||||||
|
expect(importBody.imported).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.request.put(`/api/admin/feedback/events/${createdEvent.id}/feedback-settings`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
feedback_enabled: true,
|
||||||
|
allow_ratings: true,
|
||||||
|
allow_likes: true,
|
||||||
|
allow_comments: true,
|
||||||
|
allow_favorites: true,
|
||||||
|
require_name_email: false,
|
||||||
|
moderate_comments: false,
|
||||||
|
show_feedback_to_guests: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
shareLink: createdEvent.share_link,
|
||||||
|
slug: createdEvent.slug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('External media gallery behavior', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test('Maintains session and favorites after reload', async ({ page, context }) => {
|
||||||
|
if (test.info().project.name.includes('mobile')) {
|
||||||
|
test.skip('Mobile viewport handling requires manual verification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shareLink, slug } = await createExternalGallery(page);
|
||||||
|
|
||||||
|
await page.goto(shareLink);
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||||
|
await expect(passwordField).toBeVisible();
|
||||||
|
await passwordField.fill(GALLERY_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /View Gallery/i }).click();
|
||||||
|
|
||||||
|
const tiles = page.locator('.relative.group');
|
||||||
|
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
const initialTileCount = await tiles.count();
|
||||||
|
expect(initialTileCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const firstTile = tiles.first();
|
||||||
|
await firstTile.scrollIntoViewIfNeeded();
|
||||||
|
await firstTile.getByRole('button', { name: /View full size/i }).click();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const toggle = document.querySelector('[aria-label="Toggle feedback"]');
|
||||||
|
if (toggle instanceof HTMLElement) toggle.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const favoritesButtonInLightbox = page.getByRole('button', { name: /Add to favorites|Remove from favorites/ }).first();
|
||||||
|
await expect(favoritesButtonInLightbox).toBeVisible();
|
||||||
|
|
||||||
|
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
|
||||||
|
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
|
||||||
|
const refetchPromise = page.waitForResponse((res) => {
|
||||||
|
return res.request().method() === 'GET' && res.url().includes(`/api/gallery/${slug}/photos`);
|
||||||
|
});
|
||||||
|
if (!isAlreadyFavorited) {
|
||||||
|
const favResponsePromise = page.waitForResponse((res) => {
|
||||||
|
return res.request().method() === 'POST' && res.url().includes(`/api/gallery/${slug}/photos/`);
|
||||||
|
});
|
||||||
|
await favoritesButtonInLightbox.click();
|
||||||
|
await Promise.all([favResponsePromise, refetchPromise]);
|
||||||
|
} else {
|
||||||
|
await refetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||||
|
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/gallery\//);
|
||||||
|
await expect(page.locator('.relative.group').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||||
|
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'All', exact: true }).click();
|
||||||
|
await expect(page.locator('.relative.group')).toHaveCount(initialTileCount);
|
||||||
|
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
expect(cookies.some((cookie) => cookie.name === 'gallery_token')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user