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
+6 -1
View File
@@ -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
View File
@@ -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';
+11 -112
View File
@@ -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
+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 { 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 && (
+28 -2
View File
@@ -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`);
}
};