diff --git a/backend/src/database/db.js b/backend/src/database/db.js index f338798c..7b0ab1ef 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -463,8 +463,15 @@ async function ensureGlobalCategories() { table.text('title_de'); table.text('content_en'); table.text('content_de'); + table.string('logo_url').nullable(); 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(); @@ -501,6 +508,24 @@ async function ensureGlobalCategories() { content_de: '

Datenschutzerklärung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', 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: '

Page Not Found

The page you are looking for does not exist or has been moved.

', + content_de: '

Seite nicht gefunden

Die gesuchte Seite existiert nicht oder wurde verschoben.

', + updated_at: new Date(), + }, + { + slug: 'gallery-not-found', + title_en: 'Gallery Not Found', + title_de: 'Galerie nicht gefunden', + content_en: '

Gallery Not Found

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.

', + content_de: '

Galerie nicht gefunden

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.

', + updated_at: new Date(), + }, ]; for (const page of defaultPages) { diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js index 0fc64085..647037b9 100644 --- a/backend/src/routes/adminCMS.js +++ b/backend/src/routes/adminCMS.js @@ -1,10 +1,42 @@ const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const multer = require('multer'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { validateFileType } = require('../utils/fileSecurityUtils'); 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 router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => { try { @@ -21,11 +53,11 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, 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' }); } - + res.json(page); } catch (error) { console.error('Error fetching CMS page:', error); @@ -38,42 +70,46 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ body('title_en').optional().isString(), body('title_de').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) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } - + 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(); if (!page) { return res.status(404).json({ error: 'Page not found' }); } - - // Update the page - await db('cms_pages') - .where('slug', slug) - .update({ - title_en, - title_de, - content_en, - content_de, - updated_at: new Date() - }); - + + const updateFields = { + title_en, + title_de, + content_en, + content_de, + 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(); - - // Log activity + await logActivity('cms_page_updated', { page: slug }, null, { type: 'admin', id: req.admin.id, name: req.admin.username } ); - + res.json(updated); } catch (error) { console.error('Error updating CMS page:', error); @@ -81,4 +117,69 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ } }); -module.exports = router; \ No newline at end of file +// 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; diff --git a/backend/src/routes/publicCMS.js b/backend/src/routes/publicCMS.js index 8a69f27a..b999ed0f 100644 --- a/backend/src/routes/publicCMS.js +++ b/backend/src/routes/publicCMS.js @@ -22,6 +22,9 @@ router.get('/pages/:slug', async (req, res) => { title, content, 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 }); } catch (error) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 688150bb..ed0f113f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 */} } /> + + {/* 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. */} + } /> diff --git a/frontend/src/components/common/CMSContentBlock.tsx b/frontend/src/components/common/CMSContentBlock.tsx new file mode 100644 index 00000000..ee93cf3c --- /dev/null +++ b/frontend/src/components/common/CMSContentBlock.tsx @@ -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 = ({ 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 ( +
+ +
+ ); + } + + 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 ( +
+
+ {companyName} +
+ +
+
+ +

+ {page.title} +

+
+
+ + {lang === 'de' ? '← Zur Startseite' : '← Back to home'} + +
+ +
+
+ +
+
+ + {lang === 'de' ? 'Impressum' : 'Legal Notice'} + + + + {lang === 'de' ? 'Datenschutz' : 'Privacy Policy'} + +
+ {!settings?.branding_hide_powered_by && ( +

+ Powered by PicPeak +

+ )} +
+
+ ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 4251fb50..1ac81226 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -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'; diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index c37aae3e..f440bbc7 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -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 ; } - if (identifierError && !resolvedSlug && !isResolvingIdentifier) { - return ( -
-
- {settingsData?.branding_logo_url && ( -
- {settingsData.branding_company_name -
- )} - -
- - - -

- {t('errors.galleryNotFound')} -

-

- {identifierError} -

-
-
-
- -
-
- - {t('legal.impressum')} - - | - - {t('legal.datenschutz')} - -
-

- Powered by PicPeak -

-
-
-
- ); - } - - // 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 ( -
-
- {/* Logo at top */} - {settingsData?.branding_logo_url && ( -
- {settingsData.branding_company_name -
- )} - -
- - - -

- {t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')} -

-

- {t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')} -

-
-
-
- - {/* Legal Links */} -
-
- - {t('legal.impressum')} - - | - - {t('legal.datenschutz')} - -
-

- Powered by PicPeak -

-
-
-
- ); + // 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 ; } // Show expired state diff --git a/frontend/src/pages/admin/CMSPage.tsx b/frontend/src/pages/admin/CMSPage.tsx index 804071a1..40c75fbe 100644 --- a/frontend/src/pages/admin/CMSPage.tsx +++ b/frontend/src/pages/admin/CMSPage.tsx @@ -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(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 = () => { >
-

{t(`legal.${page.slug}`)}

+ {/* 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). */} +

+ {t(`legal.${page.slug}`, { defaultValue: page.title_en || page.slug })} +

/{page.slug}

{selectedPage === page.slug && hasUnsavedChanges && ( @@ -550,7 +578,7 @@ export const CMSPage: React.FC = () => {

- {t('cms.editPage', { page: t(`legal.${selectedPage}`) })} + {t('cms.editPage', { page: t(`legal.${selectedPage}`, { defaultValue: currentPage?.title_en || selectedPage }) })}

{/* Language Tabs */} @@ -603,6 +631,60 @@ export const CMSPage: React.FC = () => { isSaving={updateMutation.isPending} />
+ + {/* Per-page logo override (#324) */} +
+ +

+ {t('cms.pageLogoHelp', 'Optional. If set, used in place of the global branding logo on this page.')} +

+
+ {editForm.logo_url ? ( + Page logo + ) : ( +
+ {t('cms.noLogo', 'no override')} +
+ )} + { + const file = e.target.files?.[0]; + if (file) uploadLogoMutation.mutate(file); + if (logoInputRef.current) logoInputRef.current.value = ''; + }} + /> + + {editForm.logo_url && ( + + )} +
+
{currentPage?.updated_at && ( diff --git a/frontend/src/services/cms.service.ts b/frontend/src/services/cms.service.ts index bd209104..0feccbc9 100644 --- a/frontend/src/services/cms.service.ts +++ b/frontend/src/services/cms.service.ts @@ -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 { + const response = await api.get(`/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 { + await api.delete(`/admin/cms/pages/${slug}/logo`); } }; \ No newline at end of file