From 5588fdb989d9378f19e5b611f6779f6312ea42a6 Mon Sep 17 00:00:00 2001 From: yveskalume Date: Wed, 7 Jan 2026 21:00:21 +0200 Subject: [PATCH] feat: add avatar upload and optimization features in branding panel, update branding store and canvas store for avatar handling --- src/components/Canvas.tsx | 94 ++++++++--- src/components/Inspector.tsx | 73 ++++++++- src/components/inspector/BrandingPanel.tsx | 172 ++++++++++++++++++++- src/store/brandingStore.ts | 40 ++++- src/store/canvasStore.ts | 39 ++++- src/types/index.ts | 3 + 6 files changed, 396 insertions(+), 25 deletions(-) diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 009d840..0ee7f1c 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect, useCallback, useMemo, memo } from 'react'; -import { Stage, Layer, Rect, Line, Text, Group, Path } from 'react-konva'; +import { Stage, Layer, Rect, Line, Text, Group, Path, Circle } from 'react-konva'; import type Konva from 'konva'; import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore'; import CodeBlock from './elements/CodeBlock'; @@ -28,6 +28,7 @@ const Canvas: React.FC = ({ stageRef }) => { const { width, height } = snap.meta; const { background } = snap; + const [brandingAvatar, setBrandingAvatar] = useState(null); // Handle resize useEffect(() => { @@ -45,6 +46,25 @@ const Canvas: React.FC = ({ stageRef }) => { return () => window.removeEventListener('resize', updateDimensions); }, []); + useEffect(() => { + const url = background.branding?.avatarUrl || ''; + if (!url) { + setBrandingAvatar(null); + return; + } + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => setBrandingAvatar(img); + img.onerror = () => setBrandingAvatar(null); + img.src = url; + + return () => { + img.onload = null; + img.onerror = null; + }; + }, [background.branding?.avatarUrl]); + // Calculate stage position to center the canvas const getStagePosition = useCallback(() => { const scaledWidth = width * zoom; @@ -193,6 +213,9 @@ const Canvas: React.FC = ({ stageRef }) => { const socialLayout = branding.socialLayout || 'horizontal'; const iconGap = 16; const iconTextGap = 6; // Gap between icon and its text + const avatarSize = branding.avatarSize || 56; + const avatarGap = 14; + const hasAvatar = Boolean(branding.showAvatar && branding.avatarUrl && brandingAvatar); // Build branding text lines const lines: string[] = []; @@ -204,7 +227,7 @@ const Canvas: React.FC = ({ stageRef }) => { } // Get active social platforms with their values - const activeSocialPlatforms = branding.showSocial && branding.social + const activeSocialPlatforms = branding.showSocial && branding.social ? SOCIAL_PLATFORMS_CONFIG.filter(p => branding.social[p.key as keyof typeof branding.social]) .map(p => ({ ...p, @@ -215,10 +238,10 @@ const Canvas: React.FC = ({ stageRef }) => { const hasTextContent = lines.length > 0; const hasSocialIcons = activeSocialPlatforms.length > 0; - if (!hasTextContent && !hasSocialIcons) return null; + if (!hasTextContent && !hasSocialIcons && !hasAvatar) return null; const textHeight = lines.length * lineHeight; - + // Calculate social section dimensions (icon + text for each platform) const socialItemHeight = Math.max(iconSize, fontSize); const socialHeight = socialLayout === 'vertical' @@ -226,7 +249,10 @@ const Canvas: React.FC = ({ stageRef }) => { : socialItemHeight; // Calculate total content height - const contentHeight = textHeight + (hasTextContent && hasSocialIcons ? 16 : 0) + (hasSocialIcons ? socialHeight : 0); + const contentHeight = (hasTextContent || hasSocialIcons) + ? textHeight + (hasTextContent && hasSocialIcons ? 16 : 0) + (hasSocialIcons ? socialHeight : 0) + : 0; + const blockHeight = Math.max(contentHeight, hasAvatar ? avatarSize : 0); // Calculate position based on setting let x = padding; @@ -246,28 +272,60 @@ const Canvas: React.FC = ({ stageRef }) => { break; case 'bottom-left': x = padding; - y = height - padding - contentHeight; + y = height - padding - blockHeight; align = 'left'; break; case 'bottom-right': x = width - padding; - y = height - padding - contentHeight; + y = height - padding - blockHeight; align = 'right'; break; } + const totalHeight = blockHeight; + const contentTop = contentHeight > 0 ? y + (totalHeight - contentHeight) / 2 : y; + const avatarY = hasAvatar ? y + (totalHeight - avatarSize) / 2 : 0; + const avatarCenterX = align === 'right' ? x - avatarSize / 2 : x + avatarSize / 2; + const avatarCenterY = avatarY + avatarSize / 2; + const avatarPatternScale = hasAvatar && brandingAvatar && brandingAvatar.width && brandingAvatar.height + ? Math.max(avatarSize / brandingAvatar.width, avatarSize / brandingAvatar.height) + : 1; + + const avatarOffset = hasAvatar ? avatarSize + avatarGap : 0; + const textStartXBase = align === 'right' ? x - avatarOffset : x + avatarOffset; + const textX = align === 'right' ? 0 : textStartXBase; + const textWidth = align === 'right' + ? Math.max(textStartXBase, 0) + : Math.max(width - padding - textStartXBase, 0); + // Calculate icon positions const iconScale = iconSize / 24; // SVG viewBox is 24x24 - const socialStartY = y + textHeight + (hasTextContent ? 16 : 0); + const socialStartY = contentTop + textHeight + (hasTextContent ? 16 : 0); + const anchorX = textStartXBase; return ( + {/* Avatar */} + {hasAvatar && brandingAvatar && ( + + )} + {/* Text content */} {hasTextContent && ( = ({ stageRef }) => { if (socialLayout === 'vertical') { itemY = socialStartY + index * (socialItemHeight + iconGap - 4); - itemX = align === 'right' ? x : x; + itemX = anchorX; } else { itemY = socialStartY; // For horizontal layout, we need to calculate cumulative width // This is simplified - for perfect alignment we'd need to measure text const prevItemsWidth = activeSocialPlatforms.slice(0, index).reduce((acc, p) => { - const textWidth = (p.value.length * fontSize * 0.5); // Approximate text width - return acc + iconSize + iconTextGap + textWidth + iconGap; + const textWidthEstimate = (p.value.length * fontSize * 0.5); // Approximate text width + return acc + iconSize + iconTextGap + textWidthEstimate + iconGap; }, 0); - itemX = align === 'right' ? x - prevItemsWidth : x + prevItemsWidth; + itemX = align === 'right' ? anchorX - prevItemsWidth : anchorX + prevItemsWidth; } // Icon vertical centering within item @@ -307,7 +365,7 @@ const Canvas: React.FC = ({ stageRef }) => { if (align === 'right') { // For right alignment: text first, then icon - const textWidth = platform.value.length * fontSize * 0.5; // Approximate + const textWidthEstimate = platform.value.length * fontSize * 0.5; // Approximate return ( = ({ stageRef }) => { scaleY={iconScale} /> = ({ stageRef }) => { })} ); - }, [background.branding, width, height]); + }, [background.branding, width, height, brandingAvatar]); const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]); diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index b630ec8..49a6faa 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo, useCallback } from 'react'; +import React, { memo, useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useCanvasStore } from '../store/canvasStore'; import type { CodeElement, TextElement, ArrowElement } from '../types'; import BackgroundPanel from './inspector/BackgroundPanel'; @@ -7,7 +7,13 @@ import CodeInspector from './inspector/CodeInspector'; import TextInspector from './inspector/TextInspector'; import ArrowInspector from './inspector/ArrowInspector'; +const DEFAULT_WIDTH = 320; +const MIN_WIDTH = 260; +const MAX_WIDTH = 520; +const EXPANDED_WIDTH = 420; + const Inspector: React.FC = () => { + const [width, setWidth] = useState(DEFAULT_WIDTH); const { snap, selectedElementId, @@ -17,10 +23,58 @@ const Inspector: React.FC = () => { moveElementDown, } = useCanvasStore(); + const dragStateRef = useRef({ + startX: 0, + startWidth: DEFAULT_WIDTH, + isDragging: false, + }); + const selectedElement = useMemo( () => snap.elements.find(el => el.id === selectedElementId), [snap.elements, selectedElementId] ); + + const handleResizeMove = useCallback((event: MouseEvent) => { + if (!dragStateRef.current.isDragging) return; + + const delta = dragStateRef.current.startX - event.clientX; + const nextWidth = Math.min( + Math.max(dragStateRef.current.startWidth + delta, MIN_WIDTH), + MAX_WIDTH + ); + + setWidth(nextWidth); + }, []); + + const stopDragging = useCallback(() => { + if (!dragStateRef.current.isDragging) return; + + dragStateRef.current.isDragging = false; + window.removeEventListener('mousemove', handleResizeMove); + window.removeEventListener('mouseup', stopDragging); + }, [handleResizeMove]); + + const handleResizeStart = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + dragStateRef.current = { + startX: event.clientX, + startWidth: width, + isDragging: true, + }; + + window.addEventListener('mousemove', handleResizeMove); + window.addEventListener('mouseup', stopDragging); + }, [width, handleResizeMove, stopDragging]); + + const handleHandleDoubleClick = useCallback(() => { + setWidth(prev => (prev < EXPANDED_WIDTH ? EXPANDED_WIDTH : DEFAULT_WIDTH)); + }, []); + + useEffect(() => ( + () => { + stopDragging(); + } + ), [stopDragging]); const handleDelete = useCallback(() => { if (selectedElement) { @@ -47,8 +101,21 @@ const Inspector: React.FC = () => { }, [selectedElement, moveElementDown]); return ( -
-
+
+
+
+
+
{selectedElement ? (
{/* Header with Title and Element Actions */} diff --git a/src/components/inspector/BrandingPanel.tsx b/src/components/inspector/BrandingPanel.tsx index da0abef..af8d476 100644 --- a/src/components/inspector/BrandingPanel.tsx +++ b/src/components/inspector/BrandingPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useCanvasStore } from '../../store/canvasStore'; import { useBrandingStore } from '../../store/brandingStore'; import { FONT_FAMILIES } from '../../types'; @@ -20,6 +20,78 @@ const SOCIAL_PLATFORMS = [ { key: 'tiktok', label: 'TikTok', placeholder: '@username' }, ] as const; +const MAX_AVATAR_DIMENSION = 192; +const MAX_EMBEDDED_AVATAR_LENGTH = 350000; + +const readFileAsDataUrl = (file: File) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Could not read file contents.')); + } + }; + reader.onerror = () => reject(reader.error ?? new Error('Avatar read failed.')); + reader.readAsDataURL(file); +}); + +const loadImage = (src: string) => new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Avatar image could not be loaded.')); + img.src = src; +}); + +const optimiseAvatarFile = async (file: File): Promise => { + const rawDataUrl = await readFileAsDataUrl(file); + + if (rawDataUrl.length <= MAX_EMBEDDED_AVATAR_LENGTH) { + return rawDataUrl; + } + + try { + const image = await loadImage(rawDataUrl); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return rawDataUrl; + } + + let targetSize = MAX_AVATAR_DIMENSION; + let optimised = rawDataUrl; + + while (targetSize >= 96) { + const maxSide = Math.max(image.width, image.height); + const scale = maxSide > targetSize ? targetSize / maxSide : 1; + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + + canvas.width = width; + canvas.height = height; + ctx.clearRect(0, 0, width, height); + ctx.drawImage(image, 0, 0, width, height); + + optimised = canvas.toDataURL('image/png'); + if (optimised.length <= MAX_EMBEDDED_AVATAR_LENGTH) { + break; + } + + optimised = canvas.toDataURL('image/jpeg', 0.82); + if (optimised.length <= MAX_EMBEDDED_AVATAR_LENGTH) { + break; + } + + targetSize = Math.floor(targetSize * 0.75); + } + + return optimised.length <= MAX_EMBEDDED_AVATAR_LENGTH ? optimised : rawDataUrl; + } catch (error) { + console.warn('Avatar optimisation failed, keeping the original image.', error); + return rawDataUrl; + } +}; + const BrandingPanel: React.FC = () => { const { setBackground } = useCanvasStore(); const { info, preferences, updateInfo, updateSocial, updatePreferences } = useBrandingStore(); @@ -32,6 +104,7 @@ const BrandingPanel: React.FC = () => { name: info.name, website: info.website, social: info.social, + avatarUrl: info.avatarUrl, }, }); }, [info, preferences, setBackground]); @@ -48,6 +121,35 @@ const BrandingPanel: React.FC = () => { updateSocial(platform, value); }; + const handleAvatarUpload = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + const resetInput = () => { + if (event.target) { + event.target.value = ''; + } + }; + + if (!file || !file.type.startsWith('image/')) { + resetInput(); + return; + } + + void optimiseAvatarFile(file) + .then((dataUrl) => { + handleUpdateInfo({ avatarUrl: dataUrl }); + handleUpdatePreferences({ showAvatar: true }); + }) + .catch((error) => { + console.warn('Avatar upload cancelled.', error); + }) + .finally(resetInput); + }, [handleUpdateInfo, handleUpdatePreferences]); + + const handleAvatarClear = useCallback(() => { + handleUpdateInfo({ avatarUrl: '' }); + }, [handleUpdateInfo]); + return (
{/* Enable Branding */} @@ -93,6 +195,74 @@ const BrandingPanel: React.FC = () => {
+ {/* Avatar */} +
+
+ + +
+
+ +
+

Use a square image for best results. Supported formats: PNG, JPG, SVG.

+ {info.avatarUrl && ( + + )} +
+
+ {preferences.showAvatar && ( +
+ + handleUpdatePreferences({ avatarSize: parseInt(e.target.value) })} + className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+ )} +
+ {/* Name */}
diff --git a/src/store/brandingStore.ts b/src/store/brandingStore.ts index 100d7cf..1098bad 100644 --- a/src/store/brandingStore.ts +++ b/src/store/brandingStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { persist, createJSONStorage } from 'zustand/middleware'; interface BrandingInfo { name: string; @@ -12,6 +12,7 @@ interface BrandingInfo { youtube?: string; tiktok?: string; }; + avatarUrl?: string; } interface BrandingPreferences { @@ -20,6 +21,7 @@ interface BrandingPreferences { showName: boolean; showWebsite: boolean; showSocial: boolean; + showAvatar: boolean; fontSize: number; fontFamily: string; color: string; @@ -27,6 +29,7 @@ interface BrandingPreferences { padding: number; socialIconSize: number; socialLayout: 'horizontal' | 'vertical'; + avatarSize: number; } interface BrandingStore { @@ -44,14 +47,18 @@ const defaultInfo: BrandingInfo = { name: '', website: '', social: {}, + avatarUrl: '', }; +const MAX_PERSISTED_AVATAR_LENGTH = 350000; + const defaultPreferences: BrandingPreferences = { enabled: false, position: 'bottom-right', showName: true, showWebsite: true, showSocial: true, + showAvatar: false, fontSize: 14, fontFamily: 'Inter', color: '#ffffff', @@ -59,6 +66,7 @@ const defaultPreferences: BrandingPreferences = { padding: 24, socialIconSize: 20, socialLayout: 'horizontal', + avatarSize: 56, }; export const useBrandingStore = create()( @@ -89,6 +97,36 @@ export const useBrandingStore = create()( }), { name: 'yvcode-branding', + storage: createJSONStorage(() => { + if (typeof window === 'undefined') { + return { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + } + + return { + getItem: (name: string) => window.localStorage.getItem(name), + setItem: (name: string, value: string) => { + try { + window.localStorage.setItem(name, value); + } catch (error) { + console.warn('Failed to persist branding preferences (quota exceeded).', error); + } + }, + removeItem: (name: string) => window.localStorage.removeItem(name), + }; + }), + partialize: (state) => ({ + info: { + ...state.info, + avatarUrl: state.info.avatarUrl && state.info.avatarUrl.length > MAX_PERSISTED_AVATAR_LENGTH + ? '' + : state.info.avatarUrl, + }, + preferences: state.preferences, + }), } ) ); diff --git a/src/store/canvasStore.ts b/src/store/canvasStore.ts index 8129206..d5d99ac 100644 --- a/src/store/canvasStore.ts +++ b/src/store/canvasStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; -import { persist } from 'zustand/middleware'; +import { persist, createJSONStorage } from 'zustand/middleware'; import { v4 as uuidv4 } from 'uuid'; import type { Snap, @@ -52,6 +52,8 @@ interface CanvasState { importSnap: (json: string) => void; } +const MAX_PERSISTED_AVATAR_LENGTH = 350000; + const defaultSnap: Snap = { version: '1.0.0', meta: { @@ -80,14 +82,19 @@ const defaultSnap: Snap = { name: '', website: '', social: {}, + avatarUrl: '', showName: true, showWebsite: true, showSocial: true, + showAvatar: false, fontSize: 14, fontFamily: 'Inter', color: '#ffffff', opacity: 0.8, padding: 24, + socialIconSize: 20, + socialLayout: 'horizontal', + avatarSize: 56, }, }, elements: [], @@ -243,7 +250,35 @@ export const useCanvasStore = create()( })), { name: 'code-canvas-storage', - partialize: (state) => ({ snap: state.snap }), + storage: createJSONStorage(() => { + if (typeof window === 'undefined') { + return { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + } + + return { + getItem: (name: string) => window.localStorage.getItem(name), + setItem: (name: string, value: string) => { + try { + window.localStorage.setItem(name, value); + } catch (error) { + console.warn('Failed to persist canvas state (quota exceeded).', error); + } + }, + removeItem: (name: string) => window.localStorage.removeItem(name), + }; + }), + partialize: (state) => { + const snap = JSON.parse(JSON.stringify(state.snap)) as Snap; + if (snap.background?.branding?.avatarUrl && snap.background.branding.avatarUrl.length > MAX_PERSISTED_AVATAR_LENGTH) { + snap.background.branding.avatarUrl = ''; + snap.background.branding.showAvatar = false; + } + return { snap }; + }, } ) ); diff --git a/src/types/index.ts b/src/types/index.ts index e362154..d349847 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,9 +25,11 @@ export interface Branding { name: string; website: string; social: SocialMedia; + avatarUrl?: string; showName: boolean; showWebsite: boolean; showSocial: boolean; + showAvatar?: boolean; fontSize: number; fontFamily: string; color: string; @@ -35,6 +37,7 @@ export interface Branding { padding: number; socialIconSize?: number; socialLayout?: 'horizontal' | 'vertical'; + avatarSize?: number; } export interface BrandStrip {