492 lines
19 KiB
TypeScript
492 lines
19 KiB
TypeScript
import React, { useEffect, useCallback } from 'react';
|
|
import { useCanvasStore } from '../../store/canvasStore';
|
|
import { useBrandingStore } from '../../store/brandingStore';
|
|
import { FONT_FAMILIES } from '../../types';
|
|
import { SocialIcon } from '../elements/SocialIcons';
|
|
|
|
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 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 { setBackground } = useCanvasStore();
|
|
const { info, preferences, updateInfo, updateSocial, updatePreferences } = useBrandingStore();
|
|
|
|
// Sync branding store to canvas whenever it changes
|
|
useEffect(() => {
|
|
setBackground({
|
|
branding: {
|
|
...preferences,
|
|
name: info.name,
|
|
website: info.website,
|
|
social: info.social,
|
|
avatarUrl: info.avatarUrl,
|
|
},
|
|
});
|
|
}, [info, preferences, setBackground]);
|
|
|
|
const handleUpdatePreferences = (updates: Partial<typeof preferences>) => {
|
|
updatePreferences(updates);
|
|
};
|
|
|
|
const handleUpdateInfo = (updates: Partial<typeof info>) => {
|
|
updateInfo(updates);
|
|
};
|
|
|
|
const handleUpdateSocial = (platform: string, value: string) => {
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* Enable Branding */}
|
|
<div className="flex items-center justify-between">
|
|
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
Branding Watermark
|
|
</label>
|
|
<button
|
|
onClick={() => handleUpdatePreferences({ enabled: !preferences.enabled })}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
preferences.enabled ? 'bg-blue-600' : 'bg-white/10'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
|
preferences.enabled ? 'translate-x-4' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{preferences.enabled && (
|
|
<div className="space-y-5">
|
|
{/* Position */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
|
|
Position
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{POSITION_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => handleUpdatePreferences({ position: opt.value })}
|
|
className={`py-2 px-3 rounded-lg text-xs font-medium transition-all border ${
|
|
preferences.position === opt.value
|
|
? 'bg-blue-600/20 text-blue-400 border-blue-500/50'
|
|
: 'bg-white/5 text-neutral-400 hover:text-white hover:bg-white/10 border-white/5'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</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 */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
Name
|
|
</label>
|
|
<button
|
|
onClick={() => handleUpdatePreferences({ showName: !preferences.showName })}
|
|
className={`text-xs px-2 py-0.5 rounded ${
|
|
preferences.showName
|
|
? 'bg-blue-600/20 text-blue-400'
|
|
: 'bg-white/5 text-neutral-500'
|
|
}`}
|
|
>
|
|
{preferences.showName ? 'Show' : 'Hide'}
|
|
</button>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={info.name}
|
|
onChange={(e) => handleUpdateInfo({ 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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Website */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
Website
|
|
</label>
|
|
<button
|
|
onClick={() => handleUpdatePreferences({ showWebsite: !preferences.showWebsite })}
|
|
className={`text-xs px-2 py-0.5 rounded ${
|
|
preferences.showWebsite
|
|
? 'bg-blue-600/20 text-blue-400'
|
|
: 'bg-white/5 text-neutral-500'
|
|
}`}
|
|
>
|
|
{preferences.showWebsite ? 'Show' : 'Hide'}
|
|
</button>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={info.website}
|
|
onChange={(e) => handleUpdateInfo({ 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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Social Media */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
Social Media
|
|
</label>
|
|
<button
|
|
onClick={() => handleUpdatePreferences({ showSocial: !preferences.showSocial })}
|
|
className={`text-xs px-2 py-0.5 rounded ${
|
|
preferences.showSocial
|
|
? 'bg-blue-600/20 text-blue-400'
|
|
: 'bg-white/5 text-neutral-500'
|
|
}`}
|
|
>
|
|
{preferences.showSocial ? 'Show' : 'Hide'}
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{SOCIAL_PLATFORMS.map((platform) => (
|
|
<div key={platform.key} className="flex items-center gap-2">
|
|
<div className="w-5 h-5 shrink-0 flex items-center justify-center text-neutral-400">
|
|
<SocialIcon platform={platform.key} size={16} color="currentColor" />
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={info.social[platform.key as keyof typeof info.social] || ''}
|
|
onChange={(e) => handleUpdateSocial(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"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Social Icons Layout */}
|
|
<div className="mt-4 space-y-3">
|
|
<div>
|
|
<label className="block text-xs text-neutral-500 mb-2">Layout</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleUpdatePreferences({ socialLayout: 'horizontal' })}
|
|
className={`flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all border ${
|
|
preferences.socialLayout === 'horizontal'
|
|
? 'bg-blue-600/20 text-blue-400 border-blue-500/50'
|
|
: 'bg-white/5 text-neutral-400 hover:text-white hover:bg-white/10 border-white/5'
|
|
}`}
|
|
>
|
|
Horizontal
|
|
</button>
|
|
<button
|
|
onClick={() => handleUpdatePreferences({ socialLayout: 'vertical' })}
|
|
className={`flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all border ${
|
|
preferences.socialLayout === 'vertical'
|
|
? 'bg-blue-600/20 text-blue-400 border-blue-500/50'
|
|
: 'bg-white/5 text-neutral-400 hover:text-white hover:bg-white/10 border-white/5'
|
|
}`}
|
|
>
|
|
Vertical
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs text-neutral-500 mb-2">
|
|
Icon Size: {preferences.socialIconSize}px
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="14"
|
|
max="32"
|
|
value={preferences.socialIconSize}
|
|
onChange={(e) => handleUpdatePreferences({ socialIconSize: parseInt(e.target.value) })}
|
|
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Styling */}
|
|
<div className="pt-4 border-t border-white/5">
|
|
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-3">
|
|
Styling
|
|
</label>
|
|
|
|
{/* Font Family */}
|
|
<div className="mb-4">
|
|
<label className="block text-xs text-neutral-500 mb-2">Font</label>
|
|
<select
|
|
value={preferences.fontFamily}
|
|
onChange={(e) => handleUpdatePreferences({ fontFamily: e.target.value })}
|
|
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"
|
|
>
|
|
{FONT_FAMILIES.text.map((font) => (
|
|
<option key={font} value={font} style={{ fontFamily: font }}>
|
|
{font}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Font Size */}
|
|
<div className="mb-4">
|
|
<label className="block text-xs text-neutral-500 mb-2">
|
|
Font Size: {preferences.fontSize}px
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="10"
|
|
max="24"
|
|
value={preferences.fontSize}
|
|
onChange={(e) => handleUpdatePreferences({ fontSize: parseInt(e.target.value) })}
|
|
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
/>
|
|
</div>
|
|
|
|
{/* Color */}
|
|
<div className="mb-4">
|
|
<label className="block text-xs text-neutral-500 mb-2">Color</label>
|
|
<div className="flex gap-2 items-center p-2 bg-white/5 rounded-lg border border-white/5">
|
|
<div className="w-6 h-6 rounded overflow-hidden relative border border-white/10 shrink-0">
|
|
<input
|
|
type="color"
|
|
value={preferences.color}
|
|
onChange={(e) => handleUpdatePreferences({ color: e.target.value })}
|
|
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={preferences.color}
|
|
onChange={(e) => handleUpdatePreferences({ color: e.target.value })}
|
|
className="w-full bg-transparent text-white text-xs focus:outline-none font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Opacity */}
|
|
<div className="mb-4">
|
|
<label className="block text-xs text-neutral-500 mb-2">
|
|
Opacity: {Math.round(preferences.opacity * 100)}%
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="0.1"
|
|
max="1"
|
|
step="0.05"
|
|
value={preferences.opacity}
|
|
onChange={(e) => handleUpdatePreferences({ opacity: parseFloat(e.target.value) })}
|
|
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
/>
|
|
</div>
|
|
|
|
{/* Padding */}
|
|
<div>
|
|
<label className="block text-xs text-neutral-500 mb-2">
|
|
Padding: {preferences.padding}px
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="8"
|
|
max="48"
|
|
value={preferences.padding}
|
|
onChange={(e) => handleUpdatePreferences({ padding: parseInt(e.target.value) })}
|
|
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BrandingPanel;
|