From 822be9a9b2716f1832a4cb6fccd53602e3cbab51 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 14:59:27 +0200 Subject: [PATCH 01/10] fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #323-A — Branding colour changes weren't persisting unless "Apply changes immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was gating its `onChange` callback on `isPreviewMode`, but the parent BrandingPage already gates global `setTheme()` on its own copy of that flag — so the customizer's gate was double-gating and silently dropped the new values from the parent state that Save reads from. Always propagate `onChange`; let parents decide what's "live". Removed the now no-op `isPreviewMode` prop and dropped the unused passers. #323-B — Default theme set in Branding wasn't applied to new events. CreateEventPage only inherited the event-type's recommended preset, with 'default' falling back to Classic Grid. Now reads `settings.theme_config` on first load and uses it as the form's starting theme; the event-type effect skips the generic 'default' so the Branding default sticks for event types like "Other". #321 — Visitors saw four sequential render states when opening a gallery (full-page "Loading Gallery" → "publicly accessible — loading photos" card → skeleton grid → real gallery). Extracted the skeleton into a shared and used it for both GalleryView's photos- loading state and GalleryPage's gallery-info-loading + public-auto-login phases. The "publicly accessible" interstitial is gone. Net: one continuous skeleton from URL open until real photos render. --- .../admin/ThemeCustomizerEnhanced.tsx | 26 ++-- .../components/gallery/GallerySkeleton.tsx | 31 +++++ .../src/components/gallery/GalleryView.tsx | 29 +---- frontend/src/pages/GalleryPage.tsx | 115 +++++++----------- frontend/src/pages/admin/BrandingPage.tsx | 1 - frontend/src/pages/admin/CreateEventPage.tsx | 39 +++++- 6 files changed, 124 insertions(+), 117 deletions(-) create mode 100644 frontend/src/components/gallery/GallerySkeleton.tsx diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index 3ad8dfbd..8278f8b5 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -12,7 +12,6 @@ interface ThemeCustomizerEnhancedProps { onChange: (theme: ThemeConfig) => void; presetName?: string; onPresetChange?: (presetName: string) => void; - isPreviewMode?: boolean; showGalleryLayouts?: boolean; hideActions?: boolean; onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise | void; @@ -76,7 +75,6 @@ export const ThemeCustomizerEnhanced: React.FC = ( onChange, presetName = 'default', onPresetChange, - isPreviewMode = false, showGalleryLayouts = true, hideActions = false, onApply, @@ -125,10 +123,11 @@ export const ThemeCustomizerEnhanced: React.FC = ( onPresetChange('custom'); } - if (isPreviewMode) { - // Include customCss in the propagated theme - onChange({ ...updated, customCss }); - } + // Always propagate to parent so Save sees the latest values (#323). + // The "Apply changes immediately (Live Preview)" toggle controls whether + // the parent applies the theme globally — that gating belongs in the + // parent, not here. + onChange({ ...updated, customCss }); }; const handlePresetSelect = (presetKey: string) => { @@ -140,9 +139,8 @@ export const ThemeCustomizerEnhanced: React.FC = ( if (onPresetChange) { onPresetChange(presetKey); } - if (isPreviewMode) { - onChange(preset.config); - } + // Always propagate; live-apply gating is the parent's concern (#323). + onChange(preset.config); } }; @@ -795,7 +793,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( mutedTextColor: '#a3a3a3', }; setLocalTheme(updated); - if (isPreviewMode) onChange({ ...updated, customCss }); + onChange({ ...updated, customCss }); } else if (mode === 'light' && localTheme.colorMode === 'dark') { const updated = { ...localTheme, @@ -807,7 +805,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( mutedTextColor: '#737373', }; setLocalTheme(updated); - if (isPreviewMode) onChange({ ...updated, customCss }); + onChange({ ...updated, customCss }); } }} className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${ @@ -1181,10 +1179,8 @@ export const ThemeCustomizerEnhanced: React.FC = ( setSelectedPreset('custom'); onPresetChange('custom'); } - // Propagate customCss changes to parent in preview mode - if (isPreviewMode) { - onChange({ ...localTheme, customCss: newCss }); - } + // Propagate to parent so Save sees the latest CSS (#323). + onChange({ ...localTheme, customCss: newCss }); }} placeholder="/* Add custom CSS here */" className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" diff --git a/frontend/src/components/gallery/GallerySkeleton.tsx b/frontend/src/components/gallery/GallerySkeleton.tsx new file mode 100644 index 00000000..4f2046d4 --- /dev/null +++ b/frontend/src/components/gallery/GallerySkeleton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Skeleton, SkeletonGalleryGrid } from '../common'; + +/** + * Loading placeholder shown while a gallery is resolving (slug → info → + * auto-login → photos). Used by GalleryPage during the pre-photos phases and + * by GalleryView while the photos query runs, so the visitor sees one + * continuous skeleton instead of multiple full-page interstitials (#321). + */ +export const GallerySkeleton: React.FC = () => ( +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+); diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 1f55dc30..f51afb58 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -3,7 +3,8 @@ import { differenceInDays, parseISO } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Button, SkeletonGalleryGrid, Skeleton } from '../common'; +import { Button } from '../common'; +import { GallerySkeleton } from './GallerySkeleton'; import { useGalleryAuth, useTheme } from '../../contexts'; import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery'; import { PhotoGridWithLayouts } from './PhotoGridWithLayouts'; @@ -587,31 +588,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { }, [showUrgentWarning, daysUntilExpiration, slug]); if (isLoading) { - return ( -
- {/* Header Skeleton */} -
-
-
-
- - -
-
- - -
-
-
-
- - {/* Content Skeleton */} -
- - -
-
- ); + return ; } if (error || !data) { diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 35f75578..c37aae3e 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../hooks/useLocalizedDate'; import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common'; +import { Card, CardContent, Input, Button, ReCaptcha } from '../components/common'; import { useGalleryAuth, useTheme } from '../contexts'; import { useGalleryInfo } from '../hooks/useGallery'; import { GalleryView } from '../components/gallery'; +import { GallerySkeleton } from '../components/gallery/GallerySkeleton'; import { analyticsService } from '../services/analytics.service'; import { galleryService } from '../services'; import { api } from '../config/api'; @@ -258,15 +259,11 @@ export const GalleryPage: React.FC = () => { } }; - // Show loading state + // Show the same skeleton GalleryView uses while photos load, so the + // visitor sees one continuous loading state from URL open to real photos + // instead of three different full-page interstitials (#321). if (isLoadingInfo) { - return ( -
-
- -
-
- ); + return ; } if (identifierError && !resolvedSlug && !isResolvingIdentifier) { @@ -448,6 +445,13 @@ export const GalleryPage: React.FC = () => { return ; } + // Public gallery: auto-login is in flight (or about to fire). Show the + // skeleton instead of the "publicly accessible — loading photos" card so + // visitors see one continuous skeleton until real photos appear (#321). + if (!requiresPassword) { + return ; + } + // Show login form return (
@@ -487,67 +491,40 @@ export const GalleryPage: React.FC = () => { - {requiresPassword ? ( - <> -

{t('auth.enterPassword')}

- -
- setPassword(e.target.value)} - error={loginError || undefined} - autoFocus - className="text-sm sm:text-base" - /> - - setRecaptchaToken(null)} - /> - - - +

{t('auth.enterPassword')}

-

- {t('auth.passwordHint')} -

- - ) : ( -
- {isLoadingSettings ? ( -
- -
- ) : ( - <> -

- {t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')} -

-

- {t('gallery.publicGallerySubtitle', 'Loading the photos now...')} -

-
- -
- - )} - {loginError && ( -

{loginError}

- )} -
- )} +
+ setPassword(e.target.value)} + error={loginError || undefined} + autoFocus + className="text-sm sm:text-base" + /> + + setRecaptchaToken(null)} + /> + + + + +

+ {t('auth.passwordHint')} +

diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 1fb2e86a..fc26fd6d 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -738,7 +738,6 @@ export const BrandingPage: React.FC = () => { onChange={handleThemeChange} presetName={currentThemeName} onPresetChange={handlePresetChange} - isPreviewMode={isPreviewMode} showGalleryLayouts={true} hideActions={true} /> diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index ce30d945..b96c29b3 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -204,13 +204,41 @@ export const CreateEventPage: React.FC = () => { })); }, [publicSettings]); - // Update theme when event type changes + // Apply the global Branding default theme on first load so admins who set a + // site-wide default in Branding actually see it on new events (#323). + const brandingThemeApplied = useRef(false); useEffect(() => { - // Find the selected event type's theme preset - const selectedType = availableEventTypes.find(t => t.value === formData.event_type); - const recommendedPreset = selectedType?.theme_preset || 'default'; + if (brandingThemeApplied.current) return; + const brandingTheme = settings?.theme_config as ThemeConfig | undefined; + if (!brandingTheme || Object.keys(brandingTheme).length === 0) return; + brandingThemeApplied.current = true; - if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) { + // Identify which preset (if any) the Branding theme matches, so the + // "Theme & Style" panel shows the right name. + let matchedPreset = 'custom'; + for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) { + if (JSON.stringify(preset.config) === JSON.stringify(brandingTheme)) { + matchedPreset = key; + break; + } + } + + setFormData(prev => ({ + ...prev, + theme_preset: matchedPreset, + theme_config: brandingTheme + })); + }, [settings]); + + // Update theme when event type changes — but only when the event type has + // an explicit recommended preset. Skip the generic 'default' so the global + // Branding theme isn't clobbered by Classic Grid for event types like + // "Other" (#323). + useEffect(() => { + const selectedType = availableEventTypes.find(t => t.value === formData.event_type); + const recommendedPreset = selectedType?.theme_preset; + + if (recommendedPreset && recommendedPreset !== 'default' && GALLERY_THEME_PRESETS[recommendedPreset]) { setFormData(prev => ({ ...prev, theme_preset: recommendedPreset, @@ -525,7 +553,6 @@ export const CreateEventPage: React.FC = () => { onChange={handleThemeChange} presetName={formData.theme_preset} onPresetChange={handlePresetChange} - isPreviewMode={true} showGalleryLayouts={true} hideActions={true} /> From 8d0fb8e157aed66e89c592d23667d2a2028fbd89 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 15:30:03 +0200 Subject: [PATCH 02/10] chore: expose pid + uptime on /health for crash-detection monitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `pid` and `uptime` fields to the /health response so external monitors (and the local E2E watchdog) can detect a silent process restart between two checks — e.g. an unhandled rejection that crashes Node and Docker quietly relaunches the container. Also adds .gitignore patterns for a local-only E2E suite that lives in tests/e2e/local/ on individual machines and is never pushed. --- .gitignore | 6 ++++++ backend/server.js | 13 ++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 931739a7..a49a1d81 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,12 @@ backup/ # Local artifacts from browser tooling .playwright-mcp/ +# Local-only E2E suite (never pushed; runs as pre-push gate on this machine) +tests/e2e/local/ +playwright-local-results/ +e2e-test.log +scripts/e2e-local.sh + # Local SQLite files in backend backend/*.sqlite* backend/*.db diff --git a/backend/server.js b/backend/server.js index 5bd6f1b4..ba62cc4a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -486,21 +486,24 @@ app.get('/robots.txt', async (req, res) => { } }); -// Health check endpoint +// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E +// watchdog) detect a silent process restart between two checks. app.get('/health', async (req, res) => { try { - // Check database connectivity await db.raw('SELECT 1'); - res.json({ status: 'ok', - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + pid: process.pid, + uptime: process.uptime() }); } catch (error) { logger.error('Health check failed:', error); res.status(503).json({ status: 'error', - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + pid: process.pid, + uptime: process.uptime() }); } }); From 793e410554b461522fbe24014dfd3baa915da2bb Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 16:13:22 +0200 Subject: [PATCH 03/10] fix: floor password_changed_at when comparing against JWT iat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT `iat` has 1-second resolution; `password_changed_at` is stored with sub-second precision. The previous comparison rejected tokens whose iat fell in the same wall-clock second as a password change — e.g. a token issued by an immediate re-login after a password reset, or by any script-driven flow that resets and logs in in quick succession. Floor the stored timestamp to whole seconds before comparing. Caught while wiring up the local E2E suite: the seeder needed a "set password_changed_at 10 s in the past" hack to avoid this race; with the fix in place that hack is gone and the suite is naturally deterministic. --- backend/src/middleware/auth.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 4522d7c4..214485f1 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -91,10 +91,16 @@ async function adminAuth(req, res, next) { return res.status(401).json({ error: 'Invalid token' }); } - // Check if password was changed after token was issued + // Check if password was changed after token was issued. JWT `iat` has + // 1-second resolution; `password_changed_at` is sub-second. Floor the + // comparison so a token issued in the *same* second as the password + // change isn't incorrectly rejected — that race used to bite anyone + // logging in immediately after a password reset/change. if (admin.password_changed_at) { - const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; - if (decoded.iat < passwordChangedTime) { + const passwordChangedSeconds = Math.floor( + new Date(admin.password_changed_at).getTime() / 1000 + ); + if (decoded.iat < passwordChangedSeconds) { logger.warn('Token used after password change', { userId: decoded.id }); return res.status(401).json({ error: 'Token invalid due to password change', From b63a8774c4b44733b903736b2ca5a472a884055e Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 16:49:50 +0200 Subject: [PATCH 04/10] fix: theme-preset match loop ignores extra fields like logoUrl (#323) The "which preset does this saved theme match?" loop in BrandingPage and CreateEventPage was doing a full JSON.stringify equality on preset.config vs the loaded theme. The previous #323 logo-preservation work means the saved theme legitimately carries a `logoUrl` (and any other fields the parent maintains), so the equality check would never match and the preset summary fell back to "Custom Theme" / Classic Grid even when the saved theme was structurally Dark Modern, etc. Compare only on the preset's own keys instead. Surfaced by the new smoke spec 07-branding-default-on-create-event which would otherwise pass green against the broken state. --- frontend/src/pages/admin/BrandingPage.tsx | 11 +++++++++-- frontend/src/pages/admin/CreateEventPage.tsx | 11 ++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index fc26fd6d..2e6c84b9 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -106,9 +106,16 @@ export const BrandingPage: React.FC = () => { setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl })); } - // Try to identify which preset this matches + // Try to identify which preset this matches. Compare only on the + // fields the preset itself defines so saved themes carrying extras + // like a `logoUrl` (preserved through preset changes — see + // handlePresetChange) still match the original preset shape. for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) { - if (JSON.stringify(preset.config) === JSON.stringify(formatted)) { + const keys = Object.keys(preset.config); + const matches = keys.every((k) => + JSON.stringify((preset.config as any)[k]) === JSON.stringify((formatted as any)[k]) + ); + if (matches) { setCurrentThemeName(key); break; } diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index b96c29b3..11d59417 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -213,11 +213,16 @@ export const CreateEventPage: React.FC = () => { if (!brandingTheme || Object.keys(brandingTheme).length === 0) return; brandingThemeApplied.current = true; - // Identify which preset (if any) the Branding theme matches, so the - // "Theme & Style" panel shows the right name. + // Identify which preset (if any) the Branding theme matches. Compare + // only on the preset's own fields so saved themes carrying extras + // (e.g. logoUrl preserved through preset changes) still match. let matchedPreset = 'custom'; for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) { - if (JSON.stringify(preset.config) === JSON.stringify(brandingTheme)) { + const keys = Object.keys(preset.config); + const matches = keys.every((k) => + JSON.stringify((preset.config as any)[k]) === JSON.stringify((brandingTheme as any)[k]) + ); + if (matches) { matchedPreset = key; break; } From 4f77905b87bea474b3d2496350996deaad041230 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 17:20:29 +0200 Subject: [PATCH 05/10] feat: customisable 404 + gallery-not-found pages via CMS (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--` filename prefix. - adminCMS PUT now accepts logo_url; publicCMS GET returns it. Frontend: - New 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.` translation is registered. --- backend/src/database/db.js | 25 +++ backend/src/routes/adminCMS.js | 145 +++++++++++++++--- backend/src/routes/publicCMS.js | 3 + frontend/src/App.tsx | 7 +- .../src/components/common/CMSContentBlock.tsx | 134 ++++++++++++++++ frontend/src/components/common/index.ts | 1 + frontend/src/pages/GalleryPage.tsx | 123 ++------------- frontend/src/pages/admin/CMSPage.tsx | 90 ++++++++++- frontend/src/services/cms.service.ts | 30 +++- 9 files changed, 417 insertions(+), 141 deletions(-) create mode 100644 frontend/src/components/common/CMSContentBlock.tsx 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 From be6cb28c8097d2277c1af2a32cf8bc88ebbc7136 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 18:40:58 +0200 Subject: [PATCH 06/10] feat: optional customer phone field gated by global toggle (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `customer_phone` column on events plus an `event_phone_field_enabled` admin setting (default off) that surfaces the input in the create-event and event-detail forms. Designed for downstream automation tooling — once exposed via the upcoming public API, n8n / similar can pick it up to deliver gallery links over WhatsApp, SMS, etc. - Migration 080 adds the column + seeds the setting as false. Existing deployments see no UI change unless the admin opts in via Settings → Events. - Backend strips the field server-side when the toggle is off (defence in depth against form bypass). - Frontend renders the input only when the public-settings flag is true; always optional even then. - publicSettings + EventSettings types extended; CreateEventPage and EventDetailsPage wired to read the toggle and submit the value. --- .../migrations/core/080_add_customer_phone.js | 33 +++++++++++++ backend/src/routes/adminEvents.js | 48 ++++++++++++++++++- backend/src/routes/publicSettings.js | 5 +- .../settings/hooks/useSettingsState.ts | 7 ++- .../src/features/settings/tabs/EventsTab.tsx | 19 ++++++++ frontend/src/pages/admin/CreateEventPage.tsx | 14 ++++++ frontend/src/pages/admin/EventDetailsPage.tsx | 23 +++++++++ .../src/services/publicSettings.service.ts | 1 + 8 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/core/080_add_customer_phone.js diff --git a/backend/migrations/core/080_add_customer_phone.js b/backend/migrations/core/080_add_customer_phone.js new file mode 100644 index 00000000..db2c765e --- /dev/null +++ b/backend/migrations/core/080_add_customer_phone.js @@ -0,0 +1,33 @@ +const { addColumnIfNotExists } = require('../helpers'); + +/** + * #322 — optional phone-number field on events. Off by default; surfaced + * only when the global `event_phone_field_enabled` app setting is true, + * so existing deployments see no UI change unless the admin opts in. + */ +exports.up = async function up(knex) { + await addColumnIfNotExists(knex, 'events', 'customer_phone', (table) => { + table.string('customer_phone', 32).nullable(); + }); + + // Seed the global enable flag (default false). + const exists = await knex('app_settings') + .where('setting_key', 'event_phone_field_enabled') + .first(); + if (!exists) { + await knex('app_settings').insert({ + setting_key: 'event_phone_field_enabled', + setting_value: JSON.stringify(false), + setting_type: 'boolean' + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasColumn('events', 'customer_phone')) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_phone'); + }); + } + await knex('app_settings').where('setting_key', 'event_phone_field_enabled').delete(); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index c12dadd4..a729aacb 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -185,6 +185,25 @@ const getBrandingDefaults = async () => { // Use parseStringInput from shared parsers for customer data extraction const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); +const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); + +// Whether the global "phone field" toggle (#322) is enabled. Cached for +// the request via a module-level read; drift is acceptable since this +// only governs whether to persist the field, not security boundaries. +const isPhoneFieldEnabled = async () => { + try { + const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + if (!row) return false; + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return value === true; + } catch (error) { + logger.debug('Failed to read event_phone_field_enabled', { error: error.message }); + return false; + } +}; const mapEventForApi = (event) => { if (!event || typeof event !== 'object') { @@ -196,6 +215,7 @@ const mapEventForApi = (event) => { host_email, customer_name, customer_email, + customer_phone, password_hash: _ph, client_password_hash: _cph, ...rest @@ -204,7 +224,8 @@ const mapEventForApi = (event) => { return { ...rest, customer_name: customer_name ?? host_name ?? null, - customer_email: customer_email ?? host_email ?? null + customer_email: customer_email ?? host_email ?? null, + customer_phone: customer_phone ?? null }; }; @@ -239,6 +260,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('event_date').optional({ values: 'falsy' }).isDate(), body('customer_name').optional().trim(), body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -354,6 +378,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ const customerName = getCustomerNameFromPayload(req.body); const customerEmail = getCustomerEmailFromPayload(req.body); + // Phone field is opt-in via the global setting (#322). If disabled, + // ignore whatever the client posted — defence in depth against form + // bypass. + const phoneEnabled = await isPhoneFieldEnabled(); + const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null; const customerColumnsAvailable = await hasCustomerContactColumns(); @@ -509,6 +538,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ event_name, event_date: event_date || null, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + ...(customerPhone ? { customer_phone: customerPhone } : {}), host_name: customerName || null, host_email: customerEmail || null, admin_email: admin_email || null, @@ -863,6 +893,9 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne body('allow_user_uploads').optional().isBoolean(), body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(), body('customer_email').optional().isEmail().normalizeEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), body('upload_category_id').optional().custom((value) => { // Accept null, undefined, or integer values if (value === null || value === undefined) return true; @@ -961,6 +994,19 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne } } + // Phone is gated on the global toggle (#322). Strip from the update + // unconditionally if disabled — even null/clear is rejected so an + // admin can't accidentally write to a field they've turned off. + if (Object.prototype.hasOwnProperty.call(updates, 'customer_phone')) { + const phoneEnabled = await isPhoneFieldEnabled(); + if (!phoneEnabled) { + delete updates.customer_phone; + } else { + const nextPhone = getCustomerPhoneFromPayload(updates); + updates.customer_phone = nextPhone || null; + } + } + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); let requirePasswordUpdate; if (hasRequirePasswordUpdate) { diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index ff7441e1..1d689b3d 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -16,7 +16,8 @@ router.get('/', async (req, res) => { .orWhereIn('setting_key', [ 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'event_default_require_password', - 'gallery_show_filter_bar' + 'gallery_show_filter_bar', + 'event_phone_field_enabled' ]); }) .select('setting_key', 'setting_value'); @@ -84,6 +85,8 @@ router.get('/', async (req, res) => { event_require_expiration: settingsObject.event_require_expiration !== false, // Default value for "Require password" toggle in event creation form event_default_require_password: settingsObject.event_default_require_password !== false, + // Phone-number field on events is opt-in (#322). + event_phone_field_enabled: settingsObject.event_phone_field_enabled === true, // Whether to show the search/sort filter bar in public galleries (default: true) gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false, // Upload settings (safe to expose - needed for client-side validation) diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 1c3b7cfb..911d36cc 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -52,6 +52,7 @@ export interface EventSettings { event_require_expiration: boolean; event_default_require_password: boolean; gallery_show_filter_bar: boolean; + event_phone_field_enabled: boolean; } export interface SeoSettings { @@ -127,7 +128,8 @@ export function useSettingsState() { event_require_event_date: true, event_require_expiration: true, event_default_require_password: true, - gallery_show_filter_bar: true + gallery_show_filter_bar: true, + event_phone_field_enabled: false }); // SEO settings state @@ -212,7 +214,8 @@ export function useSettingsState() { event_require_event_date: toBoolean(settings.event_require_event_date, true), event_require_expiration: toBoolean(settings.event_require_expiration, true), event_default_require_password: toBoolean(settings.event_default_require_password, true), - gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true) + gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true), + event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false) }); setSeoSettings({ diff --git a/frontend/src/features/settings/tabs/EventsTab.tsx b/frontend/src/features/settings/tabs/EventsTab.tsx index c89c3202..1365d3c7 100644 --- a/frontend/src/features/settings/tabs/EventsTab.tsx +++ b/frontend/src/features/settings/tabs/EventsTab.tsx @@ -187,6 +187,25 @@ export const EventsTab: React.FC = ({ + +
+ +
diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 11d59417..3aa64bbd 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -35,6 +35,7 @@ interface FormData { event_date: string; customer_name: string; customer_email: string; + customer_phone: string; admin_email: string; require_password: boolean; password: string; @@ -95,6 +96,7 @@ export const CreateEventPage: React.FC = () => { event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format customer_name: '', customer_email: '', + customer_phone: '', admin_email: '', require_password: true, password: '', @@ -177,6 +179,7 @@ export const CreateEventPage: React.FC = () => { // Get field requirements (default to true if not set) const requireCustomerName = publicSettings?.event_require_customer_name !== false; const requireCustomerEmail = publicSettings?.event_require_customer_email !== false; + const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true; const requireAdminEmail = publicSettings?.event_require_admin_email !== false; const requireEventDate = publicSettings?.event_require_event_date !== false; const requireExpiration = publicSettings?.event_require_expiration !== false; @@ -351,6 +354,7 @@ export const CreateEventPage: React.FC = () => { event_date: formData.event_date || undefined, customer_name: formData.customer_name, customer_email: formData.customer_email, + ...(phoneFieldEnabled && formData.customer_phone ? { customer_phone: formData.customer_phone.trim() } : {}), admin_email: formData.admin_email, require_password: formData.require_password, password: formData.require_password ? formData.password : undefined, @@ -654,6 +658,16 @@ export const CreateEventPage: React.FC = () => { />
+ {phoneFieldEnabled && ( + + )} + { hero_photo_id: number | null; customer_name: string; customer_email: string; + customer_phone: string; source_mode: 'managed' | 'reference'; external_path: string; require_password: boolean; @@ -184,6 +185,7 @@ export const EventDetailsPage: React.FC = () => { hero_photo_id: null, customer_name: '', customer_email: '', + customer_phone: '', source_mode: 'managed', external_path: '', require_password: true, @@ -338,6 +340,7 @@ export const EventDetailsPage: React.FC = () => { queryFn: () => publicSettingsService.getPublicSettings(), }); const requireExpiration = publicSettings?.event_require_expiration !== false; + const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true; // Fetch categories for the event const { data: categories = [] } = useQuery({ @@ -430,6 +433,7 @@ export const EventDetailsPage: React.FC = () => { hero_photo_id: event.hero_photo_id || null, customer_name: event.customer_name || '', customer_email: event.customer_email || '', + customer_phone: (event as any).customer_phone || '', source_mode: event.source_mode === 'reference' ? 'reference' : 'managed', external_path: event.external_path || '', require_password: normalizeRequirePassword(event.require_password), @@ -617,6 +621,11 @@ export const EventDetailsPage: React.FC = () => { if (editForm.customer_email !== undefined && editForm.customer_email !== null && editForm.customer_email.trim()) { updateData.customer_email = editForm.customer_email; } + if (editForm.customer_phone !== undefined) { + // Send empty string as null so an admin can clear the field. Backend + // strips this entirely if the global phone-field toggle is off. + updateData.customer_phone = editForm.customer_phone.trim() || null; + } if (editForm.new_password) { updateData.password = editForm.new_password; @@ -970,6 +979,20 @@ export const EventDetailsPage: React.FC = () => { /> + {phoneFieldEnabled && ( +
+ + setEditForm(prev => ({ ...prev, customer_phone: e.target.value }))} + placeholder={t('events.customerPhonePlaceholder', '+1 555 555 1234')} + /> +
+ )} +
); }; diff --git a/scripts/sync-api-docs.sh b/scripts/sync-api-docs.sh new file mode 100755 index 00000000..44f0aedd --- /dev/null +++ b/scripts/sync-api-docs.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Local-only API docs sync. Generates docs/openapi.{json,yaml} from +# the @openapi JSDoc blocks in backend/src/routes/v1/*, then copies the +# result into the picpeak-docs Nextra site at /Users/paul/Development/picpeak-docs/app/api/. +# +# Writes only — never commits or pushes the docs repo. Review the diff +# in picpeak-docs and commit there manually when ready. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOCS_REPO="${PICPEAK_DOCS_REPO:-/Users/paul/Development/picpeak-docs}" +SRC_DIR="$REPO_ROOT/docs" +TARGET_DIR="$DOCS_REPO/app/api" + +cd "$REPO_ROOT/backend" + +# 1. Generate fresh spec from JSDoc. +echo "▶ Generating OpenAPI spec from src/routes/v1/*" +node scripts/generate-openapi.js + +# 2. Verify docs repo is reachable. Soft-fail so this doesn't block a +# push when the docs repo isn't on this machine. +if [ ! -d "$DOCS_REPO" ]; then + echo "▶ Docs repo not found at $DOCS_REPO — skipping sync." + echo " (Set PICPEAK_DOCS_REPO to override, or create the path to enable sync.)" + exit 0 +fi +if [ ! -d "$TARGET_DIR" ]; then + echo "▶ Target dir $TARGET_DIR doesn't exist — creating." + mkdir -p "$TARGET_DIR" +fi + +# 3. Copy spec files into the docs repo. We do NOT git-add or commit +# here — the user reviews and commits picpeak-docs manually. +cp "$SRC_DIR/openapi.json" "$TARGET_DIR/openapi.json" +cp "$SRC_DIR/openapi.yaml" "$TARGET_DIR/openapi.yaml" +echo "▶ Wrote openapi.{json,yaml} to $TARGET_DIR" + +# 4. Brief drop-in MDX page that references the spec, so the Nextra +# nav has a stable target. Won't overwrite a hand-edited file — +# only writes if missing. +REF_MDX="$TARGET_DIR/reference.mdx" +if [ ! -f "$REF_MDX" ]; then + cat > "$REF_MDX" <<'EOF' +--- +title: API Reference +--- + +# API Reference + +The PicPeak v1 REST API is documented as an OpenAPI 3 spec. + +- [Download `openapi.yaml`](./openapi.yaml) +- [Download `openapi.json`](./openapi.json) +- A live, browseable Swagger UI is served by every PicPeak instance at + `/api/docs` (admin login required). + +This page is auto-generated from JSDoc annotations on the v1 route files. +Do not hand-edit. The narrative pages (auth, recipes) live alongside. +EOF + echo "▶ Created $REF_MDX (placeholder — replace with your preferred renderer)" +fi + +echo "✓ API docs synced. Review changes in $DOCS_REPO before committing." From 2eead523193ccb7f23eb767097ad9698e8312833 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 22:21:12 +0200 Subject: [PATCH 08/10] fix: theme picker buttons no longer submit the parent form (#326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every