diff --git a/client/package.json b/client/package.json
index a8a8a55..30b0b2b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -11,7 +11,10 @@
},
"dependencies": {
"react": "^19.2.5",
- "react-dom": "^19.2.5"
+ "react-dom": "^19.2.5",
+ "react-konva": "^18.2.10",
+ "konva": "^9.3.18",
+ "use-image": "^1.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 33fc69a..2148138 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -1,12 +1,82 @@
+import { useEffect, useCallback } from 'react';
+import { DesignCanvas } from './components/canvas/DesignCanvas';
+import { useDesignEditor } from './hooks/useDesignEditor';
+
function App() {
+ const {
+ elements,
+ selectedId,
+ addElement,
+ updateElement,
+ deleteElement,
+ selectElement,
+ deselectAll,
+ } = useDesignEditor();
+
+ // 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;
+ }
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ if (selectedId) {
+ deleteElement(selectedId);
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedId, deleteElement]);
+
+ // Test: Add sample image on mount (for Phase 2 testing)
+ useEffect(() => {
+ // Add a test image element
+ const testImageId = addElement({
+ type: 'image',
+ x: 75,
+ y: 75,
+ width: 150,
+ height: 150,
+ rotation: 0,
+ src: 'https://placehold.co/150x150/38bdf8/ffffff?text=Test',
+ });
+ console.log('Added test image with ID:', testImageId);
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
return (
Apparel Designer
-
+
T-shirt customization editor
-
-
Server Status: Checking...
+
+ {/* Canvas container */}
+
+
+
+
+ {/* Debug info */}
+
+
Elements: {elements.length}
+
Selected: {selectedId || 'None'}
+
+ Tip: Click to select, drag to move, use handles to resize. Press Delete to remove.
+
);
diff --git a/client/src/components/canvas/DesignCanvas.jsx b/client/src/components/canvas/DesignCanvas.jsx
new file mode 100644
index 0000000..0721d4e
--- /dev/null
+++ b/client/src/components/canvas/DesignCanvas.jsx
@@ -0,0 +1,95 @@
+import { Stage, Layer } from 'react-konva';
+import { TShirtSVG } from './TShirtSVG';
+import { ImageElement } from './ImageElement';
+import { TextElement } from './TextElement';
+
+const CANVAS_SIZE = 300;
+
+export function DesignCanvas({
+ elements,
+ selectedId,
+ onSelect,
+ onDeselect,
+ onUpdate,
+}) {
+ return (
+
+ {/* T-shirt SVG background */}
+
+
+ {/* Canvas Stage */}
+
+
+ {elements.map((el) => {
+ if (el.type === 'image') {
+ return (
+ onSelect(el.id)}
+ onUpdate={(attrs) => onUpdate(el.id, attrs)}
+ />
+ );
+ }
+ if (el.type === 'text') {
+ return (
+ onSelect(el.id)}
+ onUpdate={(attrs) => onUpdate(el.id, attrs)}
+ />
+ );
+ }
+ return null;
+ })}
+
+
+
+ {/* Canvas info bar */}
+
+ Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI
+
+
+ );
+}
diff --git a/client/src/components/canvas/ImageElement.jsx b/client/src/components/canvas/ImageElement.jsx
new file mode 100644
index 0000000..6d06b4d
--- /dev/null
+++ b/client/src/components/canvas/ImageElement.jsx
@@ -0,0 +1,98 @@
+import { useEffect, useState } from 'react';
+import { Image, Transformer } from 'react-konva';
+import useImage from 'use-image';
+
+function URLImage({ src, ...props }) {
+ const [img] = useImage(src, 'anonymous');
+ return
;
+}
+
+export function ImageElement({
+ id,
+ x,
+ y,
+ width,
+ height,
+ rotation,
+ src,
+ isSelected,
+ onSelect,
+ onUpdate,
+}) {
+ const shapeRef = null;
+ const trRef = null;
+
+ useEffect(() => {
+ if (isSelected && trRef.current) {
+ trRef.current.nodes([shapeRef.current]);
+ trRef.current.getLayer().batchDraw();
+ }
+ }, [isSelected]);
+
+ return (
+ <>
+
{
+ onUpdate({
+ x: e.target.x(),
+ y: e.target.y(),
+ });
+ }}
+ onTransformEnd={(e) => {
+ const node = shapeRef.current;
+ const scaleX = node.scaleX();
+ const scaleY = node.scaleY();
+ node.scaleX(1);
+ node.scaleY(1);
+ onUpdate({
+ x: node.x(),
+ y: node.y(),
+ width: Math.max(20, node.width() * scaleX),
+ height: Math.max(20, node.height() * scaleY),
+ rotation: node.rotation(),
+ });
+ }}
+ boundBoxFunc={(oldBox, newBox) => {
+ // Minimum size constraint
+ if (newBox.width < 20 || newBox.height < 20) {
+ return oldBox;
+ }
+ return newBox;
+ }}
+ />
+ {isSelected && (
+ {
+ // Limit resize to minimum size
+ if (newBox.width < 20 || newBox.height < 20) {
+ return oldBox;
+ }
+ return newBox;
+ }}
+ anchorSize={8}
+ anchorCornerRadius={4}
+ borderStroke="#38bdf8"
+ anchorStroke="#38bdf8"
+ anchorFill="#ffffff"
+ />
+ )}
+ >
+ );
+}
+
+ImageElement.defaultProps = {
+ width: 100,
+ height: 100,
+ rotation: 0,
+};
diff --git a/client/src/components/canvas/TShirtSVG.jsx b/client/src/components/canvas/TShirtSVG.jsx
new file mode 100644
index 0000000..2055ab0
--- /dev/null
+++ b/client/src/components/canvas/TShirtSVG.jsx
@@ -0,0 +1,59 @@
+export function TShirtSVG({ size = 300 }) {
+ const padding = size * 0.1;
+ const innerSize = size - padding * 2;
+
+ return (
+
+ );
+}
diff --git a/client/src/components/canvas/TextElement.jsx b/client/src/components/canvas/TextElement.jsx
new file mode 100644
index 0000000..742b9ff
--- /dev/null
+++ b/client/src/components/canvas/TextElement.jsx
@@ -0,0 +1,80 @@
+import { useEffect } from 'react';
+import { Text, Transformer } from 'react-konva';
+
+export function TextElement({
+ id,
+ x,
+ y,
+ text,
+ fontSize,
+ fontFamily,
+ fill,
+ rotation,
+ isSelected,
+ onSelect,
+ onUpdate,
+}) {
+ const textRef = null;
+ const trRef = null;
+
+ useEffect(() => {
+ if (isSelected && trRef.current) {
+ trRef.current.nodes([textRef.current]);
+ trRef.current.getLayer().batchDraw();
+ }
+ }, [isSelected]);
+
+ return (
+ <>
+ {
+ onUpdate({
+ x: e.target.x(),
+ y: e.target.y(),
+ });
+ }}
+ onTransformEnd={(e) => {
+ const node = textRef.current;
+ const scaleX = node.scaleX();
+ node.scaleX(1);
+ node.scaleY(1);
+ onUpdate({
+ x: node.x(),
+ y: node.y(),
+ fontSize: Math.max(12, node.fontSize() * scaleX),
+ rotation: node.rotation(),
+ });
+ }}
+ />
+ {isSelected && (
+
+ )}
+ >
+ );
+}
+
+TextElement.defaultProps = {
+ fontSize: 24,
+ fontFamily: 'DM Sans',
+ fill: '#0f172a',
+ rotation: 0,
+};
diff --git a/client/src/components/canvas/index.js b/client/src/components/canvas/index.js
new file mode 100644
index 0000000..a8628e2
--- /dev/null
+++ b/client/src/components/canvas/index.js
@@ -0,0 +1,4 @@
+export { DesignCanvas } from './DesignCanvas';
+export { TShirtSVG } from './TShirtSVG';
+export { ImageElement } from './ImageElement';
+export { TextElement } from './TextElement';
diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js
new file mode 100644
index 0000000..2c01557
--- /dev/null
+++ b/client/src/hooks/index.js
@@ -0,0 +1 @@
+export { useDesignEditor } from './useDesignEditor';
diff --git a/client/src/hooks/useDesignEditor.js b/client/src/hooks/useDesignEditor.js
new file mode 100644
index 0000000..5646759
--- /dev/null
+++ b/client/src/hooks/useDesignEditor.js
@@ -0,0 +1,59 @@
+import { useState, useCallback } from 'react';
+
+export function useDesignEditor() {
+ const [elements, setElements] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+
+ const addElement = useCallback((element) => {
+ const newElement = {
+ ...element,
+ id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ };
+ setElements((prev) => [...prev, newElement]);
+ setSelectedId(newElement.id);
+ return newElement.id;
+ }, []);
+
+ const updateElement = useCallback((id, attrs) => {
+ setElements((prev) =>
+ prev.map((el) => (el.id === id ? { ...el, ...attrs } : el))
+ );
+ }, []);
+
+ const deleteElement = useCallback((id) => {
+ setElements((prev) => prev.filter((el) => el.id !== id));
+ if (selectedId === id) {
+ setSelectedId(null);
+ }
+ }, [selectedId]);
+
+ const selectElement = useCallback((id) => {
+ setSelectedId(id);
+ }, []);
+
+ const deselectAll = useCallback(() => {
+ setSelectedId(null);
+ }, []);
+
+ const reorderElement = useCallback((id, newOrder) => {
+ setElements((prev) => {
+ const index = prev.findIndex((el) => el.id === id);
+ if (index === -1 || index === newOrder) return prev;
+ const newElements = [...prev];
+ const [removed] = newElements.splice(index, 1);
+ newElements.splice(newOrder, 0, removed);
+ return newElements;
+ });
+ }, []);
+
+ return {
+ elements,
+ selectedId,
+ addElement,
+ updateElement,
+ deleteElement,
+ selectElement,
+ deselectAll,
+ reorderElement,
+ };
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
index f818efc..f55f4f7 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -1,31 +1,10 @@
-import { StrictMode, useEffect, useState } from 'react'
+import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
-
-function AppWithHealth() {
- const [serverStatus, setServerStatus] = useState('Checking...');
-
- useEffect(() => {
- fetch('/api/health')
- .then(res => res.ok ? setServerStatus('Connected ✓') : setServerStatus('Error'))
- .catch(() => setServerStatus('Offline'));
- }, []);
-
- return (
-
-
Apparel Designer
-
- T-shirt customization editor
-
-
-
Server Status: {serverStatus}
-
-
- );
-}
+import App from './App'
createRoot(document.getElementById('root')).render(
-
+
,
)