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
+121 -206
View File
@@ -1,31 +1,29 @@
const express = require('express');
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
const { body } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const router = express.Router();
// Change password
router.get('/profile', adminAuth, async (req, res) => {
try {
const admin = await db('admin_users')
.where('id', req.admin.id)
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
.first();
// Get admin profile
router.get('/profile', adminAuth, handleAsync(async (req, res) => {
const admin = await db('admin_users')
.where('id', req.admin.id)
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
.first();
if (!admin) {
return res.status(404).json({ error: 'Admin user not found' });
}
res.json(admin);
} catch (error) {
console.error('Admin profile fetch error:', error);
res.status(500).json({ error: 'Failed to fetch admin profile' });
if (!admin) {
throw new NotFoundError('Admin user');
}
});
res.json(admin);
}));
// Update admin profile
router.put('/profile', [
adminAuth,
body('username')
@@ -37,212 +35,129 @@ router.put('/profile', [
.isEmail()
.withMessage('A valid email address is required')
.normalizeEmail()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
], handleAsync(async (req, res) => {
validateRequest(req);
const username = req.body.username.trim();
const email = req.body.email.trim().toLowerCase();
const adminId = req.admin.id;
const username = req.body.username.trim();
const email = req.body.email.trim().toLowerCase();
const adminId = req.admin.id;
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', adminId)
.first();
// Check for username conflict
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', adminId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use' });
}
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' });
if (existingUsername) {
throw new ConflictError('Username is already in use', 'username');
}
});
// 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', [
adminAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
], handleAsync(async (req, res) => {
validateRequest(req);
const { currentPassword, newPassword } = req.body;
const userId = req.admin.id; // Changed from req.user.id to req.admin.id
const { currentPassword, newPassword } = req.body;
const userId = req.admin.id;
// Validate new password strength
const passwordValidation = validatePasswordStrength(newPassword);
if (!passwordValidation.isValid) {
return res.status(400).json({
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' });
// Validate new password strength
const passwordValidation = validatePasswordStrength(newPassword);
if (!passwordValidation.isValid) {
throw new ValidationError('Password does not meet security requirements', passwordValidation.messages);
}
});
// Update admin profile
router.put('/profile', [
adminAuth,
body('username').trim().notEmpty().withMessage('Username is required'),
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() });
}
// Get user from database
const user = await db('admin_users')
.where('id', userId)
.first();
const { username, email } = req.body;
const userId = req.admin.id;
// 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' });
if (!user) {
throw new NotFoundError('User');
}
});
// 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
router.post('/logout', adminAuth, async (req, res) => {
try {
// Get token from header
const token = req.headers.authorization?.split(' ')[1];
if (token) {
// End the session
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' });
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
// Get token from header
const token = req.headers.authorization?.split(' ')[1];
if (token) {
// End the session
endSession(token);
}
});
// 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;
+7 -3
View File
@@ -141,7 +141,8 @@ router.post('/', adminAuth, [
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').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) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -178,7 +179,9 @@ router.post('/', adminAuth, [
allow_favorites = true,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true
show_feedback_to_guests = true,
// CSS Template
css_template_id = null
} = 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),
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
require_password: formatBoolean(requirePassword)
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null
}).returning('id');
// 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 { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
// Get storage path from environment or default
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
router.get('/resolve/:identifier', async (req, res) => {
try {
const { identifier } = req.params;
let result = await resolveShareIdentifier(identifier);
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
const { identifier } = req.params;
let result = await resolveShareIdentifier(identifier);
// If not found, check for redirect
if (!result) {
const newSlug = await checkSlugRedirect(identifier);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
return res.status(404).json({ error: 'Gallery not found' });
// If not found, check for redirect
if (!result) {
const newSlug = await checkSlugRedirect(identifier);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
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' });
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
});
}));
// Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => {
try {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
if (!event) {
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' });
router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
if (!event) {
throw new NotFoundError('Gallery');
}
});
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)
router.get('/:slug/info', async (req, res) => {