feat: Add CSS template system with custom gallery styling support

## Changes

### CSS Template System
- Added CSS class hooks to gallery components for custom template targeting
- Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates
- CSS variables on :root allow themes to override colors, effects, and spacing

### Gallery Component CSS Classes Added
- `.gallery-page` - Main gallery container
- `.gallery-header` - Top header bar
- `.gallery-sidebar` - Filter/download sidebar
- `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close`
- `.gallery-sidebar-content`, `.gallery-sidebar-section`
- `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon`
- `.gallery-sidebar-backdrop` - Mobile overlay
- `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons
- `.gallery-footer` - Footer section
- `.photo-card`, `.photo-grid` - Photo display elements

### CSS Templates (Database)
- Elegant Dark (id=1): Dark navy theme with light text and red accents
- Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background

### Bug Fixes
- Fixed CSS variables not inheriting (moved from .gallery-page to :root)
- Fixed sidebar position breaking layout (removed position: relative override)
- Fixed Elegant Dark sidebar text visibility (white on white issue)

### Other Changes
- Settings page refactoring and cleanup
- i18n locale updates for new gallery features
- Vite proxy port configuration fix
- Admin auth route improvements
- CSS templates service updates
This commit is contained in:
Paul Nothaft
2026-01-03 08:59:01 +01:00
parent 97455ab047
commit 0da45e699a
41 changed files with 6112 additions and 2125 deletions
+1309
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -0,0 +1,693 @@
/**
* Migration: Add Liquid Glass CSS Templates
* Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs
*/
const LIQUID_GLASS_LIGHT = `/*
* PicPeak Custom CSS Template: Liquid Glass Light
* Inspired by Apple's iOS 26 Liquid Glass Design Language
*
* Features:
* - Translucent frosted glass surfaces
* - Dynamic light refraction effects
* - Subtle specular highlights
* - Soft depth shadows
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-bg-elevated: rgba(255, 255, 255, 0.85);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
--glass-blur: 20px;
--glass-saturation: 180%;
--gallery-bg: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
--gallery-text: #1a1a2e;
--gallery-text-muted: rgba(26, 26, 46, 0.7);
--gallery-accent: #667eea;
--gallery-accent-hover: #764ba2;
--gallery-radius: 24px;
--gallery-spacing: 20px;
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-bg);
min-height: 100vh;
position: relative;
}
/* Animated gradient background */
.gallery-page::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.2) 0%, transparent 40%);
pointer-events: none;
z-index: 0;
}
/* ===== Glass Card Base ===== */
.glass-surface {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
box-shadow:
var(--glass-shadow),
inset 0 1px 1px rgba(255, 255, 255, 0.8),
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
}
/* Liquid shine effect */
.glass-surface::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
border-radius: var(--gallery-radius) var(--gallery-radius) 0 0;
pointer-events: none;
}
/* ===== Gallery Header ===== */
.gallery-header {
background: var(--glass-bg-elevated);
backdrop-filter: blur(30px) saturate(200%);
-webkit-backdrop-filter: blur(30px) saturate(200%);
border-bottom: 1px solid var(--glass-border);
padding: calc(var(--gallery-spacing) * 1.5);
position: sticky;
top: 0;
z-index: 100;
}
.gallery-title {
color: var(--gallery-text);
font-weight: 700;
font-size: 1.75rem;
letter-spacing: -0.02em;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
position: relative;
z-index: 1;
}
/* ===== Photo Cards - Glass Style ===== */
.photo-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.6);
}
.photo-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.3) 0%,
transparent 100%
);
pointer-events: none;
z-index: 1;
border-radius: var(--gallery-radius) var(--gallery-radius) 0 0;
}
.photo-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow:
0 20px 40px rgba(102, 126, 234, 0.3),
0 8px 16px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.8);
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
transition: transform 0.4s ease;
}
.photo-card:hover img {
transform: scale(1.05);
}
.photo-card-info {
padding: var(--gallery-spacing);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.3) 100%
);
}
/* ===== Buttons - Glass Style ===== */
.gallery-btn {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: calc(var(--gallery-radius) / 2);
padding: 12px 24px;
color: var(--gallery-text);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.gallery-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
transparent 100%
);
}
.gallery-btn:hover {
background: var(--glass-bg-elevated);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
}
.gallery-btn-primary {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-hover) 100%);
color: white;
border: none;
}
/* ===== Lightbox - Glass Style ===== */
.lightbox-overlay {
background: rgba(26, 26, 46, 0.8);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.lightbox-content {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
/* ===== Category Pills ===== */
.category-pill {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 9999px;
padding: 8px 20px;
font-size: 0.875rem;
font-weight: 500;
color: var(--gallery-text);
transition: all 0.3s ease;
}
.category-pill:hover,
.category-pill.active {
background: var(--gallery-accent);
color: white;
border-color: var(--gallery-accent);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-radius: 16px;
--gallery-spacing: 12px;
--glass-blur: 16px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.photo-card img {
height: 180px;
}
}
/* ===== Accessibility: Reduce Motion ===== */
@media (prefers-reduced-motion: reduce) {
.photo-card,
.gallery-btn {
transition: none;
}
.photo-card:hover {
transform: none;
}
}
/* ===== Accessibility: Reduce Transparency ===== */
@media (prefers-reduced-transparency: reduce) {
.glass-surface,
.photo-card,
.gallery-btn {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: rgba(255, 255, 255, 0.95);
}
}`;
const LIQUID_GLASS_DARK = `/*
* PicPeak Custom CSS Template: Liquid Glass Dark
* Inspired by Apple's iOS 26 Liquid Glass Design Language
*
* Features:
* - Deep translucent dark surfaces
* - Neon accent highlights
* - Dramatic glass reflections
* - Subtle animated gradients
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--glass-bg: rgba(15, 15, 35, 0.7);
--glass-bg-elevated: rgba(25, 25, 55, 0.85);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-border-highlight: rgba(255, 255, 255, 0.2);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--glass-blur: 24px;
--glass-saturation: 150%;
--gallery-bg: #0a0a1a;
--gallery-text: #f0f0f5;
--gallery-text-muted: rgba(240, 240, 245, 0.6);
--gallery-accent: #00d4ff;
--gallery-accent-secondary: #ff00e5;
--gallery-accent-hover: #00ffea;
--gallery-radius: 20px;
--gallery-spacing: 20px;
/* Neon glow variables */
--neon-glow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.2);
--neon-glow-secondary: 0 0 20px rgba(255, 0, 229, 0.5), 0 0 40px rgba(255, 0, 229, 0.2);
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-bg);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
/* Animated mesh gradient background */
.gallery-page::before {
content: '';
position: fixed;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background:
radial-gradient(circle at 30% 20%, rgba(0, 212, 255, 0.15) 0%, transparent 40%),
radial-gradient(circle at 70% 80%, rgba(255, 0, 229, 0.1) 0%, transparent 40%),
radial-gradient(circle at 50% 50%, rgba(100, 100, 255, 0.05) 0%, transparent 60%);
animation: gradientShift 20s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
@keyframes gradientShift {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(2%, 2%) rotate(1deg); }
50% { transform: translate(-1%, 3%) rotate(-1deg); }
75% { transform: translate(3%, -2%) rotate(2deg); }
}
/* ===== Gallery Header ===== */
.gallery-header {
background: var(--glass-bg-elevated);
backdrop-filter: blur(30px) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(30px) saturate(var(--glass-saturation));
border-bottom: 1px solid var(--glass-border-highlight);
padding: calc(var(--gallery-spacing) * 1.5);
position: sticky;
top: 0;
z-index: 100;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.gallery-title {
color: var(--gallery-text);
font-weight: 700;
font-size: 1.75rem;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--gallery-text) 0%, var(--gallery-accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
position: relative;
z-index: 1;
}
/* ===== Photo Cards - Dark Glass Style ===== */
.photo-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Top highlight reflection */
.photo-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
z-index: 2;
}
/* Inner glow effect */
.photo-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--gallery-radius);
padding: 1px;
background: linear-gradient(
135deg,
rgba(0, 212, 255, 0) 0%,
rgba(0, 212, 255, 0) 40%,
rgba(0, 212, 255, 0.1) 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease;
}
.photo-card:hover {
transform: translateY(-8px) scale(1.02);
border-color: var(--glass-border-highlight);
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(0, 212, 255, 0.2),
var(--neon-glow);
}
.photo-card:hover::after {
opacity: 1;
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
transition: transform 0.4s ease, filter 0.4s ease;
filter: brightness(0.9);
}
.photo-card:hover img {
transform: scale(1.05);
filter: brightness(1);
}
.photo-card-info {
padding: var(--gallery-spacing);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.4) 100%
);
color: var(--gallery-text);
}
.photo-card-info p {
color: var(--gallery-text-muted);
font-size: 0.875rem;
}
/* ===== Buttons - Neon Glass Style ===== */
.gallery-btn {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: calc(var(--gallery-radius) / 2);
padding: 12px 24px;
color: var(--gallery-text);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.gallery-btn:hover {
border-color: var(--gallery-accent);
box-shadow: var(--neon-glow);
color: var(--gallery-accent);
}
.gallery-btn-primary {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border: none;
box-shadow: var(--neon-glow);
}
.gallery-btn-primary:hover {
box-shadow:
0 0 30px rgba(0, 212, 255, 0.6),
0 0 60px rgba(0, 212, 255, 0.3),
0 0 90px rgba(255, 0, 229, 0.2);
transform: translateY(-2px);
}
/* ===== Lightbox - Dark Glass ===== */
.lightbox-overlay {
background: rgba(5, 5, 15, 0.9);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.lightbox-content {
background: var(--glass-bg-elevated);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border-highlight);
border-radius: var(--gallery-radius);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.5),
var(--neon-glow);
}
/* ===== Category Pills ===== */
.category-pill {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 9999px;
padding: 8px 20px;
font-size: 0.875rem;
font-weight: 500;
color: var(--gallery-text-muted);
transition: all 0.3s ease;
}
.category-pill:hover {
border-color: var(--gallery-accent);
color: var(--gallery-accent);
box-shadow: var(--neon-glow);
}
.category-pill.active {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border-color: transparent;
box-shadow: var(--neon-glow);
}
/* ===== Scrollbar Styling ===== */
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.gallery-page ::-webkit-scrollbar-track {
background: var(--glass-bg);
border-radius: 4px;
}
.gallery-page ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
border-radius: 4px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-radius: 16px;
--gallery-spacing: 12px;
--glass-blur: 16px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.photo-card img {
height: 180px;
}
/* Reduce animation complexity on mobile */
.gallery-page::before {
animation: none;
}
}
/* ===== Accessibility: Reduce Motion ===== */
@media (prefers-reduced-motion: reduce) {
.gallery-page::before {
animation: none;
}
.photo-card,
.gallery-btn {
transition: none;
}
.photo-card:hover {
transform: none;
}
}
/* ===== Accessibility: Reduce Transparency ===== */
@media (prefers-reduced-transparency: reduce) {
.photo-card,
.gallery-btn,
.gallery-header {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.gallery-page {
--glass-bg: rgba(20, 20, 40, 0.98);
--glass-bg-elevated: rgba(30, 30, 60, 0.98);
}
}`;
exports.up = async function(knex) {
// Update template slot 2 with Liquid Glass Light
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Liquid Glass Light',
css_content: LIQUID_GLASS_LIGHT,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
// Update template slot 3 with Liquid Glass Dark
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Liquid Glass Dark',
css_content: LIQUID_GLASS_DARK,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
};
exports.down = async function(knex) {
// Revert to empty templates
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
};
// Export templates for use elsewhere
module.exports.LIQUID_GLASS_LIGHT = LIQUID_GLASS_LIGHT;
module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK;
+6 -14
View File
@@ -25,6 +25,7 @@ const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup'); 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 { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService'); const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const { getPublicSitePayload } = require('./src/services/publicSiteService'); const { getPublicSitePayload } = require('./src/services/publicSiteService');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
@@ -471,20 +472,11 @@ try {
logger.warn('Failed to enable frontend static serving', { error: e.message }); logger.warn('Failed to enable frontend static serving', { error: e.message });
} }
// Error handling middleware // 404 handler for undefined API routes
app.use((err, req, res, next) => { app.use('/api', notFoundHandler);
console.error('EXPRESS ERROR HANDLER:', err);
console.error('Error stack:', err.stack); // Global error handler (must be last)
console.error('Request URL:', req.url); app.use(errorHandler);
console.error('Request method:', req.method);
logger.error('Express error handler:', {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method
});
res.status(500).json({ error: 'Something went wrong!', details: err.message });
});
// Initialize services // Initialize services
async function startServer() { async function startServer() {
+183
View File
@@ -0,0 +1,183 @@
/**
* Global error handler middleware.
* Catches all errors and returns standardized responses.
* Distinguishes between operational errors (expected) and programming errors (bugs).
*/
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
/**
* Determines if an error is operational (expected) or a programming error (bug).
* Operational errors are expected failures like validation errors, not found, etc.
* Programming errors are bugs that should be logged and investigated.
*
* @param {Error} err - The error to check
* @returns {boolean} True if operational error
*/
const isOperationalError = (err) => {
return err instanceof AppError && err.isOperational;
};
/**
* Formats error for development environment (includes stack trace).
*
* @param {Error} err - The error object
* @returns {Object} Formatted error response
*/
const formatDevError = (err) => {
return {
error: err.message,
code: err.code || 'INTERNAL_ERROR',
stack: err.stack,
...(err.details && { details: err.details }),
...(err.field && { field: err.field })
};
};
/**
* Formats error for production environment (hides sensitive details).
*
* @param {Error} err - The error object
* @param {boolean} isOperational - Whether this is an operational error
* @returns {Object} Formatted error response
*/
const formatProdError = (err, isOperational) => {
// For operational errors, show the message
if (isOperational) {
return {
error: err.message,
code: err.code || 'ERROR',
...(err.details && { details: err.details }),
...(err.field && { field: err.field })
};
}
// For programming errors, hide details
return {
error: 'An unexpected error occurred',
code: 'INTERNAL_ERROR'
};
};
/**
* Handles specific error types and converts them to AppError format.
*
* @param {Error} err - The error to handle
* @returns {Error} Converted error or original error
*/
const handleKnownErrors = (err) => {
// Handle Knex/Database errors
if (err.code === 'SQLITE_CONSTRAINT' || err.code === '23505') {
const { AppError } = require('../utils/errors');
const error = new AppError('A record with this value already exists', 409, 'DUPLICATE_ENTRY');
error.isOperational = true;
return error;
}
// Handle JSON parsing errors
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
const { ValidationError } = require('../utils/errors');
return new ValidationError('Invalid JSON in request body');
}
// Handle multer file upload errors
if (err.code === 'LIMIT_FILE_SIZE') {
const { ValidationError } = require('../utils/errors');
return new ValidationError('File size exceeds the maximum allowed limit');
}
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
const { ValidationError } = require('../utils/errors');
return new ValidationError('Unexpected file field');
}
return err;
};
/**
* Global error handler middleware.
* Must be registered last, after all routes.
*
* @param {Error} err - The error object
* @param {Request} req - Express request object
* @param {Response} res - Express response object
* @param {Function} next - Express next function
*/
const errorHandler = (err, req, res, next) => {
// If headers already sent, delegate to Express default handler
if (res.headersSent) {
return next(err);
}
// Convert known error types
const error = handleKnownErrors(err);
// Determine error status code
const statusCode = error.statusCode || error.status || 500;
const operational = isOperationalError(error);
// Log the error
const logContext = {
url: req.originalUrl,
method: req.method,
ip: req.ip,
statusCode,
errorCode: error.code,
operational,
...(req.admin && { adminId: req.admin.id }),
...(req.gallerySlug && { gallerySlug: req.gallerySlug })
};
if (operational) {
// Operational errors are expected, log at warn level
logger.warn('Operational error', {
...logContext,
message: error.message
});
} else {
// Programming errors are bugs, log at error level with stack
logger.error('Unhandled error', {
...logContext,
message: error.message,
stack: error.stack
});
}
// Format and send response
const isDev = process.env.NODE_ENV === 'development';
const response = isDev ? formatDevError(error) : formatProdError(error, operational);
res.status(statusCode).json(response);
};
/**
* 404 handler for undefined routes.
* Should be registered after all routes but before errorHandler.
*
* @param {Request} req - Express request object
* @param {Response} res - Express response object
* @param {Function} next - Express next function
*/
const notFoundHandler = (req, res, next) => {
const { NotFoundError } = require('../utils/errors');
next(new NotFoundError('Route', req.originalUrl));
};
/**
* Async handler that catches unhandled promise rejections.
* Use this to wrap async route handlers.
*
* @param {Function} fn - Async function to wrap
* @returns {Function} Wrapped function
*/
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = {
errorHandler,
notFoundHandler,
asyncHandler,
isOperationalError
};
+121 -206
View File
@@ -1,31 +1,29 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator'); const { body } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { endSession } = require('../middleware/sessionTimeout'); const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator'); const { validatePasswordStrength } = require('../utils/passwordGenerator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const router = express.Router(); const router = express.Router();
// Change password // Get admin profile
router.get('/profile', adminAuth, async (req, res) => { router.get('/profile', adminAuth, handleAsync(async (req, res) => {
try { const admin = await db('admin_users')
const admin = await db('admin_users') .where('id', req.admin.id)
.where('id', req.admin.id) .select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword') .first();
.first();
if (!admin) { if (!admin) {
return res.status(404).json({ error: 'Admin user not found' }); throw new NotFoundError('Admin user');
}
res.json(admin);
} catch (error) {
console.error('Admin profile fetch error:', error);
res.status(500).json({ error: 'Failed to fetch admin profile' });
} }
});
res.json(admin);
}));
// Update admin profile
router.put('/profile', [ router.put('/profile', [
adminAuth, adminAuth,
body('username') body('username')
@@ -37,212 +35,129 @@ router.put('/profile', [
.isEmail() .isEmail()
.withMessage('A valid email address is required') .withMessage('A valid email address is required')
.normalizeEmail() .normalizeEmail()
], async (req, res) => { ], handleAsync(async (req, res) => {
try { validateRequest(req);
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const username = req.body.username.trim(); const username = req.body.username.trim();
const email = req.body.email.trim().toLowerCase(); const email = req.body.email.trim().toLowerCase();
const adminId = req.admin.id; const adminId = req.admin.id;
const existingUsername = await db('admin_users') // Check for username conflict
.where('username', username) const existingUsername = await db('admin_users')
.whereNot('id', adminId) .where('username', username)
.first(); .whereNot('id', adminId)
.first();
if (existingUsername) { if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use' }); throw new ConflictError('Username is already in use', 'username');
}
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', adminId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email address is already in use' });
}
await db('admin_users')
.where('id', adminId)
.update({
username,
email,
updated_at: new Date()
});
await logActivity('admin_profile_updated',
{ username, email },
null,
{ type: 'admin', id: adminId, name: req.admin.username }
);
const updatedAdmin = await db('admin_users')
.where('id', adminId)
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
.first();
res.json({
message: 'Admin profile updated successfully',
user: updatedAdmin
});
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
} }
});
// Check for email conflict
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', adminId)
.first();
if (existingEmail) {
throw new ConflictError('Email address is already in use', 'email');
}
await db('admin_users')
.where('id', adminId)
.update({
username,
email,
updated_at: new Date()
});
await logActivity('admin_profile_updated',
{ username, email },
null,
{ type: 'admin', id: adminId, name: req.admin.username }
);
const updatedAdmin = await db('admin_users')
.where('id', adminId)
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
.first();
successResponse(res, {
message: 'Admin profile updated successfully',
user: updatedAdmin
});
}));
// Change password
router.post('/change-password', [ router.post('/change-password', [
adminAuth, adminAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'), body('currentPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters') body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters')
], async (req, res) => { ], handleAsync(async (req, res) => {
try { validateRequest(req);
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { currentPassword, newPassword } = req.body; const { currentPassword, newPassword } = req.body;
const userId = req.admin.id; // Changed from req.user.id to req.admin.id const userId = req.admin.id;
// Validate new password strength // Validate new password strength
const passwordValidation = validatePasswordStrength(newPassword); const passwordValidation = validatePasswordStrength(newPassword);
if (!passwordValidation.isValid) { if (!passwordValidation.isValid) {
return res.status(400).json({ throw new ValidationError('Password does not meet security requirements', passwordValidation.messages);
error: 'Password does not meet security requirements',
details: passwordValidation.messages
});
}
// Get user from database
const user = await db('admin_users')
.where('id', userId)
.first();
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!validPassword) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
// Hash new password with more rounds
const newPasswordHash = await bcrypt.hash(newPassword, 12);
// Update password and clear must_change_password flag
await db('admin_users')
.where('id', userId)
.update({
password_hash: newPasswordHash,
must_change_password: false,
updated_at: new Date()
});
// Log activity
await logActivity('password_changed',
{ admin_id: userId },
null,
{ type: 'admin', id: userId, name: user.username }
);
res.json({ message: 'Password changed successfully' });
} catch (error) {
console.error('Password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
} }
});
// Update admin profile // Get user from database
router.put('/profile', [ const user = await db('admin_users')
adminAuth, .where('id', userId)
body('username').trim().notEmpty().withMessage('Username is required'), .first();
body('email').trim().isEmail().withMessage('Valid email is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, email } = req.body; if (!user) {
const userId = req.admin.id; throw new NotFoundError('User');
// Check for email conflicts
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', userId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email is already in use by another admin' });
}
// Check username conflict (if multiple admins are supported)
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', userId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use by another admin' });
}
await db('admin_users')
.where('id', userId)
.update({
username,
email,
updated_at: new Date()
});
const updatedUser = await db('admin_users')
.select('id', 'username', 'email', 'must_change_password')
.where('id', userId)
.first();
await logActivity(
'admin_profile_updated',
{ admin_id: userId, updated_fields: ['username', 'email'] },
null,
{ type: 'admin', id: userId, name: username }
);
res.json({ user: updatedUser });
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
} }
});
// Verify current password
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!validPassword) {
throw new ValidationError('Current password is incorrect');
}
// Hash new password with more rounds
const newPasswordHash = await bcrypt.hash(newPassword, 12);
// Update password and clear must_change_password flag
await db('admin_users')
.where('id', userId)
.update({
password_hash: newPasswordHash,
must_change_password: false,
updated_at: new Date()
});
// Log activity
await logActivity('password_changed',
{ admin_id: userId },
null,
{ type: 'admin', id: userId, name: user.username }
);
successResponse(res, { message: 'Password changed successfully' });
}));
// Logout // Logout
router.post('/logout', adminAuth, async (req, res) => { router.post('/logout', adminAuth, handleAsync(async (req, res) => {
try { // Get token from header
// Get token from header const token = req.headers.authorization?.split(' ')[1];
const token = req.headers.authorization?.split(' ')[1]; if (token) {
if (token) { // End the session
// End the session endSession(token);
endSession(token);
}
// Log activity
await logActivity('admin_logout',
{ admin_id: req.admin.id },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Logged out successfully' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: 'Failed to logout' });
} }
});
// Log activity
await logActivity('admin_logout',
{ admin_id: req.admin.id },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
successResponse(res, { message: 'Logged out successfully' });
}));
module.exports = router; module.exports = router;
+7 -3
View File
@@ -141,7 +141,8 @@ router.post('/', 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('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
], async (req, res) => { ], async (req, res) => {
try { try {
logger.debug('Create event request body', { body: req.body }); logger.debug('Create event request body', { body: req.body });
@@ -178,7 +179,9 @@ router.post('/', adminAuth, [
allow_favorites = true, allow_favorites = true,
require_name_email = false, require_name_email = false,
moderate_comments = true, moderate_comments = true,
show_feedback_to_guests = true show_feedback_to_guests = true,
// CSS Template
css_template_id = null
} = req.body; } = req.body;
const customerName = getCustomerNameFromPayload(req.body); const customerName = getCustomerNameFromPayload(req.body);
@@ -299,7 +302,8 @@ router.post('/', adminAuth, [
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text, watermark_text,
require_password: formatBoolean(requirePassword) require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
+49 -57
View File
@@ -10,6 +10,8 @@ const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
// 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');
@@ -32,69 +34,59 @@ async function checkSlugRedirect(slug) {
} }
// Resolve gallery identifier (slug or token) to canonical data // Resolve gallery identifier (slug or token) to canonical data
router.get('/resolve/:identifier', async (req, res) => { router.get('/resolve/:identifier', handleAsync(async (req, res) => {
try { const { identifier } = req.params;
const { identifier } = req.params; let result = await resolveShareIdentifier(identifier);
let result = await resolveShareIdentifier(identifier);
// If not found, check for redirect // If not found, check for redirect
if (!result) { if (!result) {
const newSlug = await checkSlugRedirect(identifier); const newSlug = await checkSlugRedirect(identifier);
if (newSlug) { if (newSlug) {
return res.status(301).json({ return res.status(301).json({
redirect: true, redirect: true,
newSlug, newSlug,
message: 'Gallery has been renamed' message: 'Gallery has been renamed'
}); });
}
return res.status(404).json({ error: 'Gallery not found' });
} }
throw new NotFoundError('Gallery');
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
} catch (error) {
logger.error('Error resolving gallery identifier:', error);
res.status(500).json({ error: 'Failed to resolve gallery link' });
} }
});
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
}));
// Verify share token // Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => { router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
try { const { slug, token } = req.params;
const { slug, token } = req.params;
const event = await db('events')
const event = await db('events') .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .select('id', 'share_link', 'share_token')
.select('id', 'share_link', 'share_token') .first();
.first();
if (!event) {
if (!event) { throw new NotFoundError('Gallery');
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
res.json({ valid: true });
} catch (error) {
console.error('Error verifying token:', error);
res.status(500).json({ error: 'Failed to verify token' });
} }
});
const expectedToken = getEventShareToken(event);
if (token !== expectedToken) {
throw new NotFoundError('Gallery', 'Invalid gallery link');
}
res.json({ valid: true });
}));
// Get gallery info (with optional token verification) // Get gallery info (with optional token verification)
router.get('/:slug/info', async (req, res) => { router.get('/:slug/info', async (req, res) => {
+121
View File
@@ -0,0 +1,121 @@
/**
* Custom error classes for standardized error handling across the application.
* These errors are caught by the global error handler and converted to appropriate HTTP responses.
*/
/**
* Base class for operational errors (expected errors that can occur during normal operation)
*/
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
error: this.message,
code: this.code,
...(process.env.NODE_ENV === 'development' && { stack: this.stack })
};
}
}
/**
* Validation error - for invalid input data (400 Bad Request)
*/
class ValidationError extends AppError {
constructor(message = 'Validation failed', details = null) {
super(message, 400, 'VALIDATION_ERROR');
this.details = details;
}
toJSON() {
return {
...super.toJSON(),
...(this.details && { details: this.details })
};
}
}
/**
* Not found error - for resources that don't exist (404 Not Found)
*/
class NotFoundError extends AppError {
constructor(resource = 'Resource', identifier = null) {
const message = identifier
? `${resource} with identifier '${identifier}' not found`
: `${resource} not found`;
super(message, 404, 'NOT_FOUND');
this.resource = resource;
this.identifier = identifier;
}
}
/**
* Unauthorized error - for missing or invalid authentication (401 Unauthorized)
*/
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED');
}
}
/**
* Forbidden error - for insufficient permissions (403 Forbidden)
*/
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403, 'FORBIDDEN');
}
}
/**
* Conflict error - for resource conflicts (409 Conflict)
*/
class ConflictError extends AppError {
constructor(message = 'Resource conflict', field = null) {
super(message, 409, 'CONFLICT');
this.field = field;
}
toJSON() {
return {
...super.toJSON(),
...(this.field && { field: this.field })
};
}
}
/**
* Rate limit error - for too many requests (429 Too Many Requests)
*/
class RateLimitError extends AppError {
constructor(message = 'Too many requests', retryAfter = null) {
super(message, 429, 'RATE_LIMIT_EXCEEDED');
this.retryAfter = retryAfter;
}
}
/**
* Service unavailable error - for maintenance mode or service issues (503 Service Unavailable)
*/
class ServiceUnavailableError extends AppError {
constructor(message = 'Service temporarily unavailable') {
super(message, 503, 'SERVICE_UNAVAILABLE');
}
}
module.exports = {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
ForbiddenError,
ConflictError,
RateLimitError,
ServiceUnavailableError
};
+180
View File
@@ -0,0 +1,180 @@
/**
* Route helper utilities for standardized request handling.
* Provides async error wrapping, validation, and response formatting.
*/
const { validationResult } = require('express-validator');
const { ValidationError } = require('./errors');
/**
* Wraps an async route handler to catch errors and pass them to the error handler.
* Eliminates the need for try/catch blocks in every route.
*
* @param {Function} fn - Async route handler function
* @returns {Function} Express middleware function
*
* @example
* router.get('/events', handleAsync(async (req, res) => {
* const events = await eventService.getAll();
* res.json(events);
* }));
*/
const handleAsync = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
/**
* Validates the request using express-validator and throws ValidationError if invalid.
* Should be called at the beginning of route handlers after validation middleware.
*
* @param {Request} req - Express request object
* @throws {ValidationError} If validation fails
*
* @example
* router.post('/events', [
* body('name').notEmpty(),
* body('date').isDate()
* ], handleAsync(async (req, res) => {
* validateRequest(req);
* // ... rest of handler
* }));
*/
const validateRequest = (req) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errorDetails = errors.array().map(err => ({
field: err.path || err.param,
message: err.msg
}));
throw new ValidationError('Validation failed', errorDetails);
}
};
/**
* Sends a standardized success response.
*
* @param {Response} res - Express response object
* @param {*} data - Data to send in the response
* @param {number} [statusCode=200] - HTTP status code
* @param {string} [message] - Optional success message
*
* @example
* successResponse(res, { event }, 201, 'Event created successfully');
*/
const successResponse = (res, data, statusCode = 200, message = null) => {
const response = message ? { message, ...data } : data;
res.status(statusCode).json(response);
};
/**
* Sends a standardized error response.
* Note: Prefer throwing custom errors and letting the error handler format the response.
*
* @param {Response} res - Express response object
* @param {string} message - Error message
* @param {number} [statusCode=500] - HTTP status code
* @param {string} [code] - Optional error code
* @param {*} [details] - Optional additional error details
*
* @example
* errorResponse(res, 'Invalid input', 400, 'VALIDATION_ERROR', { field: 'email' });
*/
const errorResponse = (res, message, statusCode = 500, code = null, details = null) => {
const response = {
error: message,
...(code && { code }),
...(details && { details })
};
res.status(statusCode).json(response);
};
/**
* Creates a route handler with built-in validation.
* Combines handleAsync and validateRequest for cleaner route definitions.
*
* @param {Function} fn - Async route handler function
* @returns {Function} Express middleware function
*
* @example
* router.post('/events', [
* body('name').notEmpty()
* ], withValidation(async (req, res) => {
* const event = await eventService.create(req.body);
* successResponse(res, { event }, 201);
* }));
*/
const withValidation = (fn) => {
return handleAsync(async (req, res, next) => {
validateRequest(req);
return fn(req, res, next);
});
};
/**
* Extracts pagination parameters from query string with defaults.
*
* @param {Request} req - Express request object
* @param {Object} [defaults] - Default values
* @param {number} [defaults.page=1] - Default page number
* @param {number} [defaults.limit=20] - Default items per page
* @param {number} [defaults.maxLimit=100] - Maximum allowed limit
* @returns {{ page: number, limit: number, offset: number }}
*
* @example
* const { page, limit, offset } = getPagination(req);
* const events = await db('events').limit(limit).offset(offset);
*/
const getPagination = (req, defaults = {}) => {
const { page: defaultPage = 1, limit: defaultLimit = 20, maxLimit = 100 } = defaults;
let page = parseInt(req.query.page, 10) || defaultPage;
let limit = parseInt(req.query.limit, 10) || defaultLimit;
// Ensure valid values
page = Math.max(1, page);
limit = Math.min(Math.max(1, limit), maxLimit);
const offset = (page - 1) * limit;
return { page, limit, offset };
};
/**
* Creates a paginated response with metadata.
*
* @param {*} data - Data array
* @param {number} total - Total count of items
* @param {number} page - Current page
* @param {number} limit - Items per page
* @returns {Object} Paginated response object
*
* @example
* const events = await db('events').limit(limit).offset(offset);
* const total = await db('events').count('* as count').first();
* res.json(paginatedResponse(events, total.count, page, limit));
*/
const paginatedResponse = (data, total, page, limit) => {
const totalPages = Math.ceil(total / limit);
return {
data,
pagination: {
page,
limit,
total,
totalPages,
hasMore: page < totalPages
}
};
};
module.exports = {
handleAsync,
validateRequest,
successResponse,
errorResponse,
withValidation,
getPagination,
paginatedResponse
};
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid } from 'lucide-react'; import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info } from 'lucide-react';
import { Button, Card, Input } from '../common'; import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types'; import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
// import { settingsService } from '../../services/settings.service'; // import { settingsService } from '../../services/settings.service';
@@ -44,6 +44,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value); const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName); const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || ''); const [customCss, setCustomCss] = useState(value.customCss || '');
const [showCssInstructions, setShowCssInstructions] = useState(false);
// const logoInputRef = useRef<HTMLInputElement>(null); // const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -494,7 +495,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div> </div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> {/* Row 1: Font Size & Border Radius */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.fontSize')} {t('branding.fontSize')}
@@ -525,7 +527,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<option value="lg">{t('branding.borderRadiusOptions.large')}</option> <option value="lg">{t('branding.borderRadiusOptions.large')}</option>
</select> </select>
</div> </div>
</div>
{/* Row 2: Shadow Style & Background Pattern */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.shadowStyle')} {t('branding.shadowStyle')}
@@ -563,7 +568,93 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Custom CSS */} {/* Custom CSS */}
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3> <h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('branding.customCSS')}
</h3>
{/* Collapsible Instructions Panel */}
<div className="mb-4">
<button
type="button"
onClick={() => setShowCssInstructions(!showCssInstructions)}
className="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
<Info className="w-4 h-4" />
{t('branding.cssInstructions.title', 'How to use Custom CSS')}
<ChevronDown className={`w-4 h-4 transition-transform ${showCssInstructions ? 'rotate-180' : ''}`} />
</button>
{showCssInstructions && (
<div className="mt-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200 text-sm space-y-4">
{/* Available CSS Variables */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
{t('branding.cssInstructions.variables', 'Theme CSS Variables')}
</h4>
<p className="text-neutral-600 mb-2">
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`--primary-color: ${localTheme.primaryColor || '#5C8762'};
--accent-color: ${localTheme.accentColor || '#22c55e'};
--background-color: ${localTheme.backgroundColor || '#fafafa'};
--text-color: ${localTheme.textColor || '#171717'};
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
</code>
</div>
{/* Custom Gallery Layouts */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
{t('branding.cssInstructions.layouts', 'Custom Gallery Layouts')}
</h4>
<p className="text-neutral-600 mb-2">
{t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`.gallery-container { /* Main gallery wrapper */ }
.gallery-grid { /* Photo grid container */ }
.gallery-item { /* Individual photo card */ }
.gallery-header { /* Header section */ }
.gallery-hero { /* Hero image area */ }
.photo-overlay { /* Photo hover overlay */ }
.photo-actions { /* Like/favorite buttons */ }`}
</code>
</div>
{/* Glassmorphism Example */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
{t('branding.cssInstructions.glassEffect', 'Glassmorphism Effect')}
</h4>
<p className="text-neutral-600 mb-2">
{t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`.glass-panel {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
}`}
</code>
</div>
{/* Tips */}
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-blue-800 text-xs">
<strong>{t('branding.cssInstructions.tip', 'Tip')}:</strong>{' '}
{t('branding.cssInstructions.tipText', 'Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass.')}
</div>
</div>
</div>
)}
</div>
<textarea <textarea
value={customCss} value={customCss}
onChange={(e) => { onChange={(e) => {
@@ -575,7 +666,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
} }
}} }}
placeholder="/* Add custom CSS here */" placeholder="/* Add custom CSS here */"
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg" className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
/> />
<p className="mt-2 text-sm text-neutral-600"> <p className="mt-2 text-sm text-neutral-600">
{t('branding.customCSSHelp')} {t('branding.customCSSHelp')}
@@ -118,12 +118,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const heroLogoSize = getLogoDimensions('hero'); const heroLogoSize = getLogoDimensions('hero');
return ( return (
<div className="min-h-screen bg-neutral-50"> <div className="gallery-page min-h-screen bg-neutral-50">
{/* Dynamic Favicon */} {/* Dynamic Favicon */}
<DynamicFavicon /> <DynamicFavicon />
{/* Header structure */} {/* Header structure */}
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}> <header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts (excluding hero) - keep the current structure */} {/* For non-grid layouts (excluding hero) - keep the current structure */}
{isNonGridLayout && ( {isNonGridLayout && (
<div className="bg-neutral-50 border-b border-neutral-200"> <div className="bg-neutral-50 border-b border-neutral-200">
@@ -145,12 +145,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
leftIcon={<Download className="w-4 h-4" />} leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll} onClick={onDownloadAll}
isLoading={isDownloading} isLoading={isDownloading}
className="gallery-btn gallery-btn-download"
> >
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span> <span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span> <span className="sm:hidden">{t('common.download')}</span>
</Button> </Button>
)} )}
{/* Logout button */} {/* Logout button */}
{showLogout && onLogout && ( {showLogout && onLogout && (
<Button <Button
@@ -158,7 +159,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
size="sm" size="sm"
leftIcon={<LogOut className="w-4 h-4" />} leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout} onClick={onLogout}
className="sm:min-w-0" className="gallery-btn gallery-btn-logout sm:min-w-0"
> >
<span className="hidden sm:inline">{t('common.logout')}</span> <span className="hidden sm:inline">{t('common.logout')}</span>
</Button> </Button>
@@ -184,14 +185,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Logo - Show custom logo or fallback to PicPeak logo */} {/* Logo - Show custom logo or fallback to PicPeak logo */}
{shouldShowLogo('header') && ( {shouldShowLogo('header') && (
<div className={`flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}> <div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
<img <img
src={brandingSettings?.logo_url ? src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) : buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt={brandingSettings?.company_name || 'PicPeak'} alt={brandingSettings?.company_name || 'PicPeak'}
className={`${headerLogoSize.className} w-auto object-contain`} className={`gallery-logo ${headerLogoSize.className} w-auto object-contain`}
style={headerLogoSize.style} style={headerLogoSize.style}
/> />
{shouldShowCompanyName() && brandingSettings?.company_name && ( {shouldShowCompanyName() && brandingSettings?.company_name && (
@@ -253,13 +254,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
leftIcon={<Download className="w-4 h-4" />} leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll} onClick={onDownloadAll}
isLoading={isDownloading} isLoading={isDownloading}
className="hidden sm:flex" className="gallery-btn gallery-btn-download hidden sm:flex"
> >
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span> <span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span> <span className="sm:hidden">{t('common.download')}</span>
</Button> </Button>
)} )}
{/* Logout button */} {/* Logout button */}
{showLogout && onLogout && ( {showLogout && onLogout && (
<Button <Button
@@ -267,7 +268,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
size="sm" size="sm"
leftIcon={<LogOut className="w-4 h-4" />} leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout} onClick={onLogout}
className="sm:min-w-0" className="gallery-btn gallery-btn-logout sm:min-w-0"
> >
<span className="hidden sm:inline">{t('common.logout')}</span> <span className="hidden sm:inline">{t('common.logout')}</span>
</Button> </Button>
@@ -304,7 +305,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{menuButton} {menuButton}
{headerExtra} {headerExtra}
</div> </div>
{/* Right side - Action buttons */} {/* Right side - Action buttons */}
<div className="flex items-center gap-3 flex-shrink-0"> <div className="flex items-center gap-3 flex-shrink-0">
{/* Download all button */} {/* Download all button */}
@@ -315,12 +316,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
leftIcon={<Download className="w-4 h-4" />} leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll} onClick={onDownloadAll}
isLoading={isDownloading} isLoading={isDownloading}
className="gallery-btn gallery-btn-download"
> >
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span> <span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span> <span className="sm:hidden">{t('common.download')}</span>
</Button> </Button>
)} )}
{/* Logout button */} {/* Logout button */}
{showLogout && onLogout && ( {showLogout && onLogout && (
<Button <Button
@@ -328,7 +330,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
size="sm" size="sm"
leftIcon={<LogOut className="w-4 h-4" />} leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout} onClick={onLogout}
className="sm:min-w-0" className="gallery-btn gallery-btn-logout sm:min-w-0"
> >
<span className="hidden sm:inline">{t('common.logout')}</span> <span className="hidden sm:inline">{t('common.logout')}</span>
</Button> </Button>
@@ -341,8 +343,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */} {/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
{isNonGridLayout && ( {isNonGridLayout && (
<div <div
className="relative text-white overflow-hidden" className="gallery-hero relative text-white overflow-hidden"
style={{ style={{
backgroundColor: theme.accentColor || '#22c55e', backgroundColor: theme.accentColor || '#22c55e',
backgroundImage: theme.backgroundPattern !== 'none' backgroundImage: theme.backgroundPattern !== 'none'
@@ -425,7 +427,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<main className="container">{children}</main> <main className="container">{children}</main>
{/* Footer */} {/* Footer */}
<footer className="mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200"> <footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
<div className="container text-center px-4"> <div className="container text-center px-4">
{brandingSettings?.support_email && ( {brandingSettings?.support_email && (
<p className="text-xs sm:text-sm text-neutral-600 mb-2"> <p className="text-xs sm:text-sm text-neutral-600 mb-2">
@@ -110,8 +110,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<> <>
{/* Backdrop for mobile */} {/* Backdrop for mobile */}
{isMobile && isOpen && ( {isMobile && isOpen && (
<div <div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity" className="gallery-sidebar-backdrop fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose} onClick={onClose}
/> />
)} )}
@@ -120,17 +120,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<div <div
ref={sidebarRef} ref={sidebarRef}
className={` className={`
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col gallery-sidebar fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
${isMobile ? 'w-full max-w-sm' : 'w-80'} ${isMobile ? 'w-full max-w-sm' : 'w-80'}
${isOpen ? 'translate-x-0' : '-translate-x-full'} ${isOpen ? 'translate-x-0' : '-translate-x-full'}
`} `}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-neutral-200"> <div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-neutral-200">
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2> <h2 className="gallery-sidebar-title text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors" className="gallery-sidebar-close p-2 hover:bg-neutral-100 rounded-lg transition-colors"
aria-label={t('common.close')} aria-label={t('common.close')}
> >
<X className="w-5 h-5 text-neutral-600" /> <X className="w-5 h-5 text-neutral-600" />
@@ -138,7 +138,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="gallery-sidebar-content flex-1 overflow-y-auto">
{/* Upload Section - Only show on mobile when uploads are allowed */} {/* Upload Section - Only show on mobile when uploads are allowed */}
{isMobile && allowUploads && onUploadClick && ( {isMobile && allowUploads && onUploadClick && (
<div className="p-4 border-b border-neutral-200"> <div className="p-4 border-b border-neutral-200">
@@ -159,15 +159,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Search Section - Hidden for carousel layout */} {/* Search Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && ( {galleryLayout !== 'carousel' && (
<div className="p-4 border-b border-neutral-200"> <div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-neutral-200">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" /> <Search className="gallery-sidebar-search-icon absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
placeholder={t('gallery.searchPlaceholder')} placeholder={t('gallery.searchPlaceholder')}
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" className="gallery-sidebar-search-input w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/> />
</div> </div>
</div> </div>
@@ -175,12 +175,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Download Section - Hidden if gallery is expired or downloads disabled */} {/* Download Section - Hidden if gallery is expired or downloads disabled */}
{allowDownloads && ( {allowDownloads && (
<div className="p-4 border-b border-neutral-200"> <div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2"> <h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
{t('gallery.download')} {t('gallery.download')}
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
<Button <Button
variant="primary" variant="primary"
@@ -188,7 +188,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
leftIcon={<Download className="w-4 h-4" />} leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll} onClick={onDownloadAll}
disabled={isDownloading || totalPhotos === 0} disabled={isDownloading || totalPhotos === 0}
className="w-full" className="gallery-btn gallery-btn-download w-full"
> >
{t('gallery.downloadAll')} ({totalPhotos}) {t('gallery.downloadAll')} ({totalPhotos})
</Button> </Button>
@@ -197,7 +197,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
variant={isSelectionMode ? 'secondary' : 'outline'} variant={isSelectionMode ? 'secondary' : 'outline'}
size="sm" size="sm"
onClick={onToggleSelectionMode} onClick={onToggleSelectionMode}
className="w-full" className="gallery-btn w-full"
> >
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')} {isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button> </Button>
@@ -209,7 +209,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
leftIcon={<Download className="w-4 h-4" />} leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadSelected} onClick={onDownloadSelected}
disabled={isDownloading} disabled={isDownloading}
className="w-full" className="gallery-btn gallery-btn-download w-full"
> >
{t('gallery.downloadSelected', { count: selectedCount })} ({selectedCount}) {t('gallery.downloadSelected', { count: selectedCount })} ({selectedCount})
</Button> </Button>
@@ -220,7 +220,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Feedback Filter Section */} {/* Feedback Filter Section */}
{feedbackEnabled && onFilterChange && ( {feedbackEnabled && onFilterChange && (
<div className="p-4 border-b border-neutral-200"> <div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-neutral-200">
<GalleryFilter <GalleryFilter
currentFilter={filterType} currentFilter={filterType}
onFilterChange={(filter) => { onFilterChange={(filter) => {
@@ -239,12 +239,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Categories Section - Hidden for carousel layout */} {/* Categories Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && categories.length > 0 && ( {galleryLayout !== 'carousel' && categories.length > 0 && (
<div className="p-4 border-b border-neutral-200"> <div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2"> <h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{t('gallery.categories')} {t('gallery.categories')}
</h3> </h3>
<div className="space-y-1"> <div className="space-y-1">
<button <button
onClick={() => { onClick={() => {
@@ -252,7 +252,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
if (isMobile) onClose(); if (isMobile) onClose();
}} }}
className={` className={`
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${selectedCategoryId === null ${selectedCategoryId === null
? 'bg-primary-50 text-primary-700' ? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700' : 'hover:bg-neutral-50 text-neutral-700'
@@ -266,7 +266,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{categories.map((category) => { {categories.map((category) => {
const count = photoCounts[category.id] || 0; const count = photoCounts[category.id] || 0;
const isSelected = selectedCategoryId === category.id; const isSelected = selectedCategoryId === category.id;
return ( return (
<button <button
key={category.id} key={category.id}
@@ -275,7 +275,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
if (isMobile) onClose(); if (isMobile) onClose();
}} }}
className={` className={`
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${isSelected ${isSelected
? 'bg-primary-50 text-primary-700' ? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700' : 'hover:bg-neutral-50 text-neutral-700'
@@ -295,8 +295,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
)} )}
{showMediaFilter && onMediaFilterChange && ( {showMediaFilter && onMediaFilterChange && (
<div className="p-4 border-b border-neutral-200"> <div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2"> <h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{t('gallery.mediaType', 'Media')} {t('gallery.mediaType', 'Media')}
</h3> </h3>
@@ -304,6 +304,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<Button <Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'} variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm" size="sm"
className="gallery-btn"
onClick={() => { onClick={() => {
onMediaFilterChange('all'); onMediaFilterChange('all');
if (isMobile) onClose(); if (isMobile) onClose();
@@ -314,6 +315,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<Button <Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'} variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm" size="sm"
className="gallery-btn"
onClick={() => { onClick={() => {
onMediaFilterChange('photo'); onMediaFilterChange('photo');
if (isMobile) onClose(); if (isMobile) onClose();
@@ -324,6 +326,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<Button <Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'} variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm" size="sm"
className="gallery-btn"
onClick={() => { onClick={() => {
onMediaFilterChange('video'); onMediaFilterChange('video');
if (isMobile) onClose(); if (isMobile) onClose();
@@ -337,17 +340,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Sort Section - Hidden for carousel and timeline layouts */} {/* Sort Section - Hidden for carousel and timeline layouts */}
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && ( {galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
<div className="p-4"> <div className="gallery-sidebar-section gallery-sidebar-sort p-4">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2"> <h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<SortAsc className="w-4 h-4" /> <SortAsc className="w-4 h-4" />
{t('gallery.sortBy')} {t('gallery.sortBy')}
</h3> </h3>
<div className="space-y-1"> <div className="space-y-1">
{sortOptions.map((option) => { {sortOptions.map((option) => {
const Icon = option.icon; const Icon = option.icon;
const isSelected = sortBy === option.value; const isSelected = sortBy === option.value;
return ( return (
<button <button
key={option.value} key={option.value}
@@ -356,7 +359,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
if (isMobile) onClose(); if (isMobile) onClose();
}} }}
className={` className={`
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3 gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
${isSelected ${isSelected
? 'bg-primary-50 text-primary-700' ? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700' : 'hover:bg-neutral-50 text-neutral-700'
@@ -21,6 +21,7 @@ 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 { useWatermarkSettings } from '../../hooks/useWatermarkSettings'; import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import type { Photo } from '../../types'; import type { Photo } from '../../types';
interface GalleryViewProps { interface GalleryViewProps {
@@ -55,6 +56,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const [feedbackEnabled, setFeedbackEnabled] = useState(false); const [feedbackEnabled, setFeedbackEnabled] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings(); const { watermarkEnabled } = useWatermarkSettings();
// Load and inject custom CSS for this gallery
useGalleryCustomCss(slug);
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard'); const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all'); const [filterType, setFilterType] = useState<FilterType>('all');
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all'); const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
@@ -564,6 +569,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="gallery-btn"
leftIcon={<Menu className="w-4 h-4" />} leftIcon={<Menu className="w-4 h-4" />}
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={t('gallery.toggleMenu')} aria-label={t('gallery.toggleMenu')}
@@ -70,9 +70,9 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback); const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
return ( return (
<div className="relative"> <div className="photo-grid relative">
{/* Main Carousel */} {/* Main Carousel */}
<div className="relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden"> <div className="photo-card relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
<AuthenticatedImage <AuthenticatedImage
src={currentPhoto.url} src={currentPhoto.url}
alt={currentPhoto.filename} alt={currentPhoto.filename}
@@ -176,7 +176,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
return ( return (
<div <div
ref={ref} ref={ref}
className={`relative group cursor-pointer aspect-square ${animationClass}`} className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
onClick={handlePhotoClick} onClick={handlePhotoClick}
style={{ style={{
opacity: !inView && animationType === 'fade' ? 0 : 1 opacity: !inView && animationType === 'fade' ? 0 : 1
@@ -375,10 +375,10 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4'; const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `grid ${spacingClass} const gridClass = `photo-grid grid ${spacingClass}
grid-cols-${columns.mobile} grid-cols-${columns.mobile}
sm:grid-cols-${columns.tablet} sm:grid-cols-${columns.tablet}
lg:grid-cols-${columns.desktop} lg:grid-cols-${columns.desktop}
xl:grid-cols-${columns.desktop + 1}`; xl:grid-cols-${columns.desktop + 1}`;
return ( return (
@@ -187,13 +187,13 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
</div> </div>
{/* Grid Section */} {/* Grid Section */}
<div id="gallery-grid-section" className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div id="gallery-grid-section" className="photo-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => { {remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id); const actualIndex = photos.findIndex(p => p.id === photo.id);
return ( return (
<div <div
key={photo.id} key={photo.id}
className="relative group cursor-pointer overflow-hidden rounded-lg" className="photo-card relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => onPhotoClick(actualIndex)} onClick={() => onPhotoClick(actualIndex)}
> >
<AuthenticatedImage <AuthenticatedImage
@@ -54,7 +54,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
return ( return (
<div <div
className="relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]" className="photo-card relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
onClick={onClick} onClick={onClick}
style={{ style={{
...style, ...style,
@@ -243,9 +243,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}); });
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="flex gap-4" className="photo-grid flex gap-4"
style={{ gap: `${gutter}px` }} style={{ gap: `${gutter}px` }}
> >
{photoColumns.map((column, columnIndex) => ( {photoColumns.map((column, columnIndex) => (
@@ -49,7 +49,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
return ( return (
<> <>
<div <div
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`} className={`photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClick(e); onClick(e);
@@ -417,7 +417,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}; };
return ( return (
<div className="w-full max-w-7xl mx-auto"> <div className="photo-grid w-full max-w-7xl mx-auto">
{renderMosaicLayout()} {renderMosaicLayout()}
</div> </div>
); );
@@ -94,13 +94,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
)} )}
{/* Photos grid for this date */} {/* Photos grid for this date */}
<div className="lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="photo-grid lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{group.photos.map((photo) => { {group.photos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id); const actualIndex = photos.findIndex(p => p.id === photo.id);
return ( return (
<div <div
key={photo.id} key={photo.id}
className="relative group cursor-pointer aspect-square" className="photo-card relative group cursor-pointer aspect-square"
onClick={() => onPhotoClick(actualIndex)} onClick={() => onPhotoClick(actualIndex)}
> >
<AuthenticatedImage <AuthenticatedImage
@@ -0,0 +1,505 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../../services/settings.service';
import { adminService } from '../../../services/admin.service';
import { useAdminAuth } from '../../../contexts';
import { toBoolean, toNumber } from '../../../utils/parsers';
const BYTES_PER_GB = 1024 * 1024 * 1024;
export const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
export interface GeneralSettings {
site_url: string;
default_expiration_days: number;
max_file_size_mb: number;
max_files_per_upload: number;
allowed_file_types: string;
enable_watermark: boolean;
enable_analytics: boolean;
enable_registration: boolean;
maintenance_mode: boolean;
short_gallery_urls: boolean;
default_language: string;
date_format: { format: string; locale: string };
}
export interface SecuritySettings {
password_min_length: number;
password_complexity: string;
enable_2fa: boolean;
session_timeout_minutes: number;
max_login_attempts: number;
attempt_window_minutes: number;
lockout_duration_minutes: number;
enable_recaptcha: boolean;
recaptcha_site_key: string;
recaptcha_secret_key: string;
}
export interface AnalyticsSettings {
umami_enabled: boolean;
umami_url: string;
umami_website_id: string;
umami_share_url: string;
}
export interface EventSettings {
event_require_customer_name: boolean;
event_require_customer_email: boolean;
event_require_admin_email: boolean;
}
export function useSettingsState() {
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { updateUserProfile } = useAdminAuth();
// Fetch settings
const { data: settings, isLoading } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
queryKey: ['admin-profile'],
queryFn: () => adminService.getAdminProfile(),
});
// General settings state
const [generalSettings, setGeneralSettings] = useState<GeneralSettings>({
site_url: '',
default_expiration_days: 30,
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
enable_watermark: false,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
short_gallery_urls: false,
default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
});
// Security settings state
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
password_min_length: 8,
password_complexity: 'moderate',
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
attempt_window_minutes: 15,
lockout_duration_minutes: 30,
enable_recaptcha: false,
recaptcha_site_key: '',
recaptcha_secret_key: ''
});
// Analytics settings state
const [analyticsSettings, setAnalyticsSettings] = useState<AnalyticsSettings>({
umami_enabled: false,
umami_url: '',
umami_website_id: '',
umami_share_url: ''
});
// Event creation settings state
const [eventSettings, setEventSettings] = useState<EventSettings>({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true
});
// Account form state
const [accountForm, setAccountForm] = useState({
username: '',
email: ''
});
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
// Storage state
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);
// Initialize settings from API
useEffect(() => {
if (settings) {
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
i18n.changeLanguage(settings.general_default_language);
}
setGeneralSettings({
site_url: settings.general_site_url || '',
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
max_files_per_upload: Math.min(
MAX_FILES_PER_UPLOAD_LIMIT,
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
enable_watermark: toBoolean(settings.general_enable_watermark, false),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format
? (typeof settings.general_date_format === 'string'
? { format: settings.general_date_format, locale: settings.general_date_format.includes('MM/dd') ? 'en-US' : 'en-GB' }
: settings.general_date_format)
: { format: 'dd/MM/yyyy', locale: 'en-GB' }
});
setSecuritySettings({
password_min_length: toNumber(settings.security_password_min_length, 8),
password_complexity: settings.security_password_complexity ?? 'moderate',
enable_2fa: toBoolean(settings.security_enable_2fa, false),
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
});
setAnalyticsSettings({
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
});
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
});
}
}, [settings, i18n]);
useEffect(() => {
if (adminProfile) {
setAccountForm({
username: adminProfile.username || '',
email: adminProfile.email || ''
});
}
}, [adminProfile]);
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]);
// Mutations
const saveGeneralMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(generalSettings).forEach(([key, value]) => {
if (key === 'date_format' && typeof value === 'object' && value.format) {
settingsData[`general_${key}`] = value.format;
} else {
settingsData[`general_${key}`] = value;
}
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveSecurityMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(securitySettings).forEach(([key, value]) => {
settingsData[`security_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveAnalyticsMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => {
settingsData[`analytics_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveEventSettingsMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(eventSettings).forEach(([key, value]) => {
settingsData[key] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const updateAdminProfileMutation = useMutation({
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
onSuccess: (updatedUser) => {
toast.success(t('settings.general.accountSaveSuccess'));
setAccountErrors({});
setAccountForm({
username: updatedUser.username,
email: updatedUser.email
});
updateUserProfile(updatedUser);
queryClient.invalidateQueries({ queryKey: ['admin-profile'] });
},
onError: (error: { response?: { data?: { errors?: Array<{ path: string; msg: string }>; error?: string } } }) => {
if (error.response?.data?.errors) {
const fieldErrors: Record<string, string> = {};
for (const err of error.response.data.errors) {
if (err.path === 'username') {
fieldErrors.username = err.msg;
}
if (err.path === 'email') {
fieldErrors.email = err.msg;
}
}
setAccountErrors(fieldErrors);
return;
}
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t('toast.saveError'));
}
}
});
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 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'));
}
});
// Handlers
const handleAccountChange = (field: 'username' | 'email') => (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setAccountForm((prev) => ({ ...prev, [field]: value }));
if (accountErrors[field]) {
setAccountErrors((prev) => ({ ...prev, [field]: '' }));
}
};
const handleAccountSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (updateAdminProfileMutation.isPending) return;
const trimmedUsername = accountForm.username.trim();
const trimmedEmail = accountForm.email.trim();
const errors: Record<string, string> = {};
if (!trimmedUsername) {
errors.username = t('settings.general.accountUsernameRequired');
} else if (trimmedUsername.length < 3) {
errors.username = t('settings.general.accountUsernameLength');
}
if (!trimmedEmail) {
errors.email = t('settings.general.accountEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
errors.email = t('settings.general.accountEmailInvalid');
}
if (Object.keys(errors).length > 0) {
setAccountErrors(errors);
return;
}
updateAdminProfileMutation.mutate({
username: trimmedUsername,
email: trimmedEmail
});
};
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 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 });
};
return {
// Loading states
isLoading,
adminProfileLoading,
// Settings data
settings,
generalSettings,
setGeneralSettings,
securitySettings,
setSecuritySettings,
analyticsSettings,
setAnalyticsSettings,
eventSettings,
setEventSettings,
// Account form
accountForm,
accountErrors,
handleAccountChange,
handleAccountSubmit,
updateAdminProfileMutation,
// Storage settings
softLimitGb,
setSoftLimitGb,
softLimitDirty,
setSoftLimitDirty,
capacityOverrideGb,
setCapacityOverrideGb,
availableOverrideGb,
setAvailableOverrideGb,
overrideDirty,
setOverrideDirty,
handleSaveSoftLimit,
handleSaveCapacityOverride,
saveSoftLimitMutation,
saveCapacityOverrideMutation,
// Save mutations
saveGeneralMutation,
saveSecurityMutation,
saveAnalyticsMutation,
saveEventSettingsMutation,
// Translation
t,
};
}
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../../../services/settings.service';
export function useStatusTab(isActive: boolean) {
// Fetch storage info
const { data: storageInfo } = useQuery({
queryKey: ['admin-storage-info'],
queryFn: () => settingsService.getStorageInfo(),
enabled: isActive
});
// Fetch system status
const { data: systemStatus } = useQuery({
queryKey: ['system-status'],
queryFn: () => settingsService.getSystemStatus(),
enabled: isActive,
refetchInterval: 30000
});
return {
storageInfo,
systemStatus,
};
}
+14
View File
@@ -0,0 +1,14 @@
// Hooks
export { useSettingsState, MAX_FILES_PER_UPLOAD_LIMIT } from './hooks/useSettingsState';
export type { GeneralSettings, SecuritySettings, AnalyticsSettings, EventSettings } from './hooks/useSettingsState';
export { useStatusTab } from './hooks/useStatusTab';
// Tab components
export { GeneralTab } from './tabs/GeneralTab';
export { EventsTab } from './tabs/EventsTab';
export { StatusTab } from './tabs/StatusTab';
export { SecurityTab } from './tabs/SecurityTab';
export { CategoriesTab } from './tabs/CategoriesTab';
export { AnalyticsTab } from './tabs/AnalyticsTab';
export { ModerationTab } from './tabs/ModerationTab';
export { StylingTab } from './tabs/StylingTab';
@@ -0,0 +1,142 @@
import React from 'react';
import { Save, Globe, Key, Activity, AlertCircle } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { AnalyticsSettings } from '../hooks/useSettingsState';
interface AnalyticsTabProps {
analyticsSettings: AnalyticsSettings;
setAnalyticsSettings: React.Dispatch<React.SetStateAction<AnalyticsSettings>>;
saveAnalyticsMutation: {
mutate: () => void;
isPending: boolean;
};
}
export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
analyticsSettings,
setAnalyticsSettings,
saveAnalyticsMutation,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={analyticsSettings.umami_enabled}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: 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.analytics.enableUmami')}</span>
</label>
{analyticsSettings.umami_enabled && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
<p>{t('settings.analytics.umamiInfoText')}</p>
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
{t('settings.analytics.learnMore')}
</a>
</div>
</div>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveAnalyticsMutation.mutate()}
isLoading={saveAnalyticsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.analytics.saveAnalyticsSettings')}
</Button>
</div>
</Card>
{/* Backend Analytics Info */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
<ul className="text-xs text-neutral-600 space-y-1">
<li> {t('settings.analytics.galleryViews')}</li>
<li> {t('settings.analytics.photoDownloads')}</li>
<li> {t('settings.analytics.uniqueVisitors')}</li>
<li> {t('settings.analytics.deviceTypes')}</li>
</ul>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
<p className="text-xs text-neutral-600">
{t('settings.analytics.privacyText')}
</p>
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,29 @@
import React from 'react';
import { Image } from 'lucide-react';
import { Card } from '../../../components/common';
import { CategoryManager } from '../../../components/admin/CategoryManager';
import { useTranslation } from 'react-i18next';
export const CategoriesTab: React.FC = () => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<CategoryManager />
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-blue-900">{t('settings.categories.about')}</h3>
<p className="text-sm text-blue-700 mt-1">
{t('settings.categories.aboutText')}
</p>
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,129 @@
import React from 'react';
import { Save, AlertCircle } from 'lucide-react';
import { Button, Card } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { EventSettings } from '../hooks/useSettingsState';
interface EventsTabProps {
eventSettings: EventSettings;
setEventSettings: React.Dispatch<React.SetStateAction<EventSettings>>;
saveEventSettingsMutation: {
mutate: () => void;
isPending: boolean;
};
}
export const EventsTab: React.FC<EventsTabProps> = ({
eventSettings,
setEventSettings,
saveEventSettingsMutation,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.events.requiredFields', 'Required Fields')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.events.requiredFieldsDescription', 'Configure which contact fields are required when creating new events.')}
</p>
<div className="space-y-4">
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_name}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_name: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerName', 'Require customer name')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerNameHelp', 'Customer name must be provided for new events')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerEmail', 'Require customer email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerEmailHelp', 'Customer email must be provided for new events')}
</p>
{!eventSettings.event_require_customer_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.customerEmailWarning', 'Required for sending gallery invitations')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_admin_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_admin_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireAdminEmail', 'Require admin email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireAdminEmailHelp', 'Admin email must be provided for new events')}
</p>
{!eventSettings.event_require_admin_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.adminEmailWarning', 'Required for receiving event notifications')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveEventSettingsMutation.mutate()}
isLoading={saveEventSettingsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.events.saveSettings', 'Save Event Settings')}
</Button>
</div>
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
<p>
{t('settings.events.noteText', 'These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields.')}
</p>
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,311 @@
import React from 'react';
import { Save, Globe, Mail, User } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { GeneralSettings } from '../hooks/useSettingsState';
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
interface GeneralTabProps {
generalSettings: GeneralSettings;
setGeneralSettings: React.Dispatch<React.SetStateAction<GeneralSettings>>;
saveGeneralMutation: {
mutate: () => void;
isPending: boolean;
};
accountForm: { username: string; email: string };
accountErrors: Record<string, string>;
handleAccountChange: (field: 'username' | 'email') => (e: React.ChangeEvent<HTMLInputElement>) => void;
handleAccountSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
updateAdminProfileMutation: { isPending: boolean };
adminProfileLoading: boolean;
}
export const GeneralTab: React.FC<GeneralTabProps> = ({
generalSettings,
setGeneralSettings,
saveGeneralMutation,
accountForm,
accountErrors,
handleAccountChange,
handleAccountSubmit,
updateAdminProfileMutation,
adminProfileLoading,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
{adminProfileLoading ? (
<div className="py-8 flex justify-center">
<Loading size="md" />
</div>
) : (
<form className="space-y-4" onSubmit={handleAccountSubmit}>
<div>
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountUsername')}
</label>
<Input
id="admin-account-username"
type="text"
value={accountForm.username}
onChange={handleAccountChange('username')}
placeholder="admin"
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
error={accountErrors.username}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountUsernameHelp')}
</p>
</div>
<div>
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountEmail')}
</label>
<Input
id="admin-account-email"
type="email"
value={accountForm.email}
onChange={handleAccountChange('email')}
placeholder="admin@example.com"
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
error={accountErrors.email}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountEmailHelp')}
</p>
</div>
<div className="pt-2">
<Button
type="submit"
variant="primary"
leftIcon={<Save className="w-5 h-5" />}
isLoading={updateAdminProfileMutation.isPending}
>
{t('settings.general.accountSaveButton')}
</Button>
</div>
</form>
)}
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.siteUrl')}
</label>
<Input
type="url"
value={generalSettings.site_url}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, site_url: e.target.value }))}
placeholder="https://yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.siteUrlHelp')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.defaultExpiration')}
</label>
<Input
type="number"
value={generalSettings.default_expiration_days}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_expiration_days: parseInt(e.target.value) || 30 }))}
min="1"
max="365"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.maxFileSize')}
</label>
<Input
type="number"
value={generalSettings.max_file_size_mb}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, max_file_size_mb: parseInt(e.target.value) || 50 }))}
min="1"
max="500"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.maxFilesPerUpload')}
</label>
<Input
type="number"
value={generalSettings.max_files_per_upload}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
setGeneralSettings(prev => ({
...prev,
max_files_per_upload: Number.isFinite(parsed)
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
: prev.max_files_per_upload
}));
}}
min="1"
max={MAX_FILES_PER_UPLOAD_LIMIT}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.allowedFileTypes')}
</label>
<Input
type="text"
value={generalSettings.allowed_file_types}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowed_file_types: e.target.value }))}
placeholder="jpg,jpeg,png,gif"
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.allowedFileTypesHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_watermark}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_watermark: 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.general.enableWatermark')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_analytics}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_analytics: 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.general.enableAnalytics')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_registration}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_registration: 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.general.enableRegistration')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.maintenance_mode}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, maintenance_mode: 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.general.maintenanceMode')}</span>
</label>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.short_gallery_urls}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: 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.general.enableShortGalleryUrls')}</span>
</label>
<p className="text-xs text-neutral-500 ml-6 mt-1">
{t('settings.general.enableShortGalleryUrlsHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.language')}
</label>
<select
value={generalSettings.default_language}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.defaultLanguageHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.dateFormat')}
</label>
<select
value={generalSettings.date_format?.format || 'dd/MM/yyyy'}
onChange={(e) => {
const format = e.target.value;
const locale = format === 'MM/dd/yyyy' ? 'en-US' : 'en-GB';
setGeneralSettings(prev => ({
...prev,
date_format: { format, locale }
}));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="dd/MM/yyyy">DD/MM/YYYY (European)</option>
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.dateFormatHelp')}
</p>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveGeneralMutation.mutate()}
isLoading={saveGeneralMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.general.saveGeneralSettings')}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,10 @@
import React from 'react';
import { WordFilterManager } from '../../../components/admin/WordFilterManager';
export const ModerationTab: React.FC = () => {
return (
<div className="space-y-6">
<WordFilterManager />
</div>
);
};
@@ -0,0 +1,205 @@
import React from 'react';
import { Save, Key, AlertCircle } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { SecuritySettings } from '../hooks/useSettingsState';
interface SecurityTabProps {
securitySettings: SecuritySettings;
setSecuritySettings: React.Dispatch<React.SetStateAction<SecuritySettings>>;
saveSecurityMutation: {
mutate: () => void;
isPending: boolean;
};
}
export const SecurityTab: React.FC<SecurityTabProps> = ({
securitySettings,
setSecuritySettings,
saveSecurityMutation,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.minPasswordLength')}
</label>
<Input
type="number"
value={securitySettings.password_min_length}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_min_length: parseInt(e.target.value) || 8 }))}
min="4"
max="32"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.passwordComplexity')}
</label>
<select
value={securitySettings.password_complexity}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="simple">{t('settings.security.complexitySimple')}</option>
<option value="moderate">{t('settings.security.complexityModerate')}</option>
<option value="strong">{t('settings.security.complexityStrong')}</option>
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
</select>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.passwordComplexityHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.sessionTimeout')}
</label>
<Input
type="number"
value={securitySettings.session_timeout_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
min="5"
max="1440"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.attemptWindowMinutes')}
</label>
<Input
type="number"
value={securitySettings.attempt_window_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.attemptWindowMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.lockoutDurationMinutes')}
</label>
<Input
type="number"
value={securitySettings.lockout_duration_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.lockoutDurationMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.maxLoginAttempts')}
</label>
<Input
type="number"
value={securitySettings.max_login_attempts}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
min="1"
max="50"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.maxLoginAttemptsHelp')}
</p>
</div>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_2fa}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: 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.enable2FA')}</span>
</label>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.recaptchaSettings')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_recaptcha}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_recaptcha: 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.enableRecaptcha')}</span>
</label>
{securitySettings.enable_recaptcha && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.siteKey')}
</label>
<Input
type="text"
value={securitySettings.recaptcha_site_key}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_site_key: e.target.value }))}
placeholder={t('settings.security.siteKey')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.secretKey')}
</label>
<Input
type="password"
value={securitySettings.recaptcha_secret_key}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_secret_key: e.target.value }))}
placeholder={t('settings.security.secretKey')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p>{t('settings.security.recaptchaHelp')} <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noopener noreferrer" className="underline">Google reCAPTCHA Admin</a></p>
</div>
</div>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveSecurityMutation.mutate()}
isLoading={saveSecurityMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.security.saveSecuritySettings')}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,539 @@
import React, { useEffect } from 'react';
import {
Save,
Database,
Server,
CheckCircle,
Clock,
HardDrive,
Activity,
} from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../../services/settings.service';
import { useStatusTab } from '../hooks/useStatusTab';
const BYTES_PER_GB = 1024 * 1024 * 1024;
interface StatusTabProps {
isActive: boolean;
handleSaveSoftLimit: () => void;
handleSaveCapacityOverride: () => void;
saveSoftLimitMutation: { isPending: boolean };
saveCapacityOverrideMutation: { isPending: boolean };
softLimitGb: number | '';
setSoftLimitGb: (value: number | '') => void;
softLimitDirty: boolean;
setSoftLimitDirty: (dirty: boolean) => void;
capacityOverrideGb: number | '';
setCapacityOverrideGb: (value: number | '') => void;
availableOverrideGb: number | '';
setAvailableOverrideGb: (value: number | '') => void;
overrideDirty: boolean;
setOverrideDirty: (dirty: boolean) => void;
}
export const StatusTab: React.FC<StatusTabProps> = ({
isActive,
handleSaveSoftLimit,
handleSaveCapacityOverride,
saveSoftLimitMutation,
saveCapacityOverrideMutation,
softLimitGb,
setSoftLimitGb,
softLimitDirty,
setSoftLimitDirty,
capacityOverrideGb,
setCapacityOverrideGb,
availableOverrideGb,
setAvailableOverrideGb,
overrideDirty,
setOverrideDirty,
}) => {
const { t } = useTranslation();
const { storageInfo, systemStatus } = useStatusTab(isActive);
// Sync soft limit from storage info
useEffect(() => {
if (!storageInfo || 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, setSoftLimitGb]);
// Sync capacity override from storage info
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, setCapacityOverrideGb, setAvailableOverrideGb]);
return (
<div className="space-y-6">
{/* Storage Overview */}
{storageInfo && (() => {
const configuredSoftLimit = storageInfo.configured_soft_limit ?? null;
const effectiveSoftLimit = storageInfo.storage_soft_limit || storageInfo.storage_limit || storageInfo.recommended_soft_limit || 1;
const safeEffectiveSoftLimit = Math.max(effectiveSoftLimit, 1);
const usageRatio = storageInfo.total_used / safeEffectiveSoftLimit;
const usagePercentage = Math.round(usageRatio * 100);
const usageWidth = Math.min(usageRatio * 100, 100);
const overSoftLimit = configuredSoftLimit != null
? storageInfo.total_used >= configuredSoftLimit
: usagePercentage >= 100;
const limitDisplayBytes = configuredSoftLimit ?? storageInfo.storage_soft_limit ?? storageInfo.storage_limit ?? null;
const limitDisplay = limitDisplayBytes != null
? settingsService.formatBytes(limitDisplayBytes)
: t('settings.storage.unlimited');
const diskCapacityBytes = storageInfo.disk_total ?? storageInfo.disk_total_raw ?? null;
const diskAvailableBytes = storageInfo.disk_available ?? storageInfo.disk_available_raw ?? null;
const diskFreeBytes = storageInfo.disk_free ?? storageInfo.disk_free_raw ?? null;
const diskCapacityDisplay = diskCapacityBytes != null
? settingsService.formatBytes(diskCapacityBytes)
: null;
const diskAvailableDisplay = diskAvailableBytes != null
? settingsService.formatBytes(diskAvailableBytes)
: null;
const diskFreeDisplay = diskFreeBytes != null
? settingsService.formatBytes(diskFreeBytes)
: null;
const recommendedDisplay = storageInfo.recommended_soft_limit != null
? settingsService.formatBytes(storageInfo.recommended_soft_limit)
: null;
const progressColor = overSoftLimit
? 'bg-red-600'
: usagePercentage >= 90
? 'bg-amber-500'
: 'bg-primary-600';
const limitCardClass = overSoftLimit ? 'bg-amber-50 border border-amber-200' : 'bg-neutral-50';
const limitValueClass = overSoftLimit ? 'text-amber-700' : 'text-neutral-900';
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 font-semibold' : 'text-neutral-600';
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
const overrideSource = storageInfo.disk_override_source;
const overrideControlled = overrideSource === 'env';
const diskSummaryCards: Array<{ label: string; value: string }> = [];
if (diskCapacityDisplay && (diskMetricsReliable || overrideSource)) {
const label = storageInfo.disk_total != null
? t('settings.storage.diskCapacity')
: t('settings.storage.diskCapacityReported');
diskSummaryCards.push({ label, value: diskCapacityDisplay });
}
if (diskAvailableDisplay && (diskMetricsReliable || overrideSource)) {
const label = storageInfo.disk_available != null
? t('settings.storage.diskAvailable')
: t('settings.storage.diskAvailableReported');
diskSummaryCards.push({ label, value: diskAvailableDisplay });
}
if (diskFreeDisplay && storageInfo.disk_free == null && (diskMetricsReliable || overrideSource)) {
diskSummaryCards.push({
label: t('settings.storage.diskFreeReported'),
value: diskFreeDisplay
});
}
if (recommendedDisplay) {
diskSummaryCards.push({
label: t('settings.storage.recommendedSoftLimit'),
value: recommendedDisplay
});
}
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<HardDrive className="w-5 h-5" />
{t('settings.systemStatus.storageOverview')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.total_used)}
</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.archive_storage)}
</p>
</div>
<div className={`rounded-lg p-4 ${limitCardClass}`}>
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
<p className={`text-2xl font-bold ${limitValueClass}`}>
{limitDisplay}
</p>
<p className={`text-xs mt-1 ${limitDescriptorClass}`}>
{storageInfo.soft_limit_configured
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
: t('admin.storageSoftLimitRecommended', { limit: recommendedDescriptorValue })}
</p>
</div>
</div>
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
<span className={`font-medium ${overSoftLimit ? 'text-red-600' : 'text-neutral-900'}`}>
{usagePercentage}%
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-3">
<div
className={`${progressColor} h-3 rounded-full transition-all`}
style={{ width: `${usageWidth}%` }}
/>
</div>
</div>
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
<p className="text-sm text-neutral-600">
{t('settings.storage.storageLimitHelper')}
</p>
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{diskSummaryCards.map((card) => (
<div key={card.label} className="bg-neutral-50 rounded-lg p-4">
<p className="text-xs text-neutral-500 uppercase tracking-wide">{card.label}</p>
<p className="text-lg font-semibold text-neutral-900 mt-1">{card.value}</p>
</div>
))}
</div>
)}
{!diskMetricsReliable && !overrideSource && (
<p className="text-xs text-neutral-500">
{t('settings.storage.diskMetricsUnavailable')}
</p>
)}
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)]">
<Input
type="number"
inputMode="decimal"
min={0}
step="0.1"
value={softLimitGb === '' ? '' : softLimitGb}
onChange={(e) => {
const value = e.target.value;
setSoftLimitDirty(true);
if (value === '') {
setSoftLimitGb('');
return;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return;
}
setSoftLimitGb(numeric);
}}
label={t('settings.storage.softLimitInputLabel')}
helperText={t('settings.storage.softLimitHelper')}
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
/>
<p className="text-xs text-neutral-500">
{t('settings.storage.limitNotEnforced')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
if (storageInfo.recommended_soft_limit != null) {
const value = Number((storageInfo.recommended_soft_limit / BYTES_PER_GB).toFixed(2));
setSoftLimitGb(value);
setSoftLimitDirty(true);
}
}}
disabled={storageInfo.recommended_soft_limit == null}
>
{t('settings.storage.applyRecommended')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
if (storageInfo.disk_available != null) {
const value = Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2));
setSoftLimitGb(value);
setSoftLimitDirty(true);
}
}}
disabled={storageInfo.disk_available == null}
>
{t('settings.storage.applyAvailable')}
</Button>
</div>
<div className="flex justify-end">
<Button
variant="primary"
size="sm"
onClick={handleSaveSoftLimit}
isLoading={saveSoftLimitMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('settings.storage.saveSoftLimit')}
</Button>
</div>
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
<div>
<p className="text-sm font-medium text-neutral-700">{t('settings.storage.overrideTitle')}</p>
{overrideControlled ? (
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideEnvNote')}</p>
) : (
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideSettingsHelp')}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="number"
inputMode="decimal"
min={0}
step="0.1"
value={capacityOverrideGb === '' ? '' : capacityOverrideGb}
onChange={(e) => {
const value = e.target.value;
setOverrideDirty(true);
if (value === '') {
setCapacityOverrideGb('');
return;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return;
}
setCapacityOverrideGb(numeric);
}}
label={t('settings.storage.overrideCapacityLabel')}
helperText={t('settings.storage.overrideCapacityHelper')}
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
disabled={overrideControlled}
/>
<Input
type="number"
inputMode="decimal"
min={0}
step="0.1"
value={availableOverrideGb === '' ? '' : availableOverrideGb}
onChange={(e) => {
const value = e.target.value;
setOverrideDirty(true);
if (value === '') {
setAvailableOverrideGb('');
return;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return;
}
setAvailableOverrideGb(numeric);
}}
label={t('settings.storage.overrideAvailableLabel')}
helperText={t('settings.storage.overrideAvailableHelper')}
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
disabled={overrideControlled}
/>
</div>
<div className="flex justify-end">
<Button
variant="primary"
size="sm"
onClick={handleSaveCapacityOverride}
isLoading={saveCapacityOverrideMutation.isPending}
disabled={overrideControlled}
leftIcon={<Save className="w-4 h-4" />}
>
{t('settings.storage.saveOverride')}
</Button>
</div>
</div>
</div>
</Card>
);
})()}
{/* System Information */}
{systemStatus && (
<>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Server className="w-5 h-5" />
{t('settings.systemStatus.systemInfo')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.platform')}</p>
<p className="font-semibold">{systemStatus.system.platform}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.nodeVersion')}</p>
<p className="font-semibold">{systemStatus.system.nodeVersion}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.uptime')}</p>
<p className="font-semibold">{Math.floor(systemStatus.system.uptime / 3600)}h {Math.floor((systemStatus.system.uptime % 3600) / 60)}m</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.cpuCores')}</p>
<p className="font-semibold">{systemStatus.system.cpu.cores}</p>
</div>
</div>
<div className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-2">{t('settings.systemStatus.memoryUsage')}</h3>
<div className="mb-2">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.systemStatus.memoryUsed')}</span>
<span className="font-medium">
{settingsService.formatBytes(systemStatus.system.memory.used)} / {settingsService.formatBytes(systemStatus.system.memory.total)}
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${Math.round((systemStatus.system.memory.used / systemStatus.system.memory.total) * 100)}%`
}}
/>
</div>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Database className="w-5 h-5" />
{t('settings.systemStatus.databaseInfo')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.events}</p>
<p className="text-xs text-neutral-600">{t('navigation.events')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.photos}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.photos')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.admins}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.admins')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.categories}</p>
<p className="text-xs text-neutral-600">{t('settings.categories.title')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{settingsService.formatBytes(systemStatus.database.size)}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.dbSize')}</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
{t('settings.systemStatus.services')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.fileWatcher')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.fileWatcherDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.expirationChecker')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.expirationCheckerDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.emailProcessor')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.emailProcessorDesc')}</p>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<h3 className="text-sm font-semibold text-blue-900 mb-2">{t('settings.systemStatus.emailQueue')}</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-blue-700">{t('settings.systemStatus.pending')}:</span>
<span className="ml-2 font-semibold text-blue-900">
{systemStatus.emailQueue.pending}
{systemStatus.emailQueue.stuck > 0 && (
<span className="text-orange-600 text-xs ml-1">
({systemStatus.emailQueue.stuck} stuck)
</span>
)}
</span>
</div>
<div>
<span className="text-green-700">{t('settings.systemStatus.sent')}:</span>
<span className="ml-2 font-semibold text-green-900">{systemStatus.emailQueue.sent}</span>
</div>
<div>
<span className="text-red-700">{t('settings.systemStatus.failed')}:</span>
<span className="ml-2 font-semibold text-red-900">{systemStatus.emailQueue.failed}</span>
</div>
</div>
{systemStatus.emailQueue.stuck > 0 && (
<div className="mt-3 p-3 bg-orange-50 rounded-md">
<p className="text-xs text-orange-800">
<span className="font-semibold">Warning: {systemStatus.emailQueue.stuck} email(s) stuck:</span> These emails have exceeded retry limits and won&apos;t be processed automatically.
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
</p>
</div>
)}
</div>
</Card>
</>
)}
{/* Last update time */}
{systemStatus && (
<div className="text-xs text-neutral-500 text-right flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
</div>
)}
</div>
);
};
@@ -0,0 +1,10 @@
import React from 'react';
import { CssTemplateEditor } from '../../../components/admin/CssTemplateEditor';
export const StylingTab: React.FC = () => {
return (
<div className="space-y-6">
<CssTemplateEditor />
</div>
);
};
+3
View File
@@ -26,6 +26,9 @@ i18n
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },
// Use v3 format for pluralization (_plural suffix instead of _one/_other)
compatibilityJSON: 'v3',
detection: { detection: {
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'], order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
+69 -1
View File
@@ -582,6 +582,11 @@
"noThemeSet": "Kein Design konfiguriert", "noThemeSet": "Kein Design konfiguriert",
"customizingTheme": "Galerie-Design anpassen", "customizingTheme": "Galerie-Design anpassen",
"customizingThemeFor": "Design für {{event}} anpassen", "customizingThemeFor": "Design für {{event}} anpassen",
"customCssTemplate": "Benutzerdefinierte CSS-Vorlage",
"customCssTemplateDesc": "Verwenden Sie eine CSS-Vorlage, um die Galerie mit einzigartigen visuellen Effekten zu gestalten.",
"noTemplate": "Keine Vorlage",
"useThemeOnly": "Nur Design-Vorlage verwenden",
"customTemplate": "Benutzerdefinierte Vorlage",
"title": "Veranstaltungen", "title": "Veranstaltungen",
"create": "Veranstaltung erstellen", "create": "Veranstaltung erstellen",
"createEvent": "Veranstaltung erstellen", "createEvent": "Veranstaltung erstellen",
@@ -745,6 +750,7 @@
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen", "bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
"searchEventsPlaceholder": "Veranstaltungen suchen...", "searchEventsPlaceholder": "Veranstaltungen suchen...",
"all": "Alle", "all": "Alle",
"expiring": "Läuft ab",
"activeFilter": "Aktiv", "activeFilter": "Aktiv",
"archivedFilter": "Archiviert", "archivedFilter": "Archiviert",
"sortByName": "Nach Name", "sortByName": "Nach Name",
@@ -776,6 +782,18 @@
"bulkArchive": "Archivieren", "bulkArchive": "Archivieren",
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?", "confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.", "confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
"copy": "Kopieren",
"copied": "Kopiert!",
"rename": {
"button": "Umbenennen",
"title": "Veranstaltung umbenennen",
"validating": "Neuer Name wird überprüft...",
"renamingFiles": "Dateien werden umbenannt...",
"complete": "Fertig!",
"failed": "Umbenennung fehlgeschlagen",
"filesRenamed": "{{count}} Dateien aktualisiert",
"confirm": "Veranstaltung umbenennen"
},
"stats": { "stats": {
"totalEvents": "Gesamtveranstaltungen", "totalEvents": "Gesamtveranstaltungen",
"activeEvents": "Aktive Veranstaltungen", "activeEvents": "Aktive Veranstaltungen",
@@ -1089,6 +1107,17 @@
"waves": "Wellen" "waves": "Wellen"
}, },
"customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen", "customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen",
"cssInstructions": {
"title": "Benutzerdefiniertes CSS verwenden",
"variables": "Design CSS-Variablen",
"variablesDesc": "Verwenden Sie diese CSS-Variablen passend zu Ihren Design-Vorlagen:",
"layouts": "Benutzerdefinierte Galerie-Layouts",
"layoutsDesc": "Sprechen Sie Galerie-Elemente mit diesen Selektoren an:",
"glassEffect": "Glasmorphismus-Effekt",
"glassEffectDesc": "Erstellen Sie moderne Glaseffekte:",
"tip": "Tipp",
"tipText": "Verwenden Sie CSS-Vorlagen unter Einstellungen > CSS-Vorlagen für vorgefertigte Designs wie Apple Liquid Glass."
},
"resetToDefault": "Auf Standard zurücksetzen", "resetToDefault": "Auf Standard zurücksetzen",
"applyTheme": "Theme anwenden", "applyTheme": "Theme anwenden",
"customTheme": "Benutzerdefiniertes Design", "customTheme": "Benutzerdefiniertes Design",
@@ -1231,6 +1260,7 @@
"photos_bulk_deleted": "{{count}} Fotos gelöscht aus {{eventName}}", "photos_bulk_deleted": "{{count}} Fotos gelöscht aus {{eventName}}",
"settings_updated": "Einstellungen aktualisiert", "settings_updated": "Einstellungen aktualisiert",
"event_updated": "Veranstaltung aktualisiert: {{eventName}}", "event_updated": "Veranstaltung aktualisiert: {{eventName}}",
"event_renamed": "Veranstaltung umbenannt: {{eventName}}",
"event_deleted": "Veranstaltung gelöscht: {{eventName}}", "event_deleted": "Veranstaltung gelöscht: {{eventName}}",
"password_changed": "Passwort geändert", "password_changed": "Passwort geändert",
"email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}", "email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}",
@@ -1933,7 +1963,45 @@
"photoFeedback": "Foto-Feedback", "photoFeedback": "Foto-Feedback",
"hasFeedback": "Hat Feedback", "hasFeedback": "Hat Feedback",
"hasComments": "Hat Kommentare", "hasComments": "Hat Kommentare",
"hasRating": "Hat Bewertung" "hasRating": "Hat Bewertung",
"settings": {
"title": "Gast-Feedback-Einstellungen",
"enableFeedback": "Feedback aktivieren",
"feedbackTypes": "Feedback-Typen",
"ratings": "Sternebewertungen",
"ratingsDesc": "Gästen erlauben, Fotos zu bewerten (1-5 Sterne)",
"likes": "Gefällt mir",
"likesDesc": "Einfache Gefällt mir-Funktion",
"comments": "Kommentare",
"commentsDesc": "Textkommentare auf Fotos",
"favorites": "Favoriten",
"favoritesDesc": "Fotos als Favoriten markieren"
}
},
"filter": {
"feedbackFilters": "Feedback-Filter",
"clear": "Löschen",
"rating": "Bewertung",
"allPhotos": "Alle Fotos",
"anyRating": "Jede Bewertung",
"oneStarPlus": "1+ Sterne",
"twoStarsPlus": "2+ Sterne",
"threeStarsPlus": "3+ Sterne",
"fourStarsPlus": "4+ Sterne",
"fiveStarsOnly": "Nur 5 Sterne",
"hasLikes": "Hat Gefällt mir",
"hasFavorites": "Hat Favoriten",
"hasComments": "Hat Kommentare",
"showingPhotos": "Fotos gesamt",
"withRatings": "Mit Bewertungen"
},
"export": {
"button": "Exportieren",
"success": "Export erfolgreich heruntergeladen",
"error": "Export fehlgeschlagen: ",
"exportSelected": "{{count}} ausgewählte exportieren",
"exportFiltered": "Gefilterte Fotos exportieren",
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren"
}, },
"adminLogin": { "adminLogin": {
"title": "Admin-Anmeldung", "title": "Admin-Anmeldung",
+57 -2
View File
@@ -461,7 +461,22 @@
"customizeTheme": "Customize Theme", "customizeTheme": "Customize Theme",
"noThemeSet": "No theme configured", "noThemeSet": "No theme configured",
"customizingTheme": "Customizing gallery theme", "customizingTheme": "Customizing gallery theme",
"customizingThemeFor": "Customizing theme for {{event}}" "customizingThemeFor": "Customizing theme for {{event}}",
"customCssTemplate": "Custom CSS Template",
"customCssTemplateDesc": "Apply a custom CSS template to style the gallery with unique visual effects.",
"noTemplate": "No Template",
"useThemeOnly": "Use theme preset only",
"customTemplate": "Custom Template",
"rename": {
"button": "Rename",
"title": "Rename Event",
"validating": "Validating new name...",
"renamingFiles": "Renaming files...",
"complete": "Complete!",
"failed": "Rename failed",
"filesRenamed": "{{count}} files updated",
"confirm": "Rename Event"
}
}, },
"settings": { "settings": {
"title": "System Settings", "title": "System Settings",
@@ -827,6 +842,17 @@
"waves": "Waves" "waves": "Waves"
}, },
"customCSSHelp": "Advanced: Add custom CSS to further customize the appearance", "customCSSHelp": "Advanced: Add custom CSS to further customize the appearance",
"cssInstructions": {
"title": "How to use Custom CSS",
"variables": "Theme CSS Variables",
"variablesDesc": "Use these CSS variables to match your theme presets:",
"layouts": "Custom Gallery Layouts",
"layoutsDesc": "Target gallery elements with these selectors:",
"glassEffect": "Glassmorphism Effect",
"glassEffectDesc": "Create modern glass effects:",
"tip": "Tip",
"tipText": "Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass."
},
"resetToDefault": "Reset to Default", "resetToDefault": "Reset to Default",
"applyTheme": "Apply Theme", "applyTheme": "Apply Theme",
"customTheme": "Custom Theme", "customTheme": "Custom Theme",
@@ -967,6 +993,7 @@
"photos_bulk_deleted": "{{count}} photos deleted from {{eventName}}", "photos_bulk_deleted": "{{count}} photos deleted from {{eventName}}",
"settings_updated": "Settings updated", "settings_updated": "Settings updated",
"event_updated": "Event updated: {{eventName}}", "event_updated": "Event updated: {{eventName}}",
"event_renamed": "Event renamed: {{eventName}}",
"event_deleted": "Event deleted: {{eventName}}", "event_deleted": "Event deleted: {{eventName}}",
"password_changed": "Password changed", "password_changed": "Password changed",
"email_resent": "Creation email resent for: {{eventName}}", "email_resent": "Creation email resent for: {{eventName}}",
@@ -1631,7 +1658,35 @@
"photoFeedback": "Photo Feedback", "photoFeedback": "Photo Feedback",
"hasFeedback": "Has feedback", "hasFeedback": "Has feedback",
"hasComments": "Has comments", "hasComments": "Has comments",
"hasRating": "Has rating" "hasRating": "Has rating",
"settings": {
"title": "Guest Feedback Settings",
"enableFeedback": "Enable feedback",
"feedbackTypes": "Feedback Types",
"ratings": "Star Ratings",
"ratingsDesc": "Allow guests to rate photos (1-5 stars)",
"likes": "Likes",
"likesDesc": "Simple like/unlike functionality",
"comments": "Comments",
"commentsDesc": "Text comments on photos",
"favorites": "Favorites",
"favoritesDesc": "Mark photos as favorites"
}
},
"filter": {
"feedbackFilters": "Feedback Filters",
"clear": "Clear",
"rating": "Rating",
"allPhotos": "All Photos",
"anyRating": "Any Rating",
"oneStarPlus": "1+ Stars",
"twoStarsPlus": "2+ Stars",
"threeStarsPlus": "3+ Stars",
"fourStarsPlus": "4+ Stars",
"fiveStarsOnly": "5 Stars Only",
"hasLikes": "Has likes",
"hasFavorites": "Has favorites",
"hasComments": "Has comments"
}, },
"adminLogin": { "adminLogin": {
"title": "Admin Login", "title": "Admin Login",
+10 -5
View File
@@ -383,18 +383,23 @@ export const BrandingPage: React.FC = () => {
</button> </button>
</div> </div>
)} )}
<label className="cursor-pointer"> <div>
<input <input
type="file" type="file"
accept="image/png,image/jpeg,image/svg+xml" accept="image/png,image/jpeg,image/svg+xml"
onChange={handleLogoUpload} onChange={handleLogoUpload}
className="hidden" className="hidden"
id="logo-upload"
/> />
<span className="btn-secondary inline-flex items-center"> <Button
<Upload className="w-4 h-4 mr-2" /> variant="secondary"
size="sm"
onClick={() => document.getElementById('logo-upload')?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{brandingSettings.logo_url ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')} {brandingSettings.logo_url ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
</span> </Button>
</label> </div>
</div> </div>
<p className="text-xs text-neutral-600 mt-1"> <p className="text-xs text-neutral-600 mt-1">
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')} {t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
@@ -20,8 +20,10 @@ import { eventsService } from '../../services/events.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { categoriesService } from '../../services/categories.service'; import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { cssTemplatesService } from '../../services/cssTemplates.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { Code } from 'lucide-react';
interface FormData { interface FormData {
event_type: string; event_type: string;
@@ -39,6 +41,7 @@ interface FormData {
expires_in_days: number; expires_in_days: number;
allow_user_uploads: boolean; allow_user_uploads: boolean;
upload_category_id: number | null; upload_category_id: number | null;
css_template_id: number | null;
feedback_settings: { feedback_settings: {
feedback_enabled: boolean; feedback_enabled: boolean;
allow_ratings: boolean; allow_ratings: boolean;
@@ -98,6 +101,7 @@ export const CreateEventPage: React.FC = () => {
expires_in_days: 30, expires_in_days: 30,
allow_user_uploads: false, allow_user_uploads: false,
upload_category_id: null, upload_category_id: null,
css_template_id: null,
feedback_settings: { feedback_settings: {
feedback_enabled: false, feedback_enabled: false,
allow_ratings: true, allow_ratings: true,
@@ -122,6 +126,12 @@ export const CreateEventPage: React.FC = () => {
queryFn: () => categoriesService.getGlobalCategories() queryFn: () => categoriesService.getGlobalCategories()
}); });
// Fetch enabled CSS templates
const { data: cssTemplates } = useQuery({
queryKey: ['css-templates', 'enabled'],
queryFn: () => cssTemplatesService.getEnabledTemplates()
});
// Fetch default settings // Fetch default settings
const { data: settings } = useQuery({ const { data: settings } = useQuery({
queryKey: ['admin-settings'], queryKey: ['admin-settings'],
@@ -268,6 +278,7 @@ export const CreateEventPage: 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,
css_template_id: formData.css_template_id,
feedback_enabled: feedbackSettings.feedback_enabled, feedback_enabled: feedbackSettings.feedback_enabled,
allow_ratings: feedbackSettings.allow_ratings, allow_ratings: feedbackSettings.allow_ratings,
allow_likes: feedbackSettings.allow_likes, allow_likes: feedbackSettings.allow_likes,
@@ -478,6 +489,55 @@ export const CreateEventPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Custom CSS Template Selection */}
{cssTemplates && cssTemplates.length > 0 && (
<div className="pt-6 border-t border-neutral-200">
<h3 className="text-md font-semibold text-neutral-900 mb-3 flex items-center gap-2">
<Code className="w-4 h-4" />
{t('events.customCssTemplate', 'Custom CSS Template')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{t('events.customCssTemplateDesc', 'Apply a custom CSS template to style the gallery with unique visual effects.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{/* No template option */}
<button
type="button"
onClick={() => setFormData({ ...formData, css_template_id: null })}
className={`p-4 rounded-lg border-2 transition-all text-left ${
formData.css_template_id === null
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="font-medium text-sm">{t('events.noTemplate', 'No Template')}</div>
<div className="text-xs text-neutral-500 mt-1">
{t('events.useThemeOnly', 'Use theme preset only')}
</div>
</button>
{/* Available templates */}
{cssTemplates.map(template => (
<button
key={template.id}
type="button"
onClick={() => setFormData({ ...formData, css_template_id: template.id })}
className={`p-4 rounded-lg border-2 transition-all text-left ${
formData.css_template_id === template.id
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="font-medium text-sm">{template.name}</div>
<div className="text-xs text-neutral-500 mt-1">
{t('events.customTemplate', 'Custom Template')} {template.slot_number}
</div>
</button>
))}
</div>
</div>
)}
</div> </div>
</Card> </Card>
@@ -950,9 +950,9 @@ export const EventDetailsPage: React.FC = () => {
) : ( ) : (
<dl className="space-y-4"> <dl className="space-y-4">
<div> <div>
<dt className="text-sm font-medium text-neutral-500">Source Mode</dt> <dt className="text-sm font-medium text-neutral-500">{t('events.sourceMode', 'Source Mode')}</dt>
<dd className="mt-1 text-sm text-neutral-900"> <dd className="mt-1 text-sm text-neutral-900">
{event.source_mode === 'reference' ? 'Reference (external folder)' : 'Managed (upload)'} {event.source_mode === 'reference' ? t('events.sourceModeReference', 'Reference external folder') : t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}
{event.source_mode === 'reference' && event.external_path ? ( {event.source_mode === 'reference' && event.external_path ? (
<span className="text-neutral-500 ml-2">/external-media/{event.external_path}</span> <span className="text-neutral-500 ml-2">/external-media/{event.external_path}</span>
) : null} ) : null}
File diff suppressed because it is too large Load Diff
@@ -76,7 +76,14 @@ class CssTemplatesService {
async getGalleryCss(slug: string): Promise<string | null> { async getGalleryCss(slug: string): Promise<string | null> {
try { try {
const response = await api.get(`/gallery/${slug}/css-template`, { const response = await api.get(`/gallery/${slug}/css-template`, {
responseType: 'text' responseType: 'text',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
params: {
_t: Date.now() // Cache-busting parameter
}
}); });
if (response.status === 204) { if (response.status === 204) {
return null; return null;
+2 -2
View File
@@ -29,11 +29,11 @@ const config: VitestUserConfig = {
host: true, host: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', target: 'http://localhost:7101',
changeOrigin: true, changeOrigin: true,
}, },
'/photos': { '/photos': {
target: 'http://localhost:3001', target: 'http://localhost:7101',
changeOrigin: true, changeOrigin: true,
}, },
}, },