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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from './pages/admin';
|
||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags } from './components/common';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||
import { getApiBaseUrl } from './utils/url';
|
||||
@@ -165,6 +165,11 @@ function App() {
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
||||
|
||||
{/* Customisable 404 (#324) — caught here for any path that
|
||||
didn't match. Top-level `/:slug` is consumed above by
|
||||
LegalPage; this picks up deeper unknown paths. */}
|
||||
<Route path="*" element={<CMSContentBlock slug="not-found" />} />
|
||||
</Routes>
|
||||
</MaintenanceWrapper>
|
||||
</Router>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Card } from './Card';
|
||||
import { Loading } from './Loading';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import { api } from '../../config/api';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import '../../styles/prose-overrides.css';
|
||||
|
||||
interface CMSContentBlockProps {
|
||||
/** CMS page slug, e.g. "not-found" or "gallery-not-found". */
|
||||
slug: string;
|
||||
/** Rendered when the slug doesn't exist or the fetch fails so the
|
||||
* caller is never left with a blank screen during cold deployments. */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
|
||||
'code', 'pre', 'hr', 'div', 'span', 'img',
|
||||
];
|
||||
const ALLOWED_ATTR = ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', 'title'];
|
||||
|
||||
/**
|
||||
* Renders a CMS page inside the standard branded shell. Used for the
|
||||
* customisable 404 and gallery-not-found pages (#324). Logo precedence:
|
||||
* per-page logo → global branding logo → bundled placeholder.
|
||||
*/
|
||||
export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback }) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const lang = settings?.default_language || i18n.language || 'en';
|
||||
|
||||
const { data: page, isLoading, error } = useQuery({
|
||||
queryKey: ['cms-public-page', slug, lang],
|
||||
queryFn: () => cmsService.getPublicPage(slug, lang),
|
||||
enabled: !!slug,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (page?.title) document.title = `${page.title} - ${settings?.branding_company_name || 'PicPeak'}`;
|
||||
}, [page?.title, settings?.branding_company_name]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<Loading size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !page) {
|
||||
return <>{fallback ?? null}</>;
|
||||
}
|
||||
|
||||
// Logo: per-page override beats global branding logo.
|
||||
const rawLogo = page.logo_url || settings?.branding_logo_url || '/picpeak-logo-transparent.png';
|
||||
const logoSrc = rawLogo.startsWith('http') || rawLogo.startsWith('/picpeak-')
|
||||
? rawLogo
|
||||
: buildResourceUrl(rawLogo);
|
||||
const companyName = settings?.branding_company_name || 'PicPeak';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={companyName}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 flex items-start justify-center px-4">
|
||||
<div className="max-w-2xl w-full">
|
||||
<Card padding="lg">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-6">
|
||||
{page.title}
|
||||
</h1>
|
||||
<div
|
||||
className="prose prose-neutral dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.content, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
KEEP_CONTENT: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{lang === 'de' ? '← Zur Startseite' : '← Back to home'}
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="py-8 text-center text-xs text-neutral-500">
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/impressum" className="hover:text-neutral-700">
|
||||
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
|
||||
</Link>
|
||||
<span className="text-neutral-400">•</span>
|
||||
<Link to="/datenschutz" className="hover:text-neutral-700">
|
||||
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
|
||||
</Link>
|
||||
</div>
|
||||
{!settings?.branding_hide_powered_by && (
|
||||
<p className="mt-2">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Button } from './Button';
|
||||
export { CMSContentBlock } from './CMSContentBlock';
|
||||
export { Input } from './Input';
|
||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||
export { Loading, LoadingSkeleton } from './Loading';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Card, CardContent, Input, Button, ReCaptcha } from '../components/common';
|
||||
import { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock } from '../components/common';
|
||||
import { useGalleryAuth, useTheme } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
@@ -266,117 +266,16 @@ export const GalleryPage: React.FC = () => {
|
||||
return <GallerySkeleton />;
|
||||
}
|
||||
|
||||
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t('errors.galleryNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
{identifierError}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (infoError) {
|
||||
// Check if it's an archived gallery error
|
||||
const errorMessage = (infoError as any)?.response?.data?.error;
|
||||
const isArchived = errorMessage?.includes('archived');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Logo at top */}
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Legal Links */}
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Gallery missing / archived / expired-link / unresolvable identifier all
|
||||
// collapse into the customisable "gallery-not-found" CMS page (#324).
|
||||
// Admins can edit the title, body, and logo from the CMS Pages tab; the
|
||||
// seeded default copy is intentionally generic so any of those reasons
|
||||
// reads correctly.
|
||||
if (
|
||||
(identifierError && !resolvedSlug && !isResolvingIdentifier) ||
|
||||
infoError
|
||||
) {
|
||||
return <CMSContentBlock slug="gallery-not-found" />;
|
||||
}
|
||||
|
||||
// Show expired state
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react';
|
||||
import { FileText, Globe, Clock, Sparkles, ShieldCheck, Image as ImageIcon, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { debounce } from 'lodash';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -11,6 +11,7 @@ import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import type { CMSPage as CMSPageType } from '../../services/cms.service';
|
||||
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
export const CMSPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -181,6 +182,28 @@ export const CMSPage: React.FC = () => {
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
// Per-page logo upload (#324). Only meaningful for the customisable
|
||||
// error pages right now, but harmless if exposed for any slug.
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: async (file: File) => cmsService.uploadPageLogo(selectedPage, file),
|
||||
onSuccess: ({ logo_url }) => {
|
||||
setEditForm(prev => ({ ...prev, logo_url }));
|
||||
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
|
||||
toast.success(t('cms.logoUploaded', 'Logo uploaded'));
|
||||
},
|
||||
onError: () => toast.error(t('toast.uploadError')),
|
||||
});
|
||||
const clearLogoMutation = useMutation({
|
||||
mutationFn: async () => cmsService.clearPageLogo(selectedPage),
|
||||
onSuccess: () => {
|
||||
setEditForm(prev => ({ ...prev, logo_url: null }));
|
||||
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
|
||||
toast.success(t('cms.logoCleared', 'Logo cleared'));
|
||||
},
|
||||
onError: () => toast.error(t('toast.saveError')),
|
||||
});
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
@@ -483,7 +506,12 @@ export const CMSPage: React.FC = () => {
|
||||
>
|
||||
<FileText className="w-5 h-5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
|
||||
{/* Fall back to the page's own English title for slugs
|
||||
that don't have a fixed translation key (e.g. the new
|
||||
not-found / gallery-not-found error pages). */}
|
||||
<p className="font-medium truncate">
|
||||
{t(`legal.${page.slug}`, { defaultValue: page.title_en || page.slug })}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{page.slug}</p>
|
||||
</div>
|
||||
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||
@@ -550,7 +578,7 @@ export const CMSPage: React.FC = () => {
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
|
||||
{t('cms.editPage', { page: t(`legal.${selectedPage}`, { defaultValue: currentPage?.title_en || selectedPage }) })}
|
||||
</h2>
|
||||
|
||||
{/* Language Tabs */}
|
||||
@@ -603,6 +631,60 @@ export const CMSPage: React.FC = () => {
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-page logo override (#324) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('cms.pageLogo', 'Page Logo')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('cms.pageLogoHelp', 'Optional. If set, used in place of the global branding logo on this page.')}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{editForm.logo_url ? (
|
||||
<img
|
||||
src={buildResourceUrl(editForm.logo_url)}
|
||||
alt="Page logo"
|
||||
className="h-16 w-auto object-contain bg-neutral-50 dark:bg-neutral-700 rounded border border-neutral-200 dark:border-neutral-600 px-3 py-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-32 flex items-center justify-center bg-neutral-50 dark:bg-neutral-700 rounded border border-dashed border-neutral-300 dark:border-neutral-600 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('cms.noLogo', 'no override')}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadLogoMutation.mutate(file);
|
||||
if (logoInputRef.current) logoInputRef.current.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<ImageIcon className="w-4 h-4" />}
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
isLoading={uploadLogoMutation.isPending}
|
||||
>
|
||||
{editForm.logo_url ? t('cms.replaceLogo', 'Replace Logo') : t('cms.uploadLogo', 'Upload Logo')}
|
||||
</Button>
|
||||
{editForm.logo_url && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
onClick={() => clearLogoMutation.mutate()}
|
||||
isLoading={clearLogoMutation.isPending}
|
||||
>
|
||||
{t('cms.clearLogo', 'Use site default')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPage?.updated_at && (
|
||||
|
||||
@@ -7,6 +7,15 @@ export interface CMSPage {
|
||||
title_de: string;
|
||||
content_en: string;
|
||||
content_de: string;
|
||||
logo_url: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PublicCMSPage {
|
||||
title: string;
|
||||
content: string;
|
||||
slug: string;
|
||||
logo_url: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -30,10 +39,27 @@ export const cmsService = {
|
||||
},
|
||||
|
||||
// Get public CMS page (no auth required)
|
||||
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> {
|
||||
const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, {
|
||||
async getPublicPage(slug: string, lang: string = 'en'): Promise<PublicCMSPage> {
|
||||
const response = await api.get<PublicCMSPage>(`/public/pages/${slug}`, {
|
||||
params: { lang }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Upload a per-page logo (#324)
|
||||
async uploadPageLogo(slug: string, file: File): Promise<{ logo_url: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
const response = await api.post<{ logo_url: string }>(
|
||||
`/admin/cms/pages/${slug}/logo`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Clear a per-page logo override (revert to global branding logo).
|
||||
async clearPageLogo(slug: string): Promise<void> {
|
||||
await api.delete(`/admin/cms/pages/${slug}/logo`);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user