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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user