Phase 2: Canvas Editor Core
Implements the core canvas editor with react-konva: - Added dependencies: react-konva, konva, use-image - DesignCanvas component: 300×300px Stage with T-shirt SVG overlay - TShirtSVG component: Visual t-shirt outline with print zone indicator - ImageElement: Draggable/resizable image with Transformer handles - TextElement: Draggable/resizable text with Transformer handles - useDesignEditor hook: Element CRUD, selection, reordering - Keyboard shortcut: Delete/Backspace removes selected element - Test image added on mount for Phase 2 verification Canvas info bar shows: "Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.5",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@@ -1,12 +1,82 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
||||||
|
import { useDesignEditor } from './hooks/useDesignEditor';
|
||||||
|
|
||||||
function App() {
|
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 (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
<h1>Apparel Designer</h1>
|
<h1>Apparel Designer</h1>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
|
||||||
T-shirt customization editor
|
T-shirt customization editor
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: '2rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)' }}>
|
|
||||||
<p>Server Status: <code id="server-status">Checking...</code></p>
|
{/* Canvas container */}
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<DesignCanvas
|
||||||
|
elements={elements}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={selectElement}
|
||||||
|
onDeselect={deselectAll}
|
||||||
|
onUpdate={updateElement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug info */}
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
maxWidth: '400px',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}>
|
||||||
|
<p>Elements: {elements.length}</p>
|
||||||
|
<p>Selected: {selectedId || 'None'}</p>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||||
|
Tip: Click to select, drag to move, use handles to resize. Press Delete to remove.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
95
client/src/components/canvas/DesignCanvas.jsx
Normal file
95
client/src/components/canvas/DesignCanvas.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
{/* T-shirt SVG background */}
|
||||||
|
<TShirtSVG size={CANVAS_SIZE} />
|
||||||
|
|
||||||
|
{/* Canvas Stage */}
|
||||||
|
<Stage
|
||||||
|
width={CANVAS_SIZE}
|
||||||
|
height={CANVAS_SIZE}
|
||||||
|
onClick={onDeselect}
|
||||||
|
onTap={onDeselect}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
border: selectedId ? '2px solid #38bdf8' : '1px dashed #cbd5e1',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layer>
|
||||||
|
{elements.map((el) => {
|
||||||
|
if (el.type === 'image') {
|
||||||
|
return (
|
||||||
|
<ImageElement
|
||||||
|
key={el.id}
|
||||||
|
id={el.id}
|
||||||
|
x={el.x}
|
||||||
|
y={el.y}
|
||||||
|
width={el.width}
|
||||||
|
height={el.height}
|
||||||
|
rotation={el.rotation}
|
||||||
|
src={el.src}
|
||||||
|
isSelected={el.id === selectedId}
|
||||||
|
onSelect={() => onSelect(el.id)}
|
||||||
|
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (el.type === 'text') {
|
||||||
|
return (
|
||||||
|
<TextElement
|
||||||
|
key={el.id}
|
||||||
|
id={el.id}
|
||||||
|
x={el.x}
|
||||||
|
y={el.y}
|
||||||
|
text={el.text}
|
||||||
|
fontSize={el.fontSize}
|
||||||
|
fontFamily={el.fontFamily}
|
||||||
|
fill={el.fill}
|
||||||
|
rotation={el.rotation}
|
||||||
|
isSelected={el.id === selectedId}
|
||||||
|
onSelect={() => onSelect(el.id)}
|
||||||
|
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Layer>
|
||||||
|
</Stage>
|
||||||
|
|
||||||
|
{/* Canvas info bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-40px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
client/src/components/canvas/ImageElement.jsx
Normal file
98
client/src/components/canvas/ImageElement.jsx
Normal file
@@ -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 <Image image={img} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<URLImage
|
||||||
|
ref={shapeRef}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
rotation={rotation}
|
||||||
|
src={src}
|
||||||
|
draggable
|
||||||
|
onClick={onSelect}
|
||||||
|
onTap={onSelect}
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
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 && (
|
||||||
|
<Transformer
|
||||||
|
ref={trRef}
|
||||||
|
boundBoxFunc={(oldBox, newBox) => {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
59
client/src/components/canvas/TShirtSVG.jsx
Normal file
59
client/src/components/canvas/TShirtSVG.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export function TShirtSVG({ size = 300 }) {
|
||||||
|
const padding = size * 0.1;
|
||||||
|
const innerSize = size - padding * 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* T-shirt outline */}
|
||||||
|
<path
|
||||||
|
d={`
|
||||||
|
M ${padding} ${padding + innerSize * 0.15}
|
||||||
|
L ${padding + innerSize * 0.15} ${padding}
|
||||||
|
L ${size - padding - innerSize * 0.15} ${padding}
|
||||||
|
L ${size - padding} ${padding + innerSize * 0.15}
|
||||||
|
L ${size - padding} ${size - padding}
|
||||||
|
L ${padding} ${size - padding}
|
||||||
|
Z
|
||||||
|
`}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4,4"
|
||||||
|
/>
|
||||||
|
{/* Chest area indicator (design zone) */}
|
||||||
|
<rect
|
||||||
|
x={size * 0.3}
|
||||||
|
y={size * 0.25}
|
||||||
|
width={size * 0.4}
|
||||||
|
height={size * 0.35}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
opacity="0.5"
|
||||||
|
/>
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={size / 2}
|
||||||
|
y={size * 0.45}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontSize="10"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
Print Zone
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
client/src/components/canvas/TextElement.jsx
Normal file
80
client/src/components/canvas/TextElement.jsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
ref={textRef}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
text={text}
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontFamily={fontFamily}
|
||||||
|
fill={fill}
|
||||||
|
rotation={rotation}
|
||||||
|
draggable
|
||||||
|
onClick={onSelect}
|
||||||
|
onTap={onSelect}
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
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 && (
|
||||||
|
<Transformer
|
||||||
|
ref={trRef}
|
||||||
|
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
|
||||||
|
anchorSize={8}
|
||||||
|
anchorCornerRadius={4}
|
||||||
|
borderStroke="#38bdf8"
|
||||||
|
anchorStroke="#38bdf8"
|
||||||
|
anchorFill="#ffffff"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextElement.defaultProps = {
|
||||||
|
fontSize: 24,
|
||||||
|
fontFamily: 'DM Sans',
|
||||||
|
fill: '#0f172a',
|
||||||
|
rotation: 0,
|
||||||
|
};
|
||||||
4
client/src/components/canvas/index.js
Normal file
4
client/src/components/canvas/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { DesignCanvas } from './DesignCanvas';
|
||||||
|
export { TShirtSVG } from './TShirtSVG';
|
||||||
|
export { ImageElement } from './ImageElement';
|
||||||
|
export { TextElement } from './TextElement';
|
||||||
1
client/src/hooks/index.js
Normal file
1
client/src/hooks/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useDesignEditor } from './useDesignEditor';
|
||||||
59
client/src/hooks/useDesignEditor.js
Normal file
59
client/src/hooks/useDesignEditor.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,31 +1,10 @@
|
|||||||
import { StrictMode, useEffect, useState } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
function AppWithHealth() {
|
|
||||||
const [serverStatus, setServerStatus] = useState('Checking...');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/health')
|
|
||||||
.then(res => res.ok ? setServerStatus('Connected ✓') : setServerStatus('Error'))
|
|
||||||
.catch(() => setServerStatus('Offline'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
||||||
<h1>Apparel Designer</h1>
|
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
T-shirt customization editor
|
|
||||||
</p>
|
|
||||||
<div style={{ marginTop: '2rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)' }}>
|
|
||||||
<p>Server Status: <code id="server-status">{serverStatus}</code></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppWithHealth />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user