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:
@@ -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