feat: add avatar upload and optimization features in branding panel, update branding store and canvas store for avatar handling
This commit is contained in:
+76
-18
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useState, useEffect, useCallback, useMemo, memo } from 'react';
|
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 type Konva from 'konva';
|
||||||
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
|
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
|
||||||
import CodeBlock from './elements/CodeBlock';
|
import CodeBlock from './elements/CodeBlock';
|
||||||
@@ -28,6 +28,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
|
|
||||||
const { width, height } = snap.meta;
|
const { width, height } = snap.meta;
|
||||||
const { background } = snap;
|
const { background } = snap;
|
||||||
|
const [brandingAvatar, setBrandingAvatar] = useState<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +46,25 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
return () => window.removeEventListener('resize', updateDimensions);
|
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
|
// Calculate stage position to center the canvas
|
||||||
const getStagePosition = useCallback(() => {
|
const getStagePosition = useCallback(() => {
|
||||||
const scaledWidth = width * zoom;
|
const scaledWidth = width * zoom;
|
||||||
@@ -193,6 +213,9 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
const socialLayout = branding.socialLayout || 'horizontal';
|
const socialLayout = branding.socialLayout || 'horizontal';
|
||||||
const iconGap = 16;
|
const iconGap = 16;
|
||||||
const iconTextGap = 6; // Gap between icon and its text
|
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
|
// Build branding text lines
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
@@ -204,7 +227,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get active social platforms with their values
|
// 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])
|
? SOCIAL_PLATFORMS_CONFIG.filter(p => branding.social[p.key as keyof typeof branding.social])
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
@@ -215,10 +238,10 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
const hasTextContent = lines.length > 0;
|
const hasTextContent = lines.length > 0;
|
||||||
const hasSocialIcons = activeSocialPlatforms.length > 0;
|
const hasSocialIcons = activeSocialPlatforms.length > 0;
|
||||||
|
|
||||||
if (!hasTextContent && !hasSocialIcons) return null;
|
if (!hasTextContent && !hasSocialIcons && !hasAvatar) return null;
|
||||||
|
|
||||||
const textHeight = lines.length * lineHeight;
|
const textHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
// Calculate social section dimensions (icon + text for each platform)
|
// Calculate social section dimensions (icon + text for each platform)
|
||||||
const socialItemHeight = Math.max(iconSize, fontSize);
|
const socialItemHeight = Math.max(iconSize, fontSize);
|
||||||
const socialHeight = socialLayout === 'vertical'
|
const socialHeight = socialLayout === 'vertical'
|
||||||
@@ -226,7 +249,10 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
: socialItemHeight;
|
: socialItemHeight;
|
||||||
|
|
||||||
// Calculate total content height
|
// 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
|
// Calculate position based on setting
|
||||||
let x = padding;
|
let x = padding;
|
||||||
@@ -246,28 +272,60 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
break;
|
break;
|
||||||
case 'bottom-left':
|
case 'bottom-left':
|
||||||
x = padding;
|
x = padding;
|
||||||
y = height - padding - contentHeight;
|
y = height - padding - blockHeight;
|
||||||
align = 'left';
|
align = 'left';
|
||||||
break;
|
break;
|
||||||
case 'bottom-right':
|
case 'bottom-right':
|
||||||
x = width - padding;
|
x = width - padding;
|
||||||
y = height - padding - contentHeight;
|
y = height - padding - blockHeight;
|
||||||
align = 'right';
|
align = 'right';
|
||||||
break;
|
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
|
// Calculate icon positions
|
||||||
const iconScale = iconSize / 24; // SVG viewBox is 24x24
|
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 (
|
return (
|
||||||
<Group opacity={branding.opacity || 0.8}>
|
<Group opacity={branding.opacity || 0.8}>
|
||||||
|
{/* Avatar */}
|
||||||
|
{hasAvatar && brandingAvatar && (
|
||||||
|
<Circle
|
||||||
|
x={avatarCenterX}
|
||||||
|
y={avatarCenterY}
|
||||||
|
radius={avatarSize / 2}
|
||||||
|
fillPatternImage={brandingAvatar}
|
||||||
|
fillPatternScaleX={avatarPatternScale}
|
||||||
|
fillPatternScaleY={avatarPatternScale}
|
||||||
|
fillPatternOffsetX={brandingAvatar.width / 2}
|
||||||
|
fillPatternOffsetY={brandingAvatar.height / 2}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Text content */}
|
{/* Text content */}
|
||||||
{hasTextContent && (
|
{hasTextContent && (
|
||||||
<Text
|
<Text
|
||||||
x={align === 'right' ? 0 : x}
|
x={textX}
|
||||||
y={y}
|
y={contentTop}
|
||||||
width={align === 'right' ? x : width - padding}
|
width={textWidth}
|
||||||
text={lines.join('\n')}
|
text={lines.join('\n')}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
fontFamily={branding.fontFamily || 'Inter'}
|
fontFamily={branding.fontFamily || 'Inter'}
|
||||||
@@ -288,16 +346,16 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
|
|
||||||
if (socialLayout === 'vertical') {
|
if (socialLayout === 'vertical') {
|
||||||
itemY = socialStartY + index * (socialItemHeight + iconGap - 4);
|
itemY = socialStartY + index * (socialItemHeight + iconGap - 4);
|
||||||
itemX = align === 'right' ? x : x;
|
itemX = anchorX;
|
||||||
} else {
|
} else {
|
||||||
itemY = socialStartY;
|
itemY = socialStartY;
|
||||||
// For horizontal layout, we need to calculate cumulative width
|
// For horizontal layout, we need to calculate cumulative width
|
||||||
// This is simplified - for perfect alignment we'd need to measure text
|
// This is simplified - for perfect alignment we'd need to measure text
|
||||||
const prevItemsWidth = activeSocialPlatforms.slice(0, index).reduce((acc, p) => {
|
const prevItemsWidth = activeSocialPlatforms.slice(0, index).reduce((acc, p) => {
|
||||||
const textWidth = (p.value.length * fontSize * 0.5); // Approximate text width
|
const textWidthEstimate = (p.value.length * fontSize * 0.5); // Approximate text width
|
||||||
return acc + iconSize + iconTextGap + textWidth + iconGap;
|
return acc + iconSize + iconTextGap + textWidthEstimate + iconGap;
|
||||||
}, 0);
|
}, 0);
|
||||||
itemX = align === 'right' ? x - prevItemsWidth : x + prevItemsWidth;
|
itemX = align === 'right' ? anchorX - prevItemsWidth : anchorX + prevItemsWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon vertical centering within item
|
// Icon vertical centering within item
|
||||||
@@ -307,7 +365,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
|
|
||||||
if (align === 'right') {
|
if (align === 'right') {
|
||||||
// For right alignment: text first, then icon
|
// 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 (
|
return (
|
||||||
<Group key={platform.key}>
|
<Group key={platform.key}>
|
||||||
<Path
|
<Path
|
||||||
@@ -319,7 +377,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
scaleY={iconScale}
|
scaleY={iconScale}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
x={itemX - iconSize - iconTextGap - textWidth}
|
x={itemX - iconSize - iconTextGap - textWidthEstimate}
|
||||||
y={textY}
|
y={textY}
|
||||||
text={platform.value}
|
text={platform.value}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
@@ -354,7 +412,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
})}
|
})}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}, [background.branding, width, height]);
|
}, [background.branding, width, height, brandingAvatar]);
|
||||||
|
|
||||||
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
|
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useCanvasStore } from '../store/canvasStore';
|
||||||
import type { CodeElement, TextElement, ArrowElement } from '../types';
|
import type { CodeElement, TextElement, ArrowElement } from '../types';
|
||||||
import BackgroundPanel from './inspector/BackgroundPanel';
|
import BackgroundPanel from './inspector/BackgroundPanel';
|
||||||
@@ -7,7 +7,13 @@ import CodeInspector from './inspector/CodeInspector';
|
|||||||
import TextInspector from './inspector/TextInspector';
|
import TextInspector from './inspector/TextInspector';
|
||||||
import ArrowInspector from './inspector/ArrowInspector';
|
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 Inspector: React.FC = () => {
|
||||||
|
const [width, setWidth] = useState<number>(DEFAULT_WIDTH);
|
||||||
const {
|
const {
|
||||||
snap,
|
snap,
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
@@ -17,10 +23,58 @@ const Inspector: React.FC = () => {
|
|||||||
moveElementDown,
|
moveElementDown,
|
||||||
} = useCanvasStore();
|
} = useCanvasStore();
|
||||||
|
|
||||||
|
const dragStateRef = useRef({
|
||||||
|
startX: 0,
|
||||||
|
startWidth: DEFAULT_WIDTH,
|
||||||
|
isDragging: false,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedElement = useMemo(
|
const selectedElement = useMemo(
|
||||||
() => snap.elements.find(el => el.id === selectedElementId),
|
() => snap.elements.find(el => el.id === selectedElementId),
|
||||||
[snap.elements, 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<HTMLDivElement>) => {
|
||||||
|
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(() => {
|
const handleDelete = useCallback(() => {
|
||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
@@ -47,8 +101,21 @@ const Inspector: React.FC = () => {
|
|||||||
}, [selectedElement, moveElementDown]);
|
}, [selectedElement, moveElementDown]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-[#09090b] border-l border-white/5 overflow-y-auto h-full">
|
<div
|
||||||
<div className="p-6">
|
className="relative flex-shrink-0 h-full bg-[#09090b] border-l border-white/5 transition-[width] duration-150 ease-out"
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 h-full w-2 cursor-col-resize group"
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
onDoubleClick={handleHandleDoubleClick}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize inspector"
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-1 h-full w-0.5 rounded-full bg-white/5 transition-all group-hover:bg-white/20" />
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto h-full">
|
||||||
{selectedElement ? (
|
{selectedElement ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header with Title and Element Actions */}
|
{/* Header with Title and Element Actions */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import { useCanvasStore } from '../../store/canvasStore';
|
import { useCanvasStore } from '../../store/canvasStore';
|
||||||
import { useBrandingStore } from '../../store/brandingStore';
|
import { useBrandingStore } from '../../store/brandingStore';
|
||||||
import { FONT_FAMILIES } from '../../types';
|
import { FONT_FAMILIES } from '../../types';
|
||||||
@@ -20,6 +20,78 @@ const SOCIAL_PLATFORMS = [
|
|||||||
{ key: 'tiktok', label: 'TikTok', placeholder: '@username' },
|
{ key: 'tiktok', label: 'TikTok', placeholder: '@username' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const MAX_AVATAR_DIMENSION = 192;
|
||||||
|
const MAX_EMBEDDED_AVATAR_LENGTH = 350000;
|
||||||
|
|
||||||
|
const readFileAsDataUrl = (file: File) => new Promise<string>((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<HTMLImageElement>((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<string> => {
|
||||||
|
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 BrandingPanel: React.FC = () => {
|
||||||
const { setBackground } = useCanvasStore();
|
const { setBackground } = useCanvasStore();
|
||||||
const { info, preferences, updateInfo, updateSocial, updatePreferences } = useBrandingStore();
|
const { info, preferences, updateInfo, updateSocial, updatePreferences } = useBrandingStore();
|
||||||
@@ -32,6 +104,7 @@ const BrandingPanel: React.FC = () => {
|
|||||||
name: info.name,
|
name: info.name,
|
||||||
website: info.website,
|
website: info.website,
|
||||||
social: info.social,
|
social: info.social,
|
||||||
|
avatarUrl: info.avatarUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [info, preferences, setBackground]);
|
}, [info, preferences, setBackground]);
|
||||||
@@ -48,6 +121,35 @@ const BrandingPanel: React.FC = () => {
|
|||||||
updateSocial(platform, value);
|
updateSocial(platform, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Enable Branding */}
|
{/* Enable Branding */}
|
||||||
@@ -93,6 +195,74 @@ const BrandingPanel: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
Avatar
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdatePreferences({ showAvatar: !preferences.showAvatar })}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
preferences.showAvatar
|
||||||
|
? 'bg-blue-600/20 text-blue-400'
|
||||||
|
: 'bg-white/5 text-neutral-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preferences.showAvatar ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative w-16 h-16 rounded-full border border-white/10 bg-white/5 overflow-hidden cursor-pointer group">
|
||||||
|
{info.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={info.avatarUrl}
|
||||||
|
alt="Brand avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-[10px] text-neutral-500 group-hover:text-neutral-300">
|
||||||
|
<span>Upload</span>
|
||||||
|
<span className="text-[9px] text-neutral-600">PNG/JPG</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
aria-label="Upload avatar"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 text-[11px] text-neutral-500 space-y-1">
|
||||||
|
<p>Use a square image for best results. Supported formats: PNG, JPG, SVG.</p>
|
||||||
|
{info.avatarUrl && (
|
||||||
|
<button
|
||||||
|
onClick={handleAvatarClear}
|
||||||
|
className="text-xs text-neutral-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Remove avatar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{preferences.showAvatar && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-xs text-neutral-500 mb-2">
|
||||||
|
Size: {preferences.avatarSize}px
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={32}
|
||||||
|
max={120}
|
||||||
|
value={preferences.avatarSize}
|
||||||
|
onChange={(e) => handleUpdatePreferences({ avatarSize: parseInt(e.target.value) })}
|
||||||
|
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
interface BrandingInfo {
|
interface BrandingInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,6 +12,7 @@ interface BrandingInfo {
|
|||||||
youtube?: string;
|
youtube?: string;
|
||||||
tiktok?: string;
|
tiktok?: string;
|
||||||
};
|
};
|
||||||
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrandingPreferences {
|
interface BrandingPreferences {
|
||||||
@@ -20,6 +21,7 @@ interface BrandingPreferences {
|
|||||||
showName: boolean;
|
showName: boolean;
|
||||||
showWebsite: boolean;
|
showWebsite: boolean;
|
||||||
showSocial: boolean;
|
showSocial: boolean;
|
||||||
|
showAvatar: boolean;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -27,6 +29,7 @@ interface BrandingPreferences {
|
|||||||
padding: number;
|
padding: number;
|
||||||
socialIconSize: number;
|
socialIconSize: number;
|
||||||
socialLayout: 'horizontal' | 'vertical';
|
socialLayout: 'horizontal' | 'vertical';
|
||||||
|
avatarSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrandingStore {
|
interface BrandingStore {
|
||||||
@@ -44,14 +47,18 @@ const defaultInfo: BrandingInfo = {
|
|||||||
name: '',
|
name: '',
|
||||||
website: '',
|
website: '',
|
||||||
social: {},
|
social: {},
|
||||||
|
avatarUrl: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_PERSISTED_AVATAR_LENGTH = 350000;
|
||||||
|
|
||||||
const defaultPreferences: BrandingPreferences = {
|
const defaultPreferences: BrandingPreferences = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
showName: true,
|
showName: true,
|
||||||
showWebsite: true,
|
showWebsite: true,
|
||||||
showSocial: true,
|
showSocial: true,
|
||||||
|
showAvatar: false,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
@@ -59,6 +66,7 @@ const defaultPreferences: BrandingPreferences = {
|
|||||||
padding: 24,
|
padding: 24,
|
||||||
socialIconSize: 20,
|
socialIconSize: 20,
|
||||||
socialLayout: 'horizontal',
|
socialLayout: 'horizontal',
|
||||||
|
avatarSize: 56,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useBrandingStore = create<BrandingStore>()(
|
export const useBrandingStore = create<BrandingStore>()(
|
||||||
@@ -89,6 +97,36 @@ export const useBrandingStore = create<BrandingStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'yvcode-branding',
|
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,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import type {
|
import type {
|
||||||
Snap,
|
Snap,
|
||||||
@@ -52,6 +52,8 @@ interface CanvasState {
|
|||||||
importSnap: (json: string) => void;
|
importSnap: (json: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_PERSISTED_AVATAR_LENGTH = 350000;
|
||||||
|
|
||||||
const defaultSnap: Snap = {
|
const defaultSnap: Snap = {
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
meta: {
|
meta: {
|
||||||
@@ -80,14 +82,19 @@ const defaultSnap: Snap = {
|
|||||||
name: '',
|
name: '',
|
||||||
website: '',
|
website: '',
|
||||||
social: {},
|
social: {},
|
||||||
|
avatarUrl: '',
|
||||||
showName: true,
|
showName: true,
|
||||||
showWebsite: true,
|
showWebsite: true,
|
||||||
showSocial: true,
|
showSocial: true,
|
||||||
|
showAvatar: false,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
padding: 24,
|
padding: 24,
|
||||||
|
socialIconSize: 20,
|
||||||
|
socialLayout: 'horizontal',
|
||||||
|
avatarSize: 56,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
elements: [],
|
elements: [],
|
||||||
@@ -243,7 +250,35 @@ export const useCanvasStore = create<CanvasState>()(
|
|||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
name: 'code-canvas-storage',
|
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 };
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ export interface Branding {
|
|||||||
name: string;
|
name: string;
|
||||||
website: string;
|
website: string;
|
||||||
social: SocialMedia;
|
social: SocialMedia;
|
||||||
|
avatarUrl?: string;
|
||||||
showName: boolean;
|
showName: boolean;
|
||||||
showWebsite: boolean;
|
showWebsite: boolean;
|
||||||
showSocial: boolean;
|
showSocial: boolean;
|
||||||
|
showAvatar?: boolean;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -35,6 +37,7 @@ export interface Branding {
|
|||||||
padding: number;
|
padding: number;
|
||||||
socialIconSize?: number;
|
socialIconSize?: number;
|
||||||
socialLayout?: 'horizontal' | 'vertical';
|
socialLayout?: 'horizontal' | 'vertical';
|
||||||
|
avatarSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrandStrip {
|
export interface BrandStrip {
|
||||||
|
|||||||
Reference in New Issue
Block a user