From 5b5646ed7851a5ac0b3b041017bbf8851d290322 Mon Sep 17 00:00:00 2001 From: yveskalume Date: Wed, 7 Jan 2026 18:48:21 +0200 Subject: [PATCH] feat: add branding watermark feature with customizable options and inspector panel --- src/components/Canvas.tsx | 80 ++++++ src/components/Inspector.tsx | 6 + src/components/inspector/BrandingPanel.tsx | 285 +++++++++++++++++++++ src/store/canvasStore.ts | 15 ++ src/types/index.ts | 26 ++ 5 files changed, 412 insertions(+) create mode 100644 src/components/inspector/BrandingPanel.tsx diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index de9e823..4b8ad7e 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -180,6 +180,85 @@ const Canvas: React.FC = ({ stageRef }) => { ); }, [background.brandStrip, width, height]); + // Branding watermark - memoized + const brandingElement = useMemo(() => { + const branding = background.branding; + if (!branding?.enabled) return null; + + const padding = branding.padding || 24; + const fontSize = branding.fontSize || 14; + const lineHeight = fontSize * 1.5; + + // Build branding text lines + const lines: string[] = []; + if (branding.showName && branding.name) { + lines.push(branding.name); + } + if (branding.showWebsite && branding.website) { + lines.push(branding.website); + } + if (branding.showSocial && branding.social) { + const socialParts: string[] = []; + if (branding.social.twitter) socialParts.push(branding.social.twitter); + if (branding.social.linkedin) socialParts.push(branding.social.linkedin); + if (branding.social.instagram) socialParts.push(branding.social.instagram); + if (branding.social.github) socialParts.push(branding.social.github); + if (branding.social.youtube) socialParts.push(branding.social.youtube); + if (branding.social.tiktok) socialParts.push(branding.social.tiktok); + if (socialParts.length > 0) { + lines.push(socialParts.join(' • ')); + } + } + + if (lines.length === 0) return null; + + const text = lines.join('\n'); + const textHeight = lines.length * lineHeight; + + // Calculate position based on setting + let x = padding; + let y = padding; + let align: 'left' | 'right' = 'left'; + + switch (branding.position) { + case 'top-left': + x = padding; + y = padding; + align = 'left'; + break; + case 'top-right': + x = width - padding; + y = padding; + align = 'right'; + break; + case 'bottom-left': + x = padding; + y = height - padding - textHeight; + align = 'left'; + break; + case 'bottom-right': + x = width - padding; + y = height - padding - textHeight; + align = 'right'; + break; + } + + return ( + + ); + }, [background.branding, width, height]); + const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]); return ( @@ -201,6 +280,7 @@ const Canvas: React.FC = ({ stageRef }) => { {renderBackground()} {brandStripElement} + {brandingElement} {gridLines} {snap.elements.map((element) => { diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 807baa0..b630ec8 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -2,6 +2,7 @@ import React, { memo, useMemo, useCallback } from 'react'; import { useCanvasStore } from '../store/canvasStore'; import type { CodeElement, TextElement, ArrowElement } from '../types'; import BackgroundPanel from './inspector/BackgroundPanel'; +import BrandingPanel from './inspector/BrandingPanel'; import CodeInspector from './inspector/CodeInspector'; import TextInspector from './inspector/TextInspector'; import ArrowInspector from './inspector/ArrowInspector'; @@ -119,6 +120,11 @@ const Inspector: React.FC = () => {

Canvas Settings

+ +
+ +

Branding

+
)}
diff --git a/src/components/inspector/BrandingPanel.tsx b/src/components/inspector/BrandingPanel.tsx new file mode 100644 index 0000000..a447469 --- /dev/null +++ b/src/components/inspector/BrandingPanel.tsx @@ -0,0 +1,285 @@ +import React from 'react'; +import { useCanvasStore } from '../../store/canvasStore'; +import { FONT_FAMILIES } from '../../types'; + +const POSITION_OPTIONS = [ + { value: 'top-left', label: 'Top Left' }, + { value: 'top-right', label: 'Top Right' }, + { value: 'bottom-left', label: 'Bottom Left' }, + { value: 'bottom-right', label: 'Bottom Right' }, +] as const; + +const SOCIAL_PLATFORMS = [ + { key: 'twitter', label: 'X (Twitter)', placeholder: '@username' }, + { key: 'linkedin', label: 'LinkedIn', placeholder: '/in/username' }, + { key: 'instagram', label: 'Instagram', placeholder: '@username' }, + { key: 'github', label: 'GitHub', placeholder: 'username' }, + { key: 'youtube', label: 'YouTube', placeholder: '@channel' }, + { key: 'tiktok', label: 'TikTok', placeholder: '@username' }, +] as const; + +const BrandingPanel: React.FC = () => { + const { snap, setBackground } = useCanvasStore(); + + // Default branding values for backwards compatibility + const defaultBranding = { + enabled: false, + position: 'bottom-right' as const, + name: '', + website: '', + social: {}, + showName: true, + showWebsite: true, + showSocial: true, + fontSize: 14, + fontFamily: 'Inter', + color: '#ffffff', + opacity: 0.8, + padding: 24, + }; + + const branding = snap.background.branding || defaultBranding; + + const updateBranding = (updates: Partial) => { + setBackground({ + branding: { ...branding, ...updates }, + }); + }; + + const updateSocial = (platform: string, value: string) => { + setBackground({ + branding: { + ...branding, + social: { ...(branding.social || {}), [platform]: value }, + }, + }); + }; + + return ( +
+ {/* Enable Branding */} +
+ + +
+ + {branding.enabled && ( +
+ {/* Position */} +
+ +
+ {POSITION_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Name */} +
+
+ + +
+ updateBranding({ name: e.target.value })} + placeholder="Your Name" + className="w-full bg-white/5 text-white px-3 py-2 rounded-lg text-sm border border-white/5 focus:border-blue-500/50 focus:outline-none" + /> +
+ + {/* Website */} +
+
+ + +
+ updateBranding({ website: e.target.value })} + placeholder="yourwebsite.com" + className="w-full bg-white/5 text-white px-3 py-2 rounded-lg text-sm border border-white/5 focus:border-blue-500/50 focus:outline-none" + /> +
+ + {/* Social Media */} +
+
+ + +
+
+ {SOCIAL_PLATFORMS.map((platform) => ( +
+ + {platform.label} + + updateSocial(platform.key, e.target.value)} + placeholder={platform.placeholder} + className="flex-1 bg-white/5 text-white px-2 py-1.5 rounded-md text-xs border border-white/5 focus:border-blue-500/50 focus:outline-none" + /> +
+ ))} +
+
+ + {/* Styling */} +
+ + + {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+ + updateBranding({ fontSize: parseInt(e.target.value) })} + className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+ + {/* Color */} +
+ +
+
+ updateBranding({ color: e.target.value })} + className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer" + /> +
+ updateBranding({ color: e.target.value })} + className="w-full bg-transparent text-white text-xs focus:outline-none font-mono" + /> +
+
+ + {/* Opacity */} +
+ + updateBranding({ opacity: parseFloat(e.target.value) })} + className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+ + {/* Padding */} +
+ + updateBranding({ padding: parseInt(e.target.value) })} + className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+
+
+ )} +
+ ); +}; + +export default BrandingPanel; diff --git a/src/store/canvasStore.ts b/src/store/canvasStore.ts index 4f0d063..8129206 100644 --- a/src/store/canvasStore.ts +++ b/src/store/canvasStore.ts @@ -74,6 +74,21 @@ const defaultSnap: Snap = { fontSize: 16, fontFamily: 'Inter', }, + branding: { + enabled: false, + position: 'bottom-right', + name: '', + website: '', + social: {}, + showName: true, + showWebsite: true, + showSocial: true, + fontSize: 14, + fontFamily: 'Inter', + color: '#ffffff', + opacity: 0.8, + padding: 24, + }, }, elements: [], }; diff --git a/src/types/index.ts b/src/types/index.ts index 6ba600c..e84555d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,6 +10,31 @@ export interface GradientBackground { angle: number; } +export interface SocialMedia { + twitter?: string; + linkedin?: string; + instagram?: string; + github?: string; + youtube?: string; + tiktok?: string; +} + +export interface Branding { + enabled: boolean; + position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + name: string; + website: string; + social: SocialMedia; + showName: boolean; + showWebsite: boolean; + showSocial: boolean; + fontSize: number; + fontFamily: string; + color: string; + opacity: number; + padding: number; +} + export interface BrandStrip { enabled: boolean; position: 'top' | 'bottom'; @@ -26,6 +51,7 @@ export interface Background { solid: SolidBackground; gradient: GradientBackground; brandStrip: BrandStrip; + branding: Branding; } export interface Shadow {