From 4790ea5ccdc01f6cf3c5369b001618ed73d80b75 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:26:56 +0200 Subject: [PATCH] feat(branding): dark-mode logo variant Add an optional dark-mode logo (branding_logo_url_dark) alongside the main logo. Upload/remove via the logo endpoint (?variant=dark) on the Branding settings page. Admin header (admin dark mode) and the public gallery (dark themes) pick the dark logo when active, falling back to the light logo when unset. PDFs keep using the light logo. --- backend/src/routes/adminSettings.js | 43 +++++++++- backend/src/routes/publicSettings.js | 1 + frontend/src/components/admin/AdminHeader.tsx | 5 +- .../src/components/gallery/GalleryLayout.tsx | 18 ++-- .../src/components/gallery/GalleryView.tsx | 1 + frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + frontend/src/pages/admin/BrandingPage.tsx | 86 +++++++++++++++++++ .../src/services/publicSettings.service.ts | 1 + frontend/src/services/settings.service.ts | 16 ++-- 10 files changed, 159 insertions(+), 16 deletions(-) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 1385a138..8b0211cf 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -544,9 +544,16 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl return res.status(400).json({ error: 'No logo file uploaded' }); } + // ?variant=dark stores a separate dark-mode logo (branding_logo_*_dark); + // anything else is the default (light) logo. Consumers pick the dark + // variant when the active theme is dark, falling back to the light one. + const isDark = req.query.variant === 'dark' || req.body.variant === 'dark'; + const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path'; + const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url'; + // Get old logo to delete const oldLogoSetting = await db('app_settings') - .where('setting_key', 'branding_logo_path') + .where('setting_key', pathKey) .first(); if (oldLogoSetting && oldLogoSetting.setting_value) { @@ -568,7 +575,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl await db('app_settings') .insert({ - setting_key: 'branding_logo_path', + setting_key: pathKey, setting_value: JSON.stringify(logoPath), setting_type: 'branding', updated_at: new Date() @@ -582,7 +589,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl // Save public URL await db('app_settings') .insert({ - setting_key: 'branding_logo_url', + setting_key: urlKey, setting_value: JSON.stringify(publicPath), setting_type: 'branding', updated_at: new Date() @@ -603,6 +610,36 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl } }); +// Remove a logo. ?variant=dark clears the dark-mode logo +// (branding_logo_*_dark); otherwise the default logo. Best-effort file +// unlink, then blanks the url + path settings. +router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const isDark = req.query.variant === 'dark'; + const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path'; + const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url'; + + const pathSetting = await db('app_settings').where('setting_key', pathKey).first(); + if (pathSetting && pathSetting.setting_value) { + try { + let p = pathSetting.setting_value; + if (p.startsWith('"')) p = JSON.parse(p); + await fs.unlink(p); + } catch (error) { + console.error('Failed to delete logo file:', error); + } + } + await db('app_settings') + .whereIn('setting_key', [pathKey, urlKey]) + .update({ setting_value: JSON.stringify(''), updated_at: new Date() }); + + res.json({ message: 'Logo removed' }); + } catch (error) { + console.error('Logo delete error:', error); + res.status(500).json({ error: 'Failed to remove logo' }); + } +}); + // Upload watermark logo router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => { try { diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index dc1f7178..050adc9e 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -59,6 +59,7 @@ router.get('/', async (req, res) => { branding_watermark_size: settingsObject.branding_watermark_size || 15, branding_favicon_url: settingsObject.branding_favicon_url || '', branding_logo_url: settingsObject.branding_logo_url || '', + branding_logo_url_dark: settingsObject.branding_logo_url_dark || '', branding_logo_size: settingsObject.branding_logo_size || 'medium', branding_logo_max_height: settingsObject.branding_logo_max_height || 48, branding_logo_position: settingsObject.branding_logo_position || 'left', diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index ff3efaaa..0315bd77 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -33,7 +33,10 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const { data: brandingSettings } = usePublicSettings(); const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak'; - const logoUrl = brandingSettings?.branding_logo_url?.trim(); + // Dark-mode logo variant: use it when the admin theme is dark and a dark + // logo was uploaded; otherwise fall back to the light logo. + const logoUrl = (isDark && brandingSettings?.branding_logo_url_dark?.trim()) + || brandingSettings?.branding_logo_url?.trim(); const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text'; // Logo placement honours the same Branding > Logo Position setting // the gallery does. 'sidepanel' moves the logo into the AdminSidebar diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index 97b2a75b..c4bb7d03 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -32,6 +32,7 @@ interface GalleryLayoutProps { footer_text?: string; favicon_url?: string; logo_url?: string; + logo_url_dark?: string; logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom'; logo_max_height?: number; logo_position?: 'left' | 'center' | 'right' | 'sidepanel'; @@ -122,6 +123,9 @@ export const GalleryLayout: React.FC = ({ const { t } = useTranslation(); const { format } = useLocalizedDate(); const { theme } = useTheme(); + // Dark-mode logo variant: prefer it on dark gallery themes, else light. + const brandLogoUrl = (theme.colorMode === 'dark' && brandingSettings?.logo_url_dark) + || brandingSettings?.logo_url; const guestIdentity = useGuestIdentityOptional(); // Footer legal-link config. Cached aggressively because the toggle state @@ -304,8 +308,8 @@ export const GalleryLayout: React.FC = ({ {shouldShowLogo('header') && (
{brandingSettings?.company_name = ({ {/* Logo - Show custom logo or fallback to PicPeak logo */} {shouldShowLogo('hero') && (
- {brandingSettings?.company_name = ({ slug, event }) => { footer_text: settingsData.branding_footer_text || '', watermark_enabled: settingsData.branding_watermark_enabled || false, logo_url: settingsData.branding_logo_url || null, + logo_url_dark: settingsData.branding_logo_url_dark || null, logo_size: settingsData.branding_logo_size || 'medium', logo_max_height: settingsData.branding_logo_max_height || 48, logo_position: settingsData.branding_logo_position || 'left', diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 2f38fb2a..7c371084 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1682,6 +1682,8 @@ "logo": "Logo", "uploadLogo": "Logo hochladen", "logoHelp": "Empfohlene Größe: 200x60px, PNG oder JPEG", + "logoDark": "Logo für Dunkelmodus", + "logoDarkHelp": "Optional. Wird bei dunklen Designs / im Dunkelmodus angezeigt; fällt auf das Hauptlogo zurück, wenn nicht gesetzt.", "favicon": "Favicon", "currentFavicon": "Aktuelles Favicon", "uploadFavicon": "Favicon hochladen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8398ddc1..e6279e33 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1271,6 +1271,8 @@ "logo": "Logo", "uploadLogo": "Upload Logo", "logoHelp": "Recommended size: 200x60px, PNG or JPEG", + "logoDark": "Dark-mode logo", + "logoDarkHelp": "Optional. Shown on dark themes / dark mode; falls back to the main logo when unset.", "favicon": "Favicon", "currentFavicon": "Current favicon", "uploadFavicon": "Upload Favicon", diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 59c4db72..b619437b 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -12,6 +12,7 @@ import { buildResourceUrl } from '../../utils/url'; import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard'; import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; export const BrandingPage: React.FC = () => { const { t } = useTranslation(); @@ -260,6 +261,47 @@ export const BrandingPage: React.FC = () => { }); }; + // Dark-mode logo — self-contained (the upload endpoint persists + // branding_logo_url_dark directly; not part of the theme payload). + // Consumers (admin header, gallery) pick it when the theme is dark. + const { data: pubSettings } = usePublicSettings(); + const [logoDarkUrl, setLogoDarkUrl] = useState(''); + useEffect(() => { + if (pubSettings?.branding_logo_url_dark !== undefined) { + setLogoDarkUrl(pubSettings.branding_logo_url_dark || ''); + } + }, [pubSettings?.branding_logo_url_dark]); + + const refreshSettings = () => { + queryClient.invalidateQueries({ queryKey: ['public-settings'] }); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + }; + + const handleDarkLogoUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const url = await settingsService.uploadLogo(file, 'dark'); + setLogoDarkUrl(url); + refreshSettings(); + toast.success(t('toast.uploadSuccess')); + } catch (error) { + console.error('Failed to upload dark logo:', error); + toast.error(t('toast.uploadError')); + } + }; + + const handleRemoveDarkLogo = async () => { + try { + await settingsService.removeLogo('dark'); + setLogoDarkUrl(''); + refreshSettings(); + } catch (error) { + console.error('Failed to remove dark logo:', error); + toast.error(t('toast.saveError')); + } + }; + const handleWatermarkLogoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { @@ -621,6 +663,50 @@ export const BrandingPage: React.FC = () => { {t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}

+ {/* Dark-mode logo */} +
+ +
+ {logoDarkUrl && ( +
+ Dark logo + +
+ )} +
+ + +
+
+

+ {t('branding.logoDarkHelp', 'Optional. Shown on dark themes / dark mode; falls back to the main logo when unset.')} +

+
{/* Logo Size */}