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('title_de');
table.text('content_en'); table.text('content_en');
table.text('content_de'); table.text('content_de');
table.string('logo_url').nullable();
table.timestamp('updated_at').defaultTo(db.fn.now()); 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(); 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>', content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(), 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) { for (const page of defaultPages) {
+109 -8
View File
@@ -1,10 +1,42 @@
const express = require('express'); const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const multer = require('multer');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = 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 { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { validateFileType } = require('../utils/fileSecurityUtils');
const router = express.Router(); 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 // Get all CMS pages
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => { router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
try { try {
@@ -38,7 +70,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('title_en').optional().isString(), body('title_en').optional().isString(),
body('title_de').optional().isString(), body('title_de').optional().isString(),
body('content_en').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) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -47,27 +80,30 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
} }
const { slug } = req.params; 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(); const page = await db('cms_pages').where('slug', slug).first();
if (!page) { if (!page) {
return res.status(404).json({ error: 'Page not found' }); return res.status(404).json({ error: 'Page not found' });
} }
// Update the page const updateFields = {
await db('cms_pages')
.where('slug', slug)
.update({
title_en, title_en,
title_de, title_de,
content_en, content_en,
content_de, content_de,
updated_at: new Date() 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(); const updated = await db('cms_pages').where('slug', slug).first();
// Log activity
await logActivity('cms_page_updated', await logActivity('cms_page_updated',
{ page: slug }, { page: slug },
null, null,
@@ -81,4 +117,69 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
} }
}); });
// 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; module.exports = router;
+3
View File
@@ -22,6 +22,9 @@ router.get('/pages/:slug', async (req, res) => {
title, title,
content, content,
slug: page.slug, 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 updated_at: page.updated_at
}); });
} catch (error) { } catch (error) {
+6 -1
View File
@@ -30,7 +30,7 @@ import {
} from './pages/admin'; } from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin'; 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 { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { getApiBaseUrl } from './utils/url'; import { getApiBaseUrl } from './utils/url';
@@ -165,6 +165,11 @@ function App() {
{/* Default redirect */} {/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} /> <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> </Routes>
</MaintenanceWrapper> </MaintenanceWrapper>
</Router> </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
View File
@@ -1,4 +1,5 @@
export { Button } from './Button'; export { Button } from './Button';
export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input'; export { Input } from './Input';
export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading'; export { Loading, LoadingSkeleton } from './Loading';
+11 -112
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate'; import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query'; 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 { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery'; import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery'; import { GalleryView } from '../components/gallery';
@@ -266,117 +266,16 @@ export const GalleryPage: React.FC = () => {
return <GallerySkeleton />; return <GallerySkeleton />;
} }
if (identifierError && !resolvedSlug && !isResolvingIdentifier) { // Gallery missing / archived / expired-link / unresolvable identifier all
return ( // collapse into the customisable "gallery-not-found" CMS page (#324).
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}> // Admins can edit the title, body, and logo from the CMS Pages tab; the
<div className="min-h-screen flex flex-col"> // seeded default copy is intentionally generic so any of those reasons
{settingsData?.branding_logo_url && ( // reads correctly.
<div className="p-8 text-center"> if (
<img (identifierError && !resolvedSlug && !isResolvingIdentifier) ||
src={buildResourceUrl(settingsData.branding_logo_url)} infoError
alt={settingsData.branding_company_name || 'Company Logo'} ) {
className="h-16 w-auto object-contain mx-auto" return <CMSContentBlock slug="gallery-not-found" />;
/>
</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>
);
} }
// Show expired state // Show expired state
+86 -4
View File
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify'; 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 { useTranslation } from 'react-i18next';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@@ -11,6 +11,7 @@ import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service'; import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service'; import type { CMSPage as CMSPageType } from '../../services/cms.service';
import { settingsService, PublicSiteBranding } from '../../services/settings.service'; import { settingsService, PublicSiteBranding } from '../../services/settings.service';
import { buildResourceUrl } from '../../utils/url';
export const CMSPage: React.FC = () => { export const CMSPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -181,6 +182,28 @@ export const CMSPage: React.FC = () => {
setHasUnsavedChanges(true); 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 // Warn before leaving with unsaved changes
useEffect(() => { useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => { const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -483,7 +506,12 @@ export const CMSPage: React.FC = () => {
> >
<FileText className="w-5 h-5 flex-shrink-0" /> <FileText className="w-5 h-5 flex-shrink-0" />
<div className="flex-1 min-w-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> <p className="text-sm text-neutral-500 dark:text-neutral-400">/{page.slug}</p>
</div> </div>
{selectedPage === page.slug && hasUnsavedChanges && ( {selectedPage === page.slug && hasUnsavedChanges && (
@@ -550,7 +578,7 @@ export const CMSPage: React.FC = () => {
<Card padding="md"> <Card padding="md">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> <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> </h2>
{/* Language Tabs */} {/* Language Tabs */}
@@ -603,6 +631,60 @@ export const CMSPage: React.FC = () => {
isSaving={updateMutation.isPending} isSaving={updateMutation.isPending}
/> />
</div> </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> </div>
{currentPage?.updated_at && ( {currentPage?.updated_at && (
+28 -2
View File
@@ -7,6 +7,15 @@ export interface CMSPage {
title_de: string; title_de: string;
content_en: string; content_en: string;
content_de: 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; updated_at: string;
} }
@@ -30,10 +39,27 @@ export const cmsService = {
}, },
// Get public CMS page (no auth required) // Get public CMS page (no auth required)
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> { async getPublicPage(slug: string, lang: string = 'en'): Promise<PublicCMSPage> {
const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, { const response = await api.get<PublicCMSPage>(`/public/pages/${slug}`, {
params: { lang } params: { lang }
}); });
return response.data; 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`);
} }
}; };