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:
Paul Nothaft
2026-04-27 22:38:00 +02:00
parent b63a8774c4
commit 4f77905b87
9 changed files with 417 additions and 141 deletions
+25
View File
@@ -463,8 +463,15 @@ async function ensureGlobalCategories() {
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.string('logo_url').nullable();
table.timestamp('updated_at').defaultTo(db.fn.now());
});
} else if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) {
// Online migration for existing deployments — see issue #324, per-page
// logo override for admin-customisable error pages.
await db.schema.alterTable('cms_pages', (table) => {
table.string('logo_url').nullable();
});
}
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
@@ -501,6 +508,24 @@ async function ensureGlobalCategories() {
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
// Customisable error pages — issue #324. Generic copy by default;
// admins can edit text + logo per page in the CMS Pages tab.
{
slug: 'not-found',
title_en: 'Page Not Found',
title_de: 'Seite nicht gefunden',
content_en: '<h2>Page Not Found</h2><p>The page you are looking for does not exist or has been moved.</p>',
content_de: '<h2>Seite nicht gefunden</h2><p>Die gesuchte Seite existiert nicht oder wurde verschoben.</p>',
updated_at: new Date(),
},
{
slug: 'gallery-not-found',
title_en: 'Gallery Not Found',
title_de: 'Galerie nicht gefunden',
content_en: '<h2>Gallery Not Found</h2><p>This gallery could not be found. The link may be incorrect, or the gallery may have expired or been archived. Please contact the organiser if you believe this is a mistake.</p>',
content_de: '<h2>Galerie nicht gefunden</h2><p>Diese Galerie konnte nicht gefunden werden. Der Link ist möglicherweise nicht korrekt, oder die Galerie ist abgelaufen oder wurde archiviert. Bitte kontaktieren Sie den Veranstalter, falls Sie glauben, dass dies ein Fehler ist.</p>',
updated_at: new Date(),
},
];
for (const page of defaultPages) {
+123 -22
View File
@@ -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;
+3
View File
@@ -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) {