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
+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 && (