Compare commits
2 Commits
537cfd572d
...
72a1967333
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a1967333 | ||
|
|
fd11a36d93 |
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
||||
import { Sidebar } from './components/sidebar/Sidebar';
|
||||
import { PropertiesPanel } from './components/properties/PropertiesPanel';
|
||||
import { LayersPanel } from './components/panels/LayersPanel';
|
||||
import { PropertiesPanel } from './components/panels/PropertiesPanel';
|
||||
import { useDesignEditor } from './hooks/useDesignEditor';
|
||||
|
||||
function App() {
|
||||
@@ -13,45 +14,17 @@ function App() {
|
||||
deleteElement,
|
||||
selectElement,
|
||||
deselectAll,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
initializeHistory,
|
||||
} = useDesignEditor();
|
||||
|
||||
const selectedElement = elements.find((el) => el.id === selectedId);
|
||||
const selectedElement = elements.find(el => el.id === selectedId);
|
||||
|
||||
// Initialize history on mount
|
||||
useEffect(() => {
|
||||
initializeHistory();
|
||||
}, [initializeHistory]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
// Keyboard shortcut: Delete/Backspace removes selected element
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
// Don't delete if user is typing in an input
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo: Ctrl/Cmd + Z
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo) undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
|
||||
if ((e.ctrlKey || e.metaKey) && (
|
||||
(e.key === 'z' && e.shiftKey) ||
|
||||
e.key === 'y'
|
||||
)) {
|
||||
e.preventDefault();
|
||||
if (canRedo) redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete/Backspace removes selected element
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedId) {
|
||||
deleteElement(selectedId);
|
||||
@@ -61,104 +34,80 @@ function App() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
|
||||
}, [selectedId, deleteElement]);
|
||||
|
||||
const handleUpload = (data) => {
|
||||
if (data.preview?.url) {
|
||||
addElement({
|
||||
type: 'image',
|
||||
x: 75,
|
||||
y: 75,
|
||||
width: 150,
|
||||
height: 150,
|
||||
rotation: 0,
|
||||
src: data.preview.url,
|
||||
});
|
||||
}
|
||||
// Handler callbacks for sidebar tabs
|
||||
const handleAddImage = (imageData) => {
|
||||
addElement(imageData);
|
||||
};
|
||||
|
||||
const handleAddElement = (elementData) => {
|
||||
if (elementData.type === 'sticker') {
|
||||
// Convert emoji sticker to a text-like element
|
||||
addElement({
|
||||
type: 'text',
|
||||
text: elementData.emoji,
|
||||
x: elementData.x,
|
||||
y: elementData.y,
|
||||
fontSize: elementData.size,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#000000',
|
||||
rotation: elementData.rotation,
|
||||
});
|
||||
} else if (elementData.type === 'text') {
|
||||
addElement(elementData);
|
||||
}
|
||||
const handleAddSticker = (stickerData) => {
|
||||
addElement(stickerData);
|
||||
};
|
||||
|
||||
const handleApplyTemplate = (template) => {
|
||||
// Clear existing elements and apply template
|
||||
template.elements.forEach((el, index) => {
|
||||
setTimeout(() => addElement({ ...el }), index * 50);
|
||||
});
|
||||
const handleAddText = (textData) => {
|
||||
addElement(textData);
|
||||
};
|
||||
|
||||
const handleAddTemplate = (templateId) => {
|
||||
console.log('Template selected:', templateId);
|
||||
// Template loading will be implemented in Phase 6
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<div className="editor-layout">
|
||||
{/* Left Sidebar */}
|
||||
<aside className="sidebar-container">
|
||||
<Sidebar onElementAdd={handleAddElement} onUpload={handleUpload} onApplyTemplate={handleApplyTemplate} />
|
||||
</aside>
|
||||
<Sidebar
|
||||
onAddImage={handleAddImage}
|
||||
onAddSticker={handleAddSticker}
|
||||
onAddText={handleAddText}
|
||||
onAddTemplate={handleAddTemplate}
|
||||
/>
|
||||
|
||||
{/* Center Canvas */}
|
||||
<main className="canvas-container">
|
||||
<div className="canvas-header">
|
||||
<div>
|
||||
<h1 className="app-title">Apparel Designer</h1>
|
||||
<p className="app-subtitle">T-shirt customization editor</p>
|
||||
</div>
|
||||
<div className="undo-redo-buttons">
|
||||
<button
|
||||
className={`icon-btn ${!canUndo ? 'disabled' : ''}`}
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
↶ Undo
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn ${!canRedo ? 'disabled' : ''}`}
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
↷ Redo
|
||||
</button>
|
||||
</div>
|
||||
{/* Center Canvas Area */}
|
||||
<div className="canvas-area">
|
||||
<div style={{ marginBottom: '1rem', textAlign: 'center' }}>
|
||||
<h1 style={{ margin: '0 0 0.25rem 0', fontSize: '20px', color: 'var(--text-primary)' }}>
|
||||
Apparel Designer
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
T-shirt customization editor
|
||||
</p>
|
||||
</div>
|
||||
<div className="canvas-wrapper">
|
||||
<DesignCanvas
|
||||
|
||||
<DesignCanvas
|
||||
elements={elements}
|
||||
selectedId={selectedId}
|
||||
onSelect={selectElement}
|
||||
onDeselect={deselectAll}
|
||||
onUpdate={updateElement}
|
||||
/>
|
||||
|
||||
{/* Layers panel below canvas */}
|
||||
<div style={{
|
||||
marginTop: '1.5rem',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
background: 'var(--bg-primary)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '1rem',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
}}>
|
||||
<LayersPanel
|
||||
elements={elements}
|
||||
selectedId={selectedId}
|
||||
onSelect={selectElement}
|
||||
onDeselect={deselectAll}
|
||||
onUpdate={updateElement}
|
||||
onDelete={deleteElement}
|
||||
/>
|
||||
</div>
|
||||
<div className="debug-info">
|
||||
<p>Elements: {elements.length}</p>
|
||||
<p>Selected: {selectedId || 'None'}</p>
|
||||
<p className="tip">Tip: Click to select, drag to move, use handles to resize. Press Delete to remove.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Right Properties Panel */}
|
||||
<aside className="properties-container">
|
||||
<PropertiesPanel
|
||||
selectedElement={selectedElement}
|
||||
onUpdate={updateElement}
|
||||
onDelete={deleteElement}
|
||||
/>
|
||||
</aside>
|
||||
<PropertiesPanel
|
||||
element={selectedElement}
|
||||
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
||||
onDelete={deleteElement}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
131
client/src/components/panels/LayersPanel.jsx
Normal file
131
client/src/components/panels/LayersPanel.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
export function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
||||
const getIcon = (element) => {
|
||||
switch (element.type) {
|
||||
case 'image':
|
||||
return element.bgRemoved ? '🖼️' : '📷';
|
||||
case 'text':
|
||||
return '📝';
|
||||
case 'sticker':
|
||||
return '🎨';
|
||||
default:
|
||||
return '📁';
|
||||
}
|
||||
};
|
||||
|
||||
const getName = (element) => {
|
||||
switch (element.type) {
|
||||
case 'image':
|
||||
return element.bgRemoved ? 'Image (BG ✓)' : 'Image';
|
||||
case 'text':
|
||||
return element.text?.substring(0, 20) || 'Text';
|
||||
case 'sticker':
|
||||
return 'Sticker';
|
||||
default:
|
||||
return 'Element';
|
||||
}
|
||||
};
|
||||
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
No elements yet. Add images, text, or stickers to your design.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{
|
||||
margin: '0 0 0.75rem 0',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Layers ({elements.length})
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{elements.map((element, index) => (
|
||||
<div
|
||||
key={element.id}
|
||||
onClick={() => onSelect(element.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: selectedId === element.id ? 'var(--accent-bg)' : 'transparent',
|
||||
border: `1px solid ${selectedId === element.id ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedId !== element.id) {
|
||||
e.target.style.borderColor = 'var(--accent)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedId !== element.id) {
|
||||
e.target.style.borderColor = 'var(--border)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>{getIcon(element)}</span>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
fontSize: '12px',
|
||||
color: selectedId === element.id ? 'var(--accent)' : 'var(--text-primary)',
|
||||
fontWeight: selectedId === element.id ? '600' : '400',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{getName(element)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(element.id);
|
||||
}}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-muted)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = 'var(--error)';
|
||||
e.target.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'transparent';
|
||||
e.target.style.color = 'var(--text-muted)';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
client/src/components/panels/PropertiesPanel.jsx
Normal file
281
client/src/components/panels/PropertiesPanel.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
export function PropertiesPanel({ element, onUpdate, onDelete }) {
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
borderBottom: `1px solid var(--border)`,
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
Properties
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
Select an element to edit its properties
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePositionChange = (axis, value) => {
|
||||
onUpdate({ [axis]: parseFloat(value) || 0 });
|
||||
};
|
||||
|
||||
const handleSizeChange = (axis, value) => {
|
||||
const numValue = parseFloat(value) || 20;
|
||||
onUpdate({ [axis]: Math.max(20, numValue) });
|
||||
};
|
||||
|
||||
const handleRotationChange = (value) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
onUpdate({ rotation: Math.max(-180, Math.min(180, numValue)) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
borderBottom: `1px solid var(--border)`,
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
Properties
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{/* Element type badge */}
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 8px',
|
||||
background: 'var(--accent-bg)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--accent)',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
{element.type}
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Position
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>X</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(element.x)}
|
||||
onChange={(e) => handlePositionChange('x', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Y</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(element.y)}
|
||||
onChange={(e) => handlePositionChange('y', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size (for images and stickers) */}
|
||||
{(element.type === 'image' || element.type === 'sticker') && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Size
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>W</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(element.width)}
|
||||
onChange={(e) => handleSizeChange('width', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>H</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(element.height)}
|
||||
onChange={(e) => handleSizeChange('height', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Font size (for text) */}
|
||||
{element.type === 'text' && (
|
||||
<>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Font Size: {Math.round(element.fontSize)}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
max="120"
|
||||
value={element.fontSize}
|
||||
onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.fill}
|
||||
onChange={(e) => onUpdate({ fill: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rotation */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Rotation: {Math.round(element.rotation)}°
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={element.rotation}
|
||||
onChange={(e) => handleRotationChange(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => onDelete(element.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--error)',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
marginTop: '1rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#dc2626';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'var(--error)';
|
||||
}}
|
||||
>
|
||||
Delete Element
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
client/src/components/panels/index.js
Normal file
2
client/src/components/panels/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LayersPanel } from './LayersPanel';
|
||||
export { PropertiesPanel } from './PropertiesPanel';
|
||||
@@ -6,24 +6,24 @@ import { TemplatesTab } from './TemplatesTab';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'upload', label: 'Upload', icon: '📁' },
|
||||
{ id: 'stickers', label: 'Stickers', icon: '😊' },
|
||||
{ id: 'text', label: 'Text', icon: 'T' },
|
||||
{ id: 'stickers', label: 'Stickers', icon: '🎨' },
|
||||
{ id: 'text', label: 'Text', icon: '📝' },
|
||||
{ id: 'templates', label: 'Templates', icon: '📋' },
|
||||
];
|
||||
|
||||
export function Sidebar({ onElementAdd, onUpload, onApplyTemplate }) {
|
||||
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate }) {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'upload':
|
||||
return <UploadTab onUpload={onUpload} />;
|
||||
return <UploadTab onAddImage={onAddImage} />;
|
||||
case 'stickers':
|
||||
return <StickersTab onAddSticker={onElementAdd} />;
|
||||
return <StickersTab onAddSticker={onAddSticker} />;
|
||||
case 'text':
|
||||
return <TextTab onAddText={onElementAdd} />;
|
||||
return <TextTab onAddText={onAddText} />;
|
||||
case 'templates':
|
||||
return <TemplatesTab onApplyTemplate={onApplyTemplate} />;
|
||||
return <TemplatesTab onAddTemplate={onAddTemplate} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -31,20 +31,43 @@ export function Sidebar({ onElementAdd, onUpload, onApplyTemplate }) {
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-tabs">
|
||||
{/* Tab headers */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: `1px solid var(--border)`,
|
||||
background: 'var(--bg-primary)',
|
||||
}}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`sidebar-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
title={tab.label}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: activeTab === tab.id ? '600' : '400',
|
||||
color: activeTab === tab.id ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
borderBottom: activeTab === tab.id ? `2px solid var(--accent)` : '2px solid transparent',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<span className="tab-icon">{tab.icon}</span>
|
||||
<span className="tab-label">{tab.label}</span>
|
||||
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{tab.icon}</div>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-content">{renderTabContent()}</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const STICKER_CATEGORIES = [
|
||||
{ id: 'all', label: 'All', emojis: [] },
|
||||
{ id: 'faces', label: 'Faces', emojis: ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚'] },
|
||||
{ id: 'animals', label: 'Animals', emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🦆', '🦅'] },
|
||||
{ id: 'food', label: 'Food', emojis: ['🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦', '🥬'] },
|
||||
{ id: 'sports', label: 'Sports', emojis: ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🥅', '⛳', '🪁'] },
|
||||
{ id: 'symbols', label: 'Symbols', emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️'] },
|
||||
{ id: 'objects', label: 'Objects', emojis: ['⌚', '📱', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🕹️', '🗜️', '💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹', '🎥', '📽️'] },
|
||||
];
|
||||
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
|
||||
|
||||
export function StickersTab({ onAddSticker }) {
|
||||
const [selectedCategory, setSelectedCategory] = useState('faces');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
|
||||
const getStickers = () => {
|
||||
if (selectedCategory === 'all') {
|
||||
return STICKER_CATEGORIES.flatMap((cat) => cat.emojis).filter(Boolean);
|
||||
}
|
||||
const category = STICKER_CATEGORIES.find((cat) => cat.id === selectedCategory);
|
||||
return category?.emojis || [];
|
||||
};
|
||||
const categories = ['all', ...STICKER_CATEGORIES];
|
||||
|
||||
const filteredStickers = activeCategory === 'all'
|
||||
? STICKERS
|
||||
: STICKERS.filter(s => s.category === activeCategory);
|
||||
|
||||
const handleAddSticker = (emoji) => {
|
||||
// Create a canvas element with the emoji
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = 100;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.font = `${size * 0.8}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(emoji, size / 2, size / 2);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
|
||||
onAddSticker({
|
||||
type: 'sticker',
|
||||
emoji,
|
||||
x: 100,
|
||||
y: 100,
|
||||
size: 64,
|
||||
x: 125,
|
||||
y: 125,
|
||||
width: 80,
|
||||
height: 80,
|
||||
rotation: 0,
|
||||
src: dataUrl,
|
||||
emoji,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stickers-tab">
|
||||
<h3>Stickers</h3>
|
||||
<div className="category-pills">
|
||||
{STICKER_CATEGORIES.map((cat) => (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
||||
Stickers
|
||||
</h3>
|
||||
|
||||
{/* Category pills */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
marginBottom: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`category-pill ${selectedCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: `1px solid ${activeCategory === cat ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
background: activeCategory === cat ? 'var(--accent)' : 'var(--bg-primary)',
|
||||
color: activeCategory === cat ? '#fff' : 'var(--text-secondary)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{cat.label}
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sticker-grid">
|
||||
{getStickers().map((emoji, index) => (
|
||||
|
||||
{/* Sticker grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(5, 1fr)',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{filteredStickers.map((sticker, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="sticker-button"
|
||||
onClick={() => handleAddSticker(emoji)}
|
||||
title={`Add ${emoji}`}
|
||||
onClick={() => handleAddSticker(sticker.emoji)}
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--bg-primary)',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = 'var(--accent-bg)';
|
||||
e.target.style.transform = 'scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'var(--bg-primary)';
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
{sticker.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
||||
export function TemplatesTab({ onAddTemplate }) {
|
||||
const templates = [
|
||||
{
|
||||
id: 'freeform',
|
||||
name: 'Freeform',
|
||||
description: 'No template - design freely',
|
||||
thumbnail: '🎨',
|
||||
},
|
||||
// Placeholder for future templates
|
||||
{
|
||||
id: 'classic-tee-front',
|
||||
name: 'Classic Tee - Front',
|
||||
description: 'Standard front chest print',
|
||||
thumbnail: '👕',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'classic-tee-back',
|
||||
name: 'Classic Tee - Back',
|
||||
description: 'Full back print',
|
||||
thumbnail: '👕',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'all-over',
|
||||
name: 'All-Over Print',
|
||||
description: 'Full front coverage',
|
||||
thumbnail: '🎯',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function TemplatesTab({ onApplyTemplate }) {
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const filteredTemplates =
|
||||
selectedCategory === 'All'
|
||||
? TEMPLATES
|
||||
: TEMPLATES.filter((t) => t.category === selectedCategory);
|
||||
const handleSelectTemplate = (template) => {
|
||||
if (template.disabled) {
|
||||
alert('This template will be available in a future update');
|
||||
return;
|
||||
}
|
||||
onAddTemplate(template.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="templates-tab">
|
||||
<h3>Templates</h3>
|
||||
<div className="category-pills">
|
||||
{TEMPLATE_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`category-pill ${selectedCategory === cat ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
||||
Templates
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
Choose a template to constrain your design to specific print zones. Templates will be available in a future update.
|
||||
</div>
|
||||
<div className="templates-grid">
|
||||
{filteredTemplates.map((template) => (
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}>
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
className="template-card"
|
||||
onClick={() => onApplyTemplate(template)}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
disabled={template.disabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
border: `1px solid ${template.disabled ? 'var(--border)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: template.disabled ? 'var(--bg-tertiary)' : 'var(--bg-primary)',
|
||||
cursor: template.disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: template.disabled ? 0.6 : 1,
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!template.disabled) {
|
||||
e.target.style.borderColor = 'var(--accent)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!template.disabled) {
|
||||
e.target.style.borderColor = 'var(--border)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="template-preview">
|
||||
{template.elements.slice(0, 3).map((el, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="template-preview-element"
|
||||
style={{
|
||||
fontSize: el.type === 'text' && el.text.length === 1 ? '16px' : '8px',
|
||||
color: el.fill,
|
||||
}}
|
||||
>
|
||||
{el.text}
|
||||
</span>
|
||||
))}
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '24px',
|
||||
}}>
|
||||
{template.thumbnail}
|
||||
</div>
|
||||
<div className="template-info">
|
||||
<span className="template-name">{template.name}</span>
|
||||
<span className="template-category">{template.category}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
{template.name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
</div>
|
||||
{template.disabled && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,96 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const FONTS = [
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times New Roman', label: 'Times New Roman' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
{ value: 'Verdana', label: 'Verdana' },
|
||||
{ value: 'Courier New', label: 'Courier New' },
|
||||
{ value: 'Comic Sans MS', label: 'Comic Sans MS' },
|
||||
{ value: 'Impact', label: 'Impact' },
|
||||
];
|
||||
import { FONTS } from '../../constants/fonts';
|
||||
|
||||
export function TextTab({ onAddText }) {
|
||||
const [text, setText] = useState('Your Text');
|
||||
const [fontFamily, setFontFamily] = useState('Arial');
|
||||
const [fontSize, setFontSize] = useState(32);
|
||||
const [fill, setFill] = useState('#000000');
|
||||
const [text, setText] = useState('Your text here');
|
||||
const [fontFamily, setFontFamily] = useState('Roboto');
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
const [fill, setFill] = useState('#0f172a');
|
||||
|
||||
const handleAddText = () => {
|
||||
onAddText({
|
||||
type: 'text',
|
||||
x: 150,
|
||||
y: 150,
|
||||
text,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fill,
|
||||
x: 100,
|
||||
y: 100,
|
||||
rotation: 0,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-tab">
|
||||
<h3>Add Text</h3>
|
||||
<div className="text-input-group">
|
||||
<label>Text</label>
|
||||
<input
|
||||
type="text"
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
||||
Add Text
|
||||
</h3>
|
||||
|
||||
{/* Text input */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Text Content
|
||||
</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Enter text"
|
||||
className="text-input"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'var(--font-body)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-input-group">
|
||||
<label>Font</label>
|
||||
|
||||
{/* Font selector */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Font
|
||||
</label>
|
||||
<select
|
||||
value={fontFamily}
|
||||
onChange={(e) => setFontFamily(e.target.value)}
|
||||
className="font-select"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: '13px',
|
||||
fontFamily,
|
||||
cursor: 'pointer',
|
||||
background: 'var(--bg-primary)',
|
||||
}}
|
||||
>
|
||||
{FONTS.map((font) => (
|
||||
<option key={font.value} value={font.value}>
|
||||
{font.label}
|
||||
<option key={font.family} value={font.family}>
|
||||
{font.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-input-group">
|
||||
<label>Size: {fontSize}px</label>
|
||||
|
||||
{/* Font size */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Font Size: {fontSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
max="120"
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
className="size-slider"
|
||||
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-input-group">
|
||||
<label>Color</label>
|
||||
<div className="color-picker-row">
|
||||
|
||||
{/* Color picker */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Color
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={fill}
|
||||
onChange={(e) => setFill(e.target.value)}
|
||||
className="color-picker"
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={fill}
|
||||
onChange={(e) => setFill(e.target.value)}
|
||||
className="color-input"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
border: `1px solid var(--border)`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="add-text-btn" onClick={handleAddText}>
|
||||
Add Text
|
||||
</button>
|
||||
<div className="text-preview" style={{ fontFamily, fontSize, color: fill }}>
|
||||
{text}
|
||||
|
||||
{/* Preview */}
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'var(--bg-primary)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
marginBottom: '1rem',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily,
|
||||
fontSize: `${fontSize * 0.5}px`,
|
||||
color: fill,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Text button */}
|
||||
<button
|
||||
onClick={handleAddText}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.875rem',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--accent)',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = 'var(--accent-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'var(--accent)';
|
||||
}}
|
||||
>
|
||||
Add Text to Canvas
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { PhotoPreEditor } from '../editor/PhotoPreEditor';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export function UploadTab({ onUpload }) {
|
||||
const fileInputRef = useState(null)[0];
|
||||
export function UploadTab({ onAddImage }) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editingImage, setEditingImage] = useState(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFile = async (file) => {
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
const handleFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please upload a JPEG, PNG, or WebP image');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (20MB)
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
alert('File size must be under 20MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Open photo editor with uploaded image
|
||||
setEditingImage(data.original.url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleFile(file);
|
||||
const data = await response.json();
|
||||
|
||||
// Add the uploaded image to canvas (use preview for canvas)
|
||||
onAddImage({
|
||||
type: 'image',
|
||||
x: 75,
|
||||
y: 75,
|
||||
width: 150,
|
||||
height: 150,
|
||||
rotation: 0,
|
||||
src: data.preview.url,
|
||||
originalUrl: data.original.url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
alert('Failed to upload image. Please try again.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
@@ -44,61 +63,87 @@ export function UploadTab({ onUpload }) {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleEditorComplete = (editedImageUrl) => {
|
||||
onUpload({ preview: { url: editedImageUrl } });
|
||||
setEditingImage(null);
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleEditorClose = () => {
|
||||
setEditingImage(null);
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
if (editingImage) {
|
||||
return (
|
||||
<PhotoPreEditor
|
||||
imageSrc={editingImage}
|
||||
onComplete={handleEditorComplete}
|
||||
onClose={handleEditorClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const handleFileChange = (e) => {
|
||||
handleFiles(e.target.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="upload-tab">
|
||||
<h3>Upload Image</h3>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
||||
Upload Image
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={`upload-zone ${isDragging ? 'dragging' : ''} ${uploading ? 'uploading' : ''}`}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => fileInputRef?.click()}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `2px dashed ${isDragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: isDragging ? 'var(--accent-bg)' : 'var(--bg-primary)',
|
||||
transition: 'all 0.15s ease',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="uploading-state">
|
||||
<div className="spinner" />
|
||||
<p>Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="upload-icon">📁</div>
|
||||
<p>Drop image here or click to upload</p>
|
||||
<p className="upload-hint">PNG, JPG, WEBP up to 20MB</p>
|
||||
<p className="upload-hint" style={{ marginTop: '0.5rem' }}>
|
||||
Edit with crop, filters, and effects before adding to design
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFile(e.target.files[0])}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div style={{ fontSize: '32px', marginBottom: '0.5rem' }}>📁</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>
|
||||
Click to upload or drag and drop
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
|
||||
JPEG, PNG, WebP (max 20MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
<div style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--accent-bg)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--accent)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
Uploading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--bg-primary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
<strong>Tip:</strong> After uploading, you can remove the background from your image using the background removal tool.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
22
client/src/constants/fonts.js
Normal file
22
client/src/constants/fonts.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export const FONTS = [
|
||||
{ name: 'Roboto', family: 'Roboto' },
|
||||
{ name: 'Open Sans', family: 'Open Sans' },
|
||||
{ name: 'Lato', family: 'Lato' },
|
||||
{ name: 'Montserrat', family: 'Montserrat' },
|
||||
{ name: 'Oswald', family: 'Oswald' },
|
||||
{ name: 'Raleway', family: 'Raleway' },
|
||||
{ name: 'Poppins', family: 'Poppins' },
|
||||
{ name: 'Roboto Condensed', family: 'Roboto Condensed' },
|
||||
{ name: 'Source Sans 3', family: 'Source Sans 3' },
|
||||
{ name: 'Roboto Slab', family: 'Roboto Slab' },
|
||||
{ name: 'Merriweather', family: 'Merriweather' },
|
||||
{ name: 'Ubuntu', family: 'Ubuntu' },
|
||||
{ name: 'Playfair Display', family: 'Playfair Display' },
|
||||
{ name: 'Nunito', family: 'Nunito' },
|
||||
{ name: 'Rubik', family: 'Rubik' },
|
||||
{ name: 'Work Sans', family: 'Work Sans' },
|
||||
{ name: 'Lora', family: 'Lora' },
|
||||
{ name: 'Fira Sans', family: 'Fira Sans' },
|
||||
{ name: 'Barlow', family: 'Barlow' },
|
||||
{ name: 'Bebas Neue', family: 'Bebas Neue' },
|
||||
];
|
||||
159
client/src/constants/stickers.js
Normal file
159
client/src/constants/stickers.js
Normal file
@@ -0,0 +1,159 @@
|
||||
export const STICKER_CATEGORIES = ['all', 'faces', 'animals', 'food', 'sports', 'nature', 'objects'];
|
||||
|
||||
export const STICKERS = [
|
||||
// Faces
|
||||
{ emoji: '😀', category: 'faces' },
|
||||
{ emoji: '😁', category: 'faces' },
|
||||
{ emoji: '😂', category: 'faces' },
|
||||
{ emoji: '🤣', category: 'faces' },
|
||||
{ emoji: '😃', category: 'faces' },
|
||||
{ emoji: '😄', category: 'faces' },
|
||||
{ emoji: '😅', category: 'faces' },
|
||||
{ emoji: '😆', category: 'faces' },
|
||||
{ emoji: '😉', category: 'faces' },
|
||||
{ emoji: '😊', category: 'faces' },
|
||||
{ emoji: '😋', category: 'faces' },
|
||||
{ emoji: '😎', category: 'faces' },
|
||||
{ emoji: '😍', category: 'faces' },
|
||||
{ emoji: '😘', category: 'faces' },
|
||||
{ emoji: '🥰', category: 'faces' },
|
||||
{ emoji: '😗', category: 'faces' },
|
||||
{ emoji: '🤔', category: 'faces' },
|
||||
{ emoji: '🤨', category: 'faces' },
|
||||
{ emoji: '🧐', category: 'faces' },
|
||||
{ emoji: '🤓', category: 'faces' },
|
||||
{ emoji: '😈', category: 'faces' },
|
||||
{ emoji: '🤠', category: 'faces' },
|
||||
{ emoji: '🥳', category: 'faces' },
|
||||
{ emoji: '🤩', category: 'faces' },
|
||||
|
||||
// Animals
|
||||
{ emoji: '🐶', category: 'animals' },
|
||||
{ emoji: '🐱', category: 'animals' },
|
||||
{ emoji: '🐭', category: 'animals' },
|
||||
{ emoji: '🐹', category: 'animals' },
|
||||
{ emoji: '🐰', category: 'animals' },
|
||||
{ emoji: '🦊', category: 'animals' },
|
||||
{ emoji: '🐻', category: 'animals' },
|
||||
{ emoji: '🐼', category: 'animals' },
|
||||
{ emoji: '🐨', category: 'animals' },
|
||||
{ emoji: '🐯', category: 'animals' },
|
||||
{ emoji: '🦁', category: 'animals' },
|
||||
{ emoji: '🐮', category: 'animals' },
|
||||
{ emoji: '🐷', category: 'animals' },
|
||||
{ emoji: '🐸', category: 'animals' },
|
||||
{ emoji: '🐵', category: 'animals' },
|
||||
{ emoji: '🐔', category: 'animals' },
|
||||
{ emoji: '🐧', category: 'animals' },
|
||||
{ emoji: '🐦', category: 'animals' },
|
||||
{ emoji: '🦄', category: 'animals' },
|
||||
{ emoji: '🐝', category: 'animals' },
|
||||
{ emoji: '🦋', category: 'animals' },
|
||||
{ emoji: '🐌', category: 'animals' },
|
||||
{ emoji: '🐞', category: 'animals' },
|
||||
{ emoji: '🐢', category: 'animals' },
|
||||
|
||||
// Food
|
||||
{ emoji: '🍎', category: 'food' },
|
||||
{ emoji: '🍐', category: 'food' },
|
||||
{ emoji: '🍊', category: 'food' },
|
||||
{ emoji: '🍋', category: 'food' },
|
||||
{ emoji: '🍌', category: 'food' },
|
||||
{ emoji: '🍉', category: 'food' },
|
||||
{ emoji: '🍇', category: 'food' },
|
||||
{ emoji: '🍓', category: 'food' },
|
||||
{ emoji: '🍈', category: 'food' },
|
||||
{ emoji: '🍒', category: 'food' },
|
||||
{ emoji: '🍑', category: 'food' },
|
||||
{ emoji: '🍍', category: 'food' },
|
||||
{ emoji: '🥥', category: 'food' },
|
||||
{ emoji: '🥝', category: 'food' },
|
||||
{ emoji: '🍅', category: 'food' },
|
||||
{ emoji: '🥑', category: 'food' },
|
||||
{ emoji: '🍆', category: 'food' },
|
||||
{ emoji: '🥔', category: 'food' },
|
||||
{ emoji: '🥕', category: 'food' },
|
||||
{ emoji: '🌽', category: 'food' },
|
||||
{ emoji: '🍕', category: 'food' },
|
||||
{ emoji: '🍔', category: 'food' },
|
||||
{ emoji: '🍟', category: 'food' },
|
||||
{ emoji: '🌭', category: 'food' },
|
||||
|
||||
// Sports
|
||||
{ emoji: '⚽', category: 'sports' },
|
||||
{ emoji: '🏀', category: 'sports' },
|
||||
{ emoji: '🏈', category: 'sports' },
|
||||
{ emoji: '⚾', category: 'sports' },
|
||||
{ emoji: '🥎', category: 'sports' },
|
||||
{ emoji: '🎾', category: 'sports' },
|
||||
{ emoji: '🏐', category: 'sports' },
|
||||
{ emoji: '🏉', category: 'sports' },
|
||||
{ emoji: '🎱', category: 'sports' },
|
||||
{ emoji: '🏓', category: 'sports' },
|
||||
{ emoji: '🏸', category: 'sports' },
|
||||
{ emoji: '🥅', category: 'sports' },
|
||||
{ emoji: '⛳', category: 'sports' },
|
||||
{ emoji: '🥊', category: 'sports' },
|
||||
{ emoji: '🥋', category: 'sports' },
|
||||
{ emoji: '🎯', category: 'sports' },
|
||||
{ emoji: '⛹️', category: 'sports' },
|
||||
{ emoji: '🚴', category: 'sports' },
|
||||
{ emoji: '🏆', category: 'sports' },
|
||||
{ emoji: '🥇', category: 'sports' },
|
||||
{ emoji: '🥈', category: 'sports' },
|
||||
{ emoji: '🥉', category: 'sports' },
|
||||
{ emoji: '🏅', category: 'sports' },
|
||||
{ emoji: '🎖️', category: 'sports' },
|
||||
|
||||
// Nature
|
||||
{ emoji: '🌸', category: 'nature' },
|
||||
{ emoji: '💐', category: 'nature' },
|
||||
{ emoji: '🌹', category: 'nature' },
|
||||
{ emoji: '🌺', category: 'nature' },
|
||||
{ emoji: '🌻', category: 'nature' },
|
||||
{ emoji: '🌼', category: 'nature' },
|
||||
{ emoji: '🌷', category: 'nature' },
|
||||
{ emoji: '🌱', category: 'nature' },
|
||||
{ emoji: '🌲', category: 'nature' },
|
||||
{ emoji: '🌳', category: 'nature' },
|
||||
{ emoji: '🌴', category: 'nature' },
|
||||
{ emoji: '🌵', category: 'nature' },
|
||||
{ emoji: '🌾', category: 'nature' },
|
||||
{ emoji: '🌿', category: 'nature' },
|
||||
{ emoji: '☘️', category: 'nature' },
|
||||
{ emoji: '🍀', category: 'nature' },
|
||||
{ emoji: '🍁', category: 'nature' },
|
||||
{ emoji: '🍂', category: 'nature' },
|
||||
{ emoji: '🍃', category: 'nature' },
|
||||
{ emoji: '🌈', category: 'nature' },
|
||||
{ emoji: '☀️', category: 'nature' },
|
||||
{ emoji: '🌙', category: 'nature' },
|
||||
{ emoji: '⭐', category: 'nature' },
|
||||
{ emoji: '🔥', category: 'nature' },
|
||||
|
||||
// Objects
|
||||
{ emoji: '❤️', category: 'objects' },
|
||||
{ emoji: '💛', category: 'objects' },
|
||||
{ emoji: '💚', category: 'objects' },
|
||||
{ emoji: '💙', category: 'objects' },
|
||||
{ emoji: '💜', category: 'objects' },
|
||||
{ emoji: '🧡', category: 'objects' },
|
||||
{ emoji: '💔', category: 'objects' },
|
||||
{ emoji: '💯', category: 'objects' },
|
||||
{ emoji: '✨', category: 'objects' },
|
||||
{ emoji: '🌟', category: 'objects' },
|
||||
{ emoji: '💫', category: 'objects' },
|
||||
{ emoji: '🎵', category: 'objects' },
|
||||
{ emoji: '🎶', category: 'objects' },
|
||||
{ emoji: '🎸', category: 'objects' },
|
||||
{ emoji: '🎺', category: 'objects' },
|
||||
{ emoji: '🎷', category: 'objects' },
|
||||
{ emoji: '🎹', category: 'objects' },
|
||||
{ emoji: '👑', category: 'objects' },
|
||||
{ emoji: '💎', category: 'objects' },
|
||||
{ emoji: '🎁', category: 'objects' },
|
||||
{ emoji: '🎈', category: 'objects' },
|
||||
{ emoji: '🎉', category: 'objects' },
|
||||
{ emoji: '🎊', category: 'objects' },
|
||||
{ emoji: '🔮', category: 'objects' },
|
||||
];
|
||||
@@ -56,11 +56,54 @@ body {
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Three-column layout */
|
||||
.editor-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.properties-panel {
|
||||
width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
|
||||
Reference in New Issue
Block a user