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 ( + + {/* T-shirt outline */} + + {/* Chest area indicator (design zone) */} + + {/* Label */} + + Print Zone + + + ); +} 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( - + , )