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.
This commit is contained in:
Luca
2026-06-03 13:26:56 +02:00
parent d7f0488f6e
commit 4790ea5ccd
10 changed files with 159 additions and 16 deletions
+40 -3
View File
@@ -544,9 +544,16 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
return res.status(400).json({ error: 'No logo file uploaded' }); 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 // Get old logo to delete
const oldLogoSetting = await db('app_settings') const oldLogoSetting = await db('app_settings')
.where('setting_key', 'branding_logo_path') .where('setting_key', pathKey)
.first(); .first();
if (oldLogoSetting && oldLogoSetting.setting_value) { if (oldLogoSetting && oldLogoSetting.setting_value) {
@@ -568,7 +575,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
await db('app_settings') await db('app_settings')
.insert({ .insert({
setting_key: 'branding_logo_path', setting_key: pathKey,
setting_value: JSON.stringify(logoPath), setting_value: JSON.stringify(logoPath),
setting_type: 'branding', setting_type: 'branding',
updated_at: new Date() updated_at: new Date()
@@ -582,7 +589,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
// Save public URL // Save public URL
await db('app_settings') await db('app_settings')
.insert({ .insert({
setting_key: 'branding_logo_url', setting_key: urlKey,
setting_value: JSON.stringify(publicPath), setting_value: JSON.stringify(publicPath),
setting_type: 'branding', setting_type: 'branding',
updated_at: new Date() 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 // Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => { router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
try { try {
+1
View File
@@ -59,6 +59,7 @@ router.get('/', async (req, res) => {
branding_watermark_size: settingsObject.branding_watermark_size || 15, branding_watermark_size: settingsObject.branding_watermark_size || 15,
branding_favicon_url: settingsObject.branding_favicon_url || '', branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_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_size: settingsObject.branding_logo_size || 'medium',
branding_logo_max_height: settingsObject.branding_logo_max_height || 48, branding_logo_max_height: settingsObject.branding_logo_max_height || 48,
branding_logo_position: settingsObject.branding_logo_position || 'left', branding_logo_position: settingsObject.branding_logo_position || 'left',
@@ -33,7 +33,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const { data: brandingSettings } = usePublicSettings(); const { data: brandingSettings } = usePublicSettings();
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak'; 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'; const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
// Logo placement honours the same Branding > Logo Position setting // Logo placement honours the same Branding > Logo Position setting
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar // the gallery does. 'sidepanel' moves the logo into the AdminSidebar
@@ -32,6 +32,7 @@ interface GalleryLayoutProps {
footer_text?: string; footer_text?: string;
favicon_url?: string; favicon_url?: string;
logo_url?: string; logo_url?: string;
logo_url_dark?: string;
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom'; logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
logo_max_height?: number; logo_max_height?: number;
logo_position?: 'left' | 'center' | 'right' | 'sidepanel'; logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
@@ -122,6 +123,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
const { theme } = useTheme(); 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(); const guestIdentity = useGuestIdentityOptional();
// Footer legal-link config. Cached aggressively because the toggle state // Footer legal-link config. Cached aggressively because the toggle state
@@ -304,8 +308,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{shouldShowLogo('header') && ( {shouldShowLogo('header') && (
<div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}> <div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
<img <img
src={brandingSettings?.logo_url ? src={brandLogoUrl ?
buildResourceUrl(brandingSettings.logo_url) : buildResourceUrl(brandLogoUrl) :
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt={brandingSettings?.company_name || 'PicPeak'} alt={brandingSettings?.company_name || 'PicPeak'}
@@ -588,8 +592,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{shouldShowLogo('hero') && ( {shouldShowLogo('hero') && (
<div className="mb-6"> <div className="mb-6">
<img <img
src={brandingSettings?.logo_url ? src={brandLogoUrl ?
buildResourceUrl(brandingSettings.logo_url) : buildResourceUrl(brandLogoUrl) :
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt={brandingSettings?.company_name || 'PicPeak'} alt={brandingSettings?.company_name || 'PicPeak'}
@@ -597,7 +601,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
style={{ style={{
...(heroLogoSize.style || {}), ...(heroLogoSize.style || {}),
// Only apply brightness/invert filter to default logo; custom logos display as-is // Only apply brightness/invert filter to default logo; custom logos display as-is
filter: brandingSettings?.logo_url filter: brandLogoUrl
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' ? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' : 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}} }}
@@ -280,6 +280,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
footer_text: settingsData.branding_footer_text || '', footer_text: settingsData.branding_footer_text || '',
watermark_enabled: settingsData.branding_watermark_enabled || false, watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null, logo_url: settingsData.branding_logo_url || null,
logo_url_dark: settingsData.branding_logo_url_dark || null,
logo_size: settingsData.branding_logo_size || 'medium', logo_size: settingsData.branding_logo_size || 'medium',
logo_max_height: settingsData.branding_logo_max_height || 48, logo_max_height: settingsData.branding_logo_max_height || 48,
logo_position: settingsData.branding_logo_position || 'left', logo_position: settingsData.branding_logo_position || 'left',
+2
View File
@@ -1682,6 +1682,8 @@
"logo": "Logo", "logo": "Logo",
"uploadLogo": "Logo hochladen", "uploadLogo": "Logo hochladen",
"logoHelp": "Empfohlene Größe: 200x60px, PNG oder JPEG", "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", "favicon": "Favicon",
"currentFavicon": "Aktuelles Favicon", "currentFavicon": "Aktuelles Favicon",
"uploadFavicon": "Favicon hochladen", "uploadFavicon": "Favicon hochladen",
+2
View File
@@ -1271,6 +1271,8 @@
"logo": "Logo", "logo": "Logo",
"uploadLogo": "Upload Logo", "uploadLogo": "Upload Logo",
"logoHelp": "Recommended size: 200x60px, PNG or JPEG", "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", "favicon": "Favicon",
"currentFavicon": "Current favicon", "currentFavicon": "Current favicon",
"uploadFavicon": "Upload Favicon", "uploadFavicon": "Upload Favicon",
+86
View File
@@ -12,6 +12,7 @@ import { buildResourceUrl } from '../../utils/url';
import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard'; import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard';
import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard'; import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard';
import { usePublicSettings } from '../../hooks/usePublicSettings';
export const BrandingPage: React.FC = () => { export const BrandingPage: React.FC = () => {
const { t } = useTranslation(); 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<HTMLInputElement>) => {
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<HTMLInputElement>) => { const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
@@ -621,6 +663,50 @@ export const BrandingPage: React.FC = () => {
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')} {t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
</p> </p>
</div> </div>
{/* Dark-mode logo */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.logoDark', 'Dark-mode logo')}
</label>
<div className="flex items-center gap-4">
{logoDarkUrl && (
<div className="relative">
<img
src={logoDarkUrl.startsWith('http') ? logoDarkUrl : buildResourceUrl(logoDarkUrl)}
alt="Dark logo"
className="h-16 object-contain bg-neutral-800 rounded p-2"
/>
<button
type="button"
onClick={handleRemoveDarkLogo}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
>
×
</button>
</div>
)}
<div>
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleDarkLogoUpload}
className="hidden"
id="logo-dark-upload"
/>
<Button
variant="secondary"
size="sm"
onClick={() => document.getElementById('logo-dark-upload')?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{logoDarkUrl ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
</Button>
</div>
</div>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('branding.logoDarkHelp', 'Optional. Shown on dark themes / dark mode; falls back to the main logo when unset.')}
</p>
</div>
{/* Logo Size */} {/* Logo Size */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
@@ -12,6 +12,7 @@ export interface PublicSettings {
branding_watermark_size: number; branding_watermark_size: number;
branding_favicon_url: string; branding_favicon_url: string;
branding_logo_url: string; branding_logo_url: string;
branding_logo_url_dark?: string;
branding_logo_size?: string; branding_logo_size?: string;
branding_logo_max_height?: number; branding_logo_max_height?: number;
/** /**
+9 -3
View File
@@ -221,13 +221,14 @@ export const settingsService = {
}); });
}, },
// Upload logo // Upload logo. Pass variant='dark' to store the dark-mode logo
async uploadLogo(file: File): Promise<string> { // (branding_logo_url_dark); default stores the light logo.
async uploadLogo(file: File, variant?: 'dark'): Promise<string> {
const formData = new FormData(); const formData = new FormData();
formData.append('logo', file); formData.append('logo', file);
const response = await api.post<{ logoUrl: string }>( const response = await api.post<{ logoUrl: string }>(
'/admin/settings/logo', `/admin/settings/logo${variant === 'dark' ? '?variant=dark' : ''}`,
formData, formData,
{ {
headers: { headers: {
@@ -239,6 +240,11 @@ export const settingsService = {
return response.data.logoUrl; return response.data.logoUrl;
}, },
// Remove a logo (variant='dark' clears the dark-mode logo).
async removeLogo(variant?: 'dark'): Promise<void> {
await api.delete(`/admin/settings/logo${variant === 'dark' ? '?variant=dark' : ''}`);
},
// Upload favicon // Upload favicon
async uploadFavicon(file: File): Promise<string> { async uploadFavicon(file: File): Promise<string> {
const formData = new FormData(); const formData = new FormData();