A web app to help you design things, local, offline, on device. In your browser.
TypeScript
53.1%
JSON
45.6%
CSS
0.9%
Markdown
0.3%
JavaScript
0.1%
'use client';
import React, { useState } from 'react';
import {
X,
Eye,
EyeOff,
Lock,
Unlock,
Trash2,
Copy,
ChevronUp,
ChevronDown,
RotateCw,
Eraser,
Loader2,
CornerUpLeft,
Link2,
Link2Off,
AlignLeft,
AlignCenter,
AlignRight,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignHorizontalSpaceAround,
AlignVerticalSpaceAround,
} from 'lucide-react';
import { useEditorStore } from '@/lib/store/editor-store';
import { removeImageBackground, type BackgroundRemovalProgress } from '@/lib/ai/background-removal';
import type { ImageLayerData, CornerRadius } from '@/types';
export function PropertiesPanel() {
const {
layers,
selectedLayerIds,
updateLayer,
deleteLayer,
duplicateLayer,
moveLayer,
deselectAll,
alignLayers,
distributeLayers,
} = useEditorStore();
const [bgRemovalProgress, setBgRemovalProgress] = useState<BackgroundRemovalProgress | null>(null);
const [uniformCorners, setUniformCorners] = useState(true);
const selectedLayers = selectedLayerIds
.map((id) => layers.find((l) => l.id === id))
.filter(Boolean);
const isImageLayer = selectedLayers.length === 1 && selectedLayers[0]?.type === 'image';
// Get current corner radius for image layers
const getImageCornerRadius = (): { uniform: number; individual: CornerRadius } => {
if (!isImageLayer) return { uniform: 0, individual: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 } };
const imageData = selectedLayers[0]!.data as ImageLayerData;
if (!imageData.cornerRadius) {
return { uniform: 0, individual: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 } };
}
if (typeof imageData.cornerRadius === 'number') {
return {
uniform: imageData.cornerRadius,
individual: {
topLeft: imageData.cornerRadius,
topRight: imageData.cornerRadius,
bottomLeft: imageData.cornerRadius,
bottomRight: imageData.cornerRadius,
},
};
}
const cr = imageData.cornerRadius;
const uniform = cr.topLeft === cr.topRight && cr.topRight === cr.bottomLeft && cr.bottomLeft === cr.bottomRight
? cr.topLeft : 0;
return { uniform, individual: cr };
};
const handleUniformCornerChange = (value: number) => {
if (!isImageLayer) return;
const layer = selectedLayers[0]!;
updateLayer(layer.id, {
data: { ...layer.data, cornerRadius: value } as ImageLayerData,
});
};
const handleIndividualCornerChange = (corner: keyof CornerRadius, value: number) => {
if (!isImageLayer) return;
const layer = selectedLayers[0]!;
const current = getImageCornerRadius().individual;
updateLayer(layer.id, {
data: {
...layer.data,
cornerRadius: { ...current, [corner]: value },
} as ImageLayerData,
});
};
const handleRemoveBackground = async () => {
if (!isImageLayer) return;
const layer = selectedLayers[0]!;
const imageData = layer.data as ImageLayerData;
setBgRemovalProgress({ progress: 0, stage: 'loading', message: 'Starting...' });
const result = await removeImageBackground(
imageData.src,
(progress) => setBgRemovalProgress(progress)
);
if (result.success && result.dataUrl) {
updateLayer(layer.id, {
data: { ...imageData, src: result.dataUrl },
});
}
// Clear progress after a moment
setTimeout(() => setBgRemovalProgress(null), 2000);
};
if (selectedLayers.length === 0) {
return (
<div className="w-64 bg-neutral-900/50 border-l border-neutral-800 p-4">
<h3 className="text-sm font-medium text-neutral-400 mb-4">Properties</h3>
<p className="text-sm text-neutral-500">Select a layer to edit its properties</p>
</div>
);
}
const layer = selectedLayers[0]!;
return (
<div className="w-64 bg-neutral-900/50 border-l border-neutral-800 flex flex-col">
{/* Header */}
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-white truncate">{layer.name}</div>
<span className="text-xs text-neutral-500 capitalize">{layer.type}</span>
</div>
<button
onClick={deselectAll}
className="p-1 text-neutral-500 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Quick actions */}
<div className="flex gap-1">
<button
onClick={() => updateLayer(layer.id, { visible: !layer.visible })}
className={`
flex-1 p-2 rounded-lg flex items-center justify-center
transition-colors
${layer.visible ? 'bg-neutral-800 text-white' : 'bg-neutral-800/50 text-neutral-500'}
`}
title={layer.visible ? 'Hide' : 'Show'}
>
{layer.visible ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button
onClick={() => updateLayer(layer.id, { locked: !layer.locked })}
className={`
flex-1 p-2 rounded-lg flex items-center justify-center
transition-colors
${layer.locked ? 'bg-amber-500/20 text-amber-400' : 'bg-neutral-800 text-white'}
`}
title={layer.locked ? 'Unlock' : 'Lock'}
>
{layer.locked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
</button>
<button
onClick={() => duplicateLayer(layer.id)}
className="flex-1 p-2 rounded-lg bg-neutral-800 text-white hover:bg-neutral-700 transition-colors"
title="Duplicate"
>
<Copy className="w-4 h-4 mx-auto" />
</button>
<button
onClick={() => deleteLayer(layer.id)}
className="flex-1 p-2 rounded-lg bg-neutral-800 text-red-400 hover:bg-red-500/20 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4 mx-auto" />
</button>
</div>
{/* Alignment */}
<div>
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
Align {selectedLayerIds.length > 1 ? '(to each other)' : '(to canvas)'}
</label>
<div className="grid grid-cols-6 gap-1">
<button
onClick={() => alignLayers('left')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Align Left"
>
<AlignLeft className="w-3.5 h-3.5 mx-auto" />
</button>
<button
onClick={() => alignLayers('center')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Align Center"
>
<AlignCenter className="w-3.5 h-3.5 mx-auto" />
</button>
<button
onClick={() => alignLayers('right')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Align Right"
>
<AlignRight className="w-3.5 h-3.5 mx-auto" />
</button>
<button
onClick={() => alignLayers('top')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Align Top"
>
<AlignVerticalJustifyStart className="w-3.5 h-3.5 mx-auto" />
</button>
<button
onClick={() => alignLayers('middle')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Align Middle"
>
<AlignVerticalJustifyCenter className="w-3.5 h-3.5 mx-auto" />
</button>
<button
onClick={() => alignLayers('bottom')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Align Bottom"
>
<AlignVerticalJustifyEnd className="w-3.5 h-3.5 mx-auto" />
</button>
</div>
{/* Distribution - only show when 3+ layers selected */}
{selectedLayerIds.length >= 3 && (
<div className="grid grid-cols-2 gap-1 mt-2">
<button
onClick={() => distributeLayers('horizontal')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors flex items-center justify-center gap-1.5 text-xs"
title="Distribute Horizontally"
>
<AlignHorizontalSpaceAround className="w-3.5 h-3.5" />
Horizontal
</button>
<button
onClick={() => distributeLayers('vertical')}
className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors flex items-center justify-center gap-1.5 text-xs"
title="Distribute Vertically"
>
<AlignVerticalSpaceAround className="w-3.5 h-3.5" />
Vertical
</button>
</div>
)}
</div>
{/* Position */}
<div>
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
Position
</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-neutral-400 block mb-1">X</label>
<input
type="number"
value={Math.round(layer.position.x)}
onChange={(e) =>
updateLayer(layer.id, {
position: { ...layer.position, x: Number(e.target.value) },
})
}
className="
w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
text-white text-sm focus:outline-none focus:border-indigo-500
"
/>
</div>
<div>
<label className="text-xs text-neutral-400 block mb-1">Y</label>
<input
type="number"
value={Math.round(layer.position.y)}
onChange={(e) =>
updateLayer(layer.id, {
position: { ...layer.position, y: Number(e.target.value) },
})
}
className="
w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
text-white text-sm focus:outline-none focus:border-indigo-500
"
/>
</div>
</div>
</div>
{/* Size */}
<div>
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
Size
</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-neutral-400 block mb-1">W</label>
<input
type="number"
value={Math.round(layer.size.width)}
onChange={(e) =>
updateLayer(layer.id, {
size: { ...layer.size, width: Number(e.target.value) },
})
}
className="
w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
text-white text-sm focus:outline-none focus:border-indigo-500
"
/>
</div>
<div>
<label className="text-xs text-neutral-400 block mb-1">H</label>
<input
type="number"
value={Math.round(layer.size.height)}
onChange={(e) =>
updateLayer(layer.id, {
size: { ...layer.size, height: Number(e.target.value) },
})
}
className="
w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
text-white text-sm focus:outline-none focus:border-indigo-500
"
/>
</div>
</div>
</div>
{/* Rotation */}
<div>
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
Rotation
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={Math.round(layer.rotation)}
onChange={(e) => updateLayer(layer.id, { rotation: Number(e.target.value) })}
className="
flex-1 px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
text-white text-sm focus:outline-none focus:border-indigo-500
"
/>
<button
onClick={() => updateLayer(layer.id, { rotation: 0 })}
className="p-1.5 rounded bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
title="Reset rotation"
>
<RotateCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Opacity */}
<div>
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
Opacity
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
value={layer.opacity * 100}
onChange={(e) => updateLayer(layer.id, { opacity: Number(e.target.value) / 100 })}
className="flex-1"
/>
<span className="text-sm text-white w-10 text-right">
{Math.round(layer.opacity * 100)}%
</span>
</div>
</div>
{/* Image-specific controls */}
{isImageLayer && (
<>
{/* Corner Radius */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-neutral-500 uppercase tracking-wider">
Corner Radius
</label>
<button
onClick={() => setUniformCorners(!uniformCorners)}
className={`p-1 rounded transition-colors ${
uniformCorners ? 'text-indigo-400' : 'text-neutral-500 hover:text-white'
}`}
title={uniformCorners ? 'Uniform corners' : 'Individual corners'}
>
{uniformCorners ? <Link2 className="w-3.5 h-3.5" /> : <Link2Off className="w-3.5 h-3.5" />}
</button>
</div>
{uniformCorners ? (
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={200}
value={getImageCornerRadius().uniform}
onChange={(e) => handleUniformCornerChange(Number(e.target.value))}
className="flex-1"
/>
<input
type="number"
min={0}
max={200}
value={getImageCornerRadius().uniform}
onChange={(e) => handleUniformCornerChange(Number(e.target.value))}
className="w-14 px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm text-center"
/>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-neutral-500 block mb-1">Top Left</label>
<input
type="number"
min={0}
max={200}
value={getImageCornerRadius().individual.topLeft}
onChange={(e) => handleIndividualCornerChange('topLeft', Number(e.target.value))}
className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
/>
</div>
<div>
<label className="text-[10px] text-neutral-500 block mb-1">Top Right</label>
<input
type="number"
min={0}
max={200}
value={getImageCornerRadius().individual.topRight}
onChange={(e) => handleIndividualCornerChange('topRight', Number(e.target.value))}
className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
/>
</div>
<div>
<label className="text-[10px] text-neutral-500 block mb-1">Bottom Left</label>
<input
type="number"
min={0}
max={200}
value={getImageCornerRadius().individual.bottomLeft}
onChange={(e) => handleIndividualCornerChange('bottomLeft', Number(e.target.value))}
className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
/>
</div>
<div>
<label className="text-[10px] text-neutral-500 block mb-1">Bottom Right</label>
<input
type="number"
min={0}
max={200}
value={getImageCornerRadius().individual.bottomRight}
onChange={(e) => handleIndividualCornerChange('bottomRight', Number(e.target.value))}
className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
/>
</div>
</div>
)}
</div>
{/* Background Removal */}
<div>
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
AI Tools
</label>
<button
onClick={handleRemoveBackground}
disabled={bgRemovalProgress !== null && bgRemovalProgress.stage !== 'complete' && bgRemovalProgress.stage !== 'error'}
className={`
w-full py-2 px-3 rounded-lg flex items-center justify-center gap-2
transition-colors text-sm font-medium
${bgRemovalProgress?.stage === 'complete'
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: bgRemovalProgress?.stage === 'error'
? 'bg-red-500/20 text-red-400 border border-red-500/30'
: bgRemovalProgress
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/30'
: 'bg-indigo-500 hover:bg-indigo-600 text-white'
}
disabled:cursor-not-allowed
`}
>
{bgRemovalProgress ? (
bgRemovalProgress.stage === 'complete' ? (
<>
<CornerUpLeft className="w-4 h-4" />
Background Removed!
</>
) : bgRemovalProgress.stage === 'error' ? (
<>Error: {bgRemovalProgress.message}</>
) : (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{bgRemovalProgress.message}
</>
)
) : (
<>
<Eraser className="w-4 h-4" />
Remove Background
</>
)}
</button>
{bgRemovalProgress && bgRemovalProgress.stage === 'processing' && (
<div className="mt-2 h-1 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-300"
style={{ width: `${bgRemovalProgress.progress}%` }}
/>
</div>
)}
</div>
</>
)}
{/* Layer order */}
<div>
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
Layer Order
</label>
<div className="flex gap-1">
<button
onClick={() => moveLayer(layer.id, 'top')}
className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors text-xs"
>
Top
</button>
<button
onClick={() => moveLayer(layer.id, 'up')}
className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
>
<ChevronUp className="w-4 h-4 mx-auto" />
</button>
<button
onClick={() => moveLayer(layer.id, 'down')}
className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
>
<ChevronDown className="w-4 h-4 mx-auto" />
</button>
<button
onClick={() => moveLayer(layer.id, 'bottom')}
className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors text-xs"
>
Bottom
</button>
</div>
</div>
</div>
{/* Layers list */}
<div className="border-t border-neutral-800 p-3">
<label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
Layers ({layers.length})
</label>
<div className="space-y-1 max-h-40 overflow-y-auto">
{[...layers].reverse().map((l) => (
<button
key={l.id}
onClick={() => useEditorStore.getState().selectLayer(l.id)}
className={`
w-full flex items-center gap-2 p-2 rounded-lg text-left
transition-colors
${
selectedLayerIds.includes(l.id)
? 'bg-indigo-500/20 border border-indigo-500/50'
: 'hover:bg-neutral-800 border border-transparent'
}
`}
>
{!l.visible && <EyeOff className="w-3 h-3 text-neutral-500" />}
{l.locked && <Lock className="w-3 h-3 text-amber-400" />}
<span
className={`text-sm truncate flex-1 ${
selectedLayerIds.includes(l.id) ? 'text-white' : 'text-neutral-400'
}`}
>
{l.name}
</span>
<span className="text-xs text-neutral-600 capitalize">{l.type}</span>
</button>
))}
</div>
</div>
</div>
);
}
About
A web app to help you design things, local, offline, on device. In your browser.
0 stars
0 forks