feat: customisable 404 + gallery-not-found pages via CMS (#324)
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.
Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
migration on existing deployments. Null falls back to the global
branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.
Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
the standard branded shell (logo precedence: page → branding → bundled
default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
identifier + infoError archived/missing) into a single
CMSContentBlock("gallery-not-found"), so admins can edit one source
of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
control per page; falls back to the page's own English title in the
page list when no `legal.<slug>` translation is registered.
This commit is contained in:
+123
-22
@@ -1,10 +1,42 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const multer = require('multer');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const router = express.Router();
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Multer config for per-page logo uploads. Stores into the same
|
||||
// /uploads/logos directory the global branding logo uses, with a
|
||||
// per-slug filename so a page swap doesn't fight an unrelated upload.
|
||||
const pageLogoStorage = multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
const dir = path.join(getStoragePath(), 'uploads/logos');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const safeSlug = (req.params.slug || 'page').replace(/[^a-z0-9-]/gi, '');
|
||||
cb(null, `cms-${safeSlug}-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const pageLogoUpload = multer({
|
||||
storage: pageLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true);
|
||||
else cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||||
}
|
||||
});
|
||||
|
||||
// Get all CMS pages
|
||||
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -21,11 +53,11 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req,
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const page = await db('cms_pages').where('slug', slug).first();
|
||||
|
||||
|
||||
if (!page) {
|
||||
return res.status(404).json({ error: 'Page not found' });
|
||||
}
|
||||
|
||||
|
||||
res.json(page);
|
||||
} catch (error) {
|
||||
console.error('Error fetching CMS page:', error);
|
||||
@@ -38,42 +70,46 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
body('title_en').optional().isString(),
|
||||
body('title_de').optional().isString(),
|
||||
body('content_en').optional().isString(),
|
||||
body('content_de').optional().isString()
|
||||
body('content_de').optional().isString(),
|
||||
body('logo_url').optional({ nullable: true }).isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
|
||||
const { slug } = req.params;
|
||||
const { title_en, title_de, content_en, content_de } = req.body;
|
||||
|
||||
const { title_en, title_de, content_en, content_de, logo_url } = req.body;
|
||||
|
||||
const page = await db('cms_pages').where('slug', slug).first();
|
||||
if (!page) {
|
||||
return res.status(404).json({ error: 'Page not found' });
|
||||
}
|
||||
|
||||
// Update the page
|
||||
await db('cms_pages')
|
||||
.where('slug', slug)
|
||||
.update({
|
||||
title_en,
|
||||
title_de,
|
||||
content_en,
|
||||
content_de,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
|
||||
const updateFields = {
|
||||
title_en,
|
||||
title_de,
|
||||
content_en,
|
||||
content_de,
|
||||
updated_at: new Date()
|
||||
};
|
||||
// Only touch logo_url when explicitly present so partial updates
|
||||
// (e.g. text-only edits) don't accidentally clear the upload.
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) {
|
||||
updateFields.logo_url = logo_url || null;
|
||||
}
|
||||
|
||||
await db('cms_pages').where('slug', slug).update(updateFields);
|
||||
|
||||
const updated = await db('cms_pages').where('slug', slug).first();
|
||||
|
||||
// Log activity
|
||||
|
||||
await logActivity('cms_page_updated',
|
||||
{ page: slug },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating CMS page:', error);
|
||||
@@ -81,4 +117,69 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// Upload a per-page logo (#324). Persists the URL to cms_pages.logo_url
|
||||
// and returns it so the client can re-render without a refetch.
|
||||
router.post(
|
||||
'/pages/:slug/logo',
|
||||
adminAuth,
|
||||
requirePermission('cms.edit'),
|
||||
pageLogoUpload.single('logo'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const page = await db('cms_pages').where('slug', slug).first();
|
||||
if (!page) {
|
||||
// Best-effort cleanup of the orphaned upload before erroring.
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
return res.status(404).json({ error: 'Page not found' });
|
||||
}
|
||||
|
||||
const logoUrl = `/uploads/logos/${path.basename(req.file.path)}`;
|
||||
await db('cms_pages').where('slug', slug).update({
|
||||
logo_url: logoUrl,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('cms_page_logo_uploaded',
|
||||
{ page: slug },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ logo_url: logoUrl });
|
||||
} catch (error) {
|
||||
console.error('Error uploading CMS page logo:', error);
|
||||
res.status(500).json({ error: 'Failed to upload logo' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Clear a per-page logo override (revert to global branding logo).
|
||||
router.delete(
|
||||
'/pages/:slug/logo',
|
||||
adminAuth,
|
||||
requirePermission('cms.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const page = await db('cms_pages').where('slug', slug).first();
|
||||
if (!page) return res.status(404).json({ error: 'Page not found' });
|
||||
|
||||
await db('cms_pages').where('slug', slug).update({
|
||||
logo_url: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
res.json({ logo_url: null });
|
||||
} catch (error) {
|
||||
console.error('Error clearing CMS page logo:', error);
|
||||
res.status(500).json({ error: 'Failed to clear logo' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -22,6 +22,9 @@ router.get('/pages/:slug', async (req, res) => {
|
||||
title,
|
||||
content,
|
||||
slug: page.slug,
|
||||
// Per-page logo override (#324). Null means "fall back to global
|
||||
// branding logo" — the consumer decides.
|
||||
logo_url: page.logo_url || null,
|
||||
updated_at: page.updated_at
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user