First push
This commit is contained in:
1 parent
db25d6dbda
commit
7299578599
48 files changed
+17513
-1
No files matched your search
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "ui-mockup-generator-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^10.16.5",
|
||||
"html-to-image": "^1.11.11",
|
||||
"konva": "^9.2.3",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-redux": "^9.0.0",
|
||||
"recharts": "^2.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.2",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="UI Mockup Generator - Create UI mockups with JSON configuration for LLMs"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>UI Mockup Generator</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { ChakraProvider, Box, Flex } from '@chakra-ui/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
import theme from './theme';
|
||||
import Header from './components/common/Header';
|
||||
import Sidebar from './components/panels/Sidebar';
|
||||
import Canvas from './components/canvas/Canvas';
|
||||
import PropertyPanel from './components/panels/PropertyPanel';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ChakraProvider theme={theme}>
|
||||
<Box h="100vh" overflow="hidden">
|
||||
<Header />
|
||||
<Flex h="calc(100vh - 60px)">
|
||||
<Sidebar />
|
||||
<Canvas />
|
||||
<PropertyPanel />
|
||||
</Flex>
|
||||
</Box>
|
||||
</ChakraProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { Box, Center } from '@chakra-ui/react';
|
||||
import { Stage, Layer, Rect } from 'react-konva';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store';
|
||||
import { selectComponent, clearSelection } from '@/store/slices/selectionSlice';
|
||||
import ComponentRenderer from './ComponentRenderer';
|
||||
import useCanvasSize from '@/hooks/useCanvasSize';
|
||||
import useCanvasZoom from '@/hooks/useCanvasZoom';
|
||||
|
||||
const Canvas: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const stageRef = useRef<any>(null);
|
||||
const { mockup } = useSelector((state: RootState) => state);
|
||||
const { components, canvas } = mockup;
|
||||
|
||||
// Custom hooks for canvas operations
|
||||
const { containerSize, canvasContainerRef } = useCanvasSize();
|
||||
const { scale, position, handleWheel, handleDragMove } = useCanvasZoom();
|
||||
|
||||
// Handle background click (deselect all)
|
||||
const handleStageClick = useCallback((e: any) => {
|
||||
// If clicked on the stage background
|
||||
if (e.target === e.currentTarget) {
|
||||
dispatch(clearSelection());
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// Handle component selection
|
||||
const handleComponentClick = useCallback((id: string, e: any) => {
|
||||
e.cancelBubble = true; // Stop propagation to parent
|
||||
dispatch(selectComponent(id));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Center
|
||||
flex="1"
|
||||
bg="gray.100"
|
||||
overflow="hidden"
|
||||
ref={canvasContainerRef}
|
||||
position="relative"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
bottom="0"
|
||||
right="0"
|
||||
className="canvas-grid"
|
||||
/>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
onClick={handleStageClick}
|
||||
onWheel={handleWheel}
|
||||
draggable
|
||||
onDragMove={handleDragMove}
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
scaleX={scale}
|
||||
scaleY={scale}
|
||||
>
|
||||
<Layer>
|
||||
{/* Canvas background */}
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={canvas.width}
|
||||
height={canvas.height}
|
||||
fill={canvas.background}
|
||||
shadowColor="rgba(0,0,0,0.2)"
|
||||
shadowBlur={10}
|
||||
shadowOffsetX={0}
|
||||
shadowOffsetY={0}
|
||||
cornerRadius={4}
|
||||
/>
|
||||
|
||||
{/* Render components */}
|
||||
{components.map((component) => (
|
||||
<ComponentRenderer
|
||||
key={component.id}
|
||||
component={component}
|
||||
onClick={(e) => handleComponentClick(component.id, e)}
|
||||
/>
|
||||
))}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default Canvas;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Group } from 'react-konva';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store';
|
||||
import { Component } from '@/types/mockup';
|
||||
import ChartComponent from './components/ChartComponent';
|
||||
import FunnelComponent from './components/FunnelComponent';
|
||||
|
||||
interface ComponentRendererProps {
|
||||
component: Component;
|
||||
onClick?: (e: any) => void;
|
||||
}
|
||||
|
||||
const ComponentRenderer: React.FC<ComponentRendererProps> = ({ component, onClick }) => {
|
||||
const selection = useSelector((state: RootState) => state.selection);
|
||||
const isSelected = selection?.selectedComponentId === component.id;
|
||||
|
||||
// Common props for all component types
|
||||
const commonProps = {
|
||||
id: component.id,
|
||||
position: component.position,
|
||||
size: component.size,
|
||||
style: component.style,
|
||||
isSelected,
|
||||
onClick,
|
||||
};
|
||||
|
||||
// Only render components that we know exist
|
||||
switch (component.type) {
|
||||
case 'chart':
|
||||
return (
|
||||
<ChartComponent
|
||||
{...commonProps}
|
||||
data={component.data}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'funnel':
|
||||
return (
|
||||
<FunnelComponent
|
||||
{...commonProps}
|
||||
data={component.data}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// Draw a placeholder for other component types
|
||||
return (
|
||||
<Group
|
||||
x={component.position.x}
|
||||
y={component.position.y}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ComponentRenderer;
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import ComponentRenderer from '../ComponentRenderer';
|
||||
import { ChartData } from '@/types/mockup';
|
||||
|
||||
// Mock the components used by ComponentRenderer
|
||||
vi.mock('../components/ChartComponent', () => ({
|
||||
default: ({ id, data }: { id: string; data: ChartData }) => (
|
||||
<div data-testid="chart-component" data-id={id}>
|
||||
{data.title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/FunnelComponent', () => ({
|
||||
default: ({ id, data }: { id: string; data: any }) => (
|
||||
<div data-testid="funnel-component" data-id={id}>
|
||||
{data.steps ? data.steps.length : 0} steps
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Konva components
|
||||
vi.mock('react-konva', () => ({
|
||||
Group: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
|
||||
<div data-testid="konva-group" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ComponentRenderer', () => {
|
||||
let store: any;
|
||||
|
||||
beforeEach(() => {
|
||||
store = configureStore({
|
||||
reducer: {
|
||||
selection: (state = { selectedComponentId: 'chart-1' }) => state
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('renders chart component correctly', () => {
|
||||
const chartComponent = {
|
||||
id: 'chart-1',
|
||||
type: 'chart',
|
||||
position: { x: 10, y: 20 },
|
||||
size: { width: 400, height: 300 },
|
||||
data: {
|
||||
title: 'Test Chart',
|
||||
labels: ['Jan', 'Feb', 'Mar'],
|
||||
datasets: [
|
||||
{ label: 'Dataset 1', data: [10, 20, 30], backgroundColor: '#4caf50' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ComponentRenderer component={chartComponent} onClick={() => {}} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const chartElement = screen.getByTestId('chart-component');
|
||||
expect(chartElement).toBeTruthy();
|
||||
expect(chartElement.textContent).toBe('Test Chart');
|
||||
expect(chartElement.getAttribute('data-id')).toBe('chart-1');
|
||||
});
|
||||
|
||||
test('renders funnel component correctly', () => {
|
||||
const funnelComponent = {
|
||||
id: 'funnel-1',
|
||||
type: 'funnel',
|
||||
position: { x: 10, y: 20 },
|
||||
size: { width: 400, height: 300 },
|
||||
data: {
|
||||
steps: [
|
||||
{ label: 'Step 1', value: 1000, dropoff: 0 },
|
||||
{ label: 'Step 2', value: 600, dropoff: 40 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ComponentRenderer component={funnelComponent} onClick={() => {}} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const funnelElement = screen.getByTestId('funnel-component');
|
||||
expect(funnelElement).toBeTruthy();
|
||||
expect(funnelElement.textContent).toBe('2 steps');
|
||||
expect(funnelElement.getAttribute('data-id')).toBe('funnel-1');
|
||||
});
|
||||
|
||||
test('renders placeholder for unknown component types', () => {
|
||||
const unknownComponent = {
|
||||
id: 'unknown-1',
|
||||
type: 'unknown-type',
|
||||
position: { x: 10, y: 20 },
|
||||
size: { width: 400, height: 300 },
|
||||
data: {}
|
||||
};
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ComponentRenderer component={unknownComponent} onClick={() => {}} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('konva-group')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { Group, Rect, Line, Text } from 'react-konva';
|
||||
import { ChartComponent as ChartComponentType, ChartData, ChartOptions } from '@/types/mockup';
|
||||
|
||||
interface ChartComponentProps {
|
||||
id: string;
|
||||
position: ChartComponentType['position'];
|
||||
size?: ChartComponentType['size'];
|
||||
style?: ChartComponentType['style'];
|
||||
data: ChartData;
|
||||
isSelected?: boolean;
|
||||
onClick?: (e: any) => void;
|
||||
}
|
||||
|
||||
const ChartComponent: React.FC<ChartComponentProps> = ({
|
||||
size,
|
||||
data,
|
||||
style,
|
||||
isSelected,
|
||||
onClick
|
||||
}) => {
|
||||
if (!size) return null;
|
||||
|
||||
const { title, labels = [], datasets = [] } = data;
|
||||
const options: ChartOptions = (data as any).options || {};
|
||||
|
||||
// Extract colors from style or use defaults
|
||||
const backgroundColor = style?.backgroundColor || 'white';
|
||||
const colorList = datasets.map((dataset, index) => {
|
||||
if (Array.isArray(dataset.backgroundColor)) return dataset.backgroundColor[0];
|
||||
return dataset.backgroundColor || ['#4caf50', '#2196f3', '#ff9800', '#e91e63'][index % 4];
|
||||
});
|
||||
|
||||
// Currently these options aren't being used but could be in future enhancements
|
||||
// const {
|
||||
// showLegend = true,
|
||||
// showGrid = true,
|
||||
// showTooltip = true
|
||||
// } = options;
|
||||
|
||||
const padding = 40;
|
||||
const chartWidth = size.width - padding * 2;
|
||||
const chartHeight = size.height - padding * 2;
|
||||
|
||||
const renderBarChart = () => {
|
||||
const barWidth = chartWidth / (labels.length * 2);
|
||||
const allValues = datasets.flatMap(dataset => dataset.data);
|
||||
const maxValue = Math.max(...allValues, 1); // Prevent division by zero
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{/* X and Y axis */}
|
||||
<Line
|
||||
points={[padding, padding, padding, size.height - padding]}
|
||||
stroke="black"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Line
|
||||
points={[padding, size.height - padding, size.width - padding, size.height - padding]}
|
||||
stroke="black"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Render bars for each data point */}
|
||||
{datasets.map((dataset, datasetIndex) => (
|
||||
<Group key={datasetIndex}>
|
||||
{dataset.data.map((value, index) => {
|
||||
const barHeight = (value / maxValue) * chartHeight;
|
||||
const xPos = padding + index * (barWidth * 2) + datasetIndex * barWidth;
|
||||
const yPos = size.height - padding - barHeight;
|
||||
|
||||
const barColor = Array.isArray(dataset.backgroundColor)
|
||||
? dataset.backgroundColor[index % dataset.backgroundColor.length]
|
||||
: dataset.backgroundColor || colorList[datasetIndex];
|
||||
|
||||
return (
|
||||
<Group key={`bar-${datasetIndex}-${index}`}>
|
||||
<Rect
|
||||
x={xPos}
|
||||
y={yPos}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill={barColor as string}
|
||||
/>
|
||||
|
||||
{/* Display values on bars */}
|
||||
<Text
|
||||
text={value.toString()}
|
||||
x={xPos}
|
||||
y={yPos - 15}
|
||||
fontSize={style?.fontSize || 10}
|
||||
fontFamily={style?.fontFamily || 'Arial'}
|
||||
fill="black"
|
||||
width={barWidth}
|
||||
align="center"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{/* X axis labels */}
|
||||
{labels.map((label, index) => {
|
||||
const xPos = padding + index * (barWidth * 2);
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={`label-${index}`}
|
||||
text={label}
|
||||
x={xPos}
|
||||
y={size.height - padding + 5}
|
||||
fontSize={style?.fontSize || 10}
|
||||
fontFamily={style?.fontFamily || 'Arial'}
|
||||
fill="black"
|
||||
width={barWidth * 2}
|
||||
align="center"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group onClick={onClick}>
|
||||
{/* Chart background */}
|
||||
<Rect
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
fill={backgroundColor}
|
||||
cornerRadius={style?.borderRadius || 0}
|
||||
shadowColor="rgba(0,0,0,0.1)"
|
||||
shadowBlur={5}
|
||||
shadowOffsetY={2}
|
||||
/>
|
||||
|
||||
{/* Chart title */}
|
||||
{title && (
|
||||
<Text
|
||||
text={title}
|
||||
x={padding}
|
||||
y={10}
|
||||
fontSize={style?.fontSize ? style.fontSize + 2 : 14}
|
||||
fontFamily={style?.fontFamily || 'Arial'}
|
||||
fontStyle="bold"
|
||||
fill="black"
|
||||
width={size.width - padding * 2}
|
||||
align="center"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Render bar chart (we can add other chart types later) */}
|
||||
{renderBarChart()}
|
||||
|
||||
{/* Add selection indicator if selected */}
|
||||
{isSelected && (
|
||||
<Rect
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
stroke="#2196f3"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartComponent;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Group, Rect, Text } from 'react-konva';
|
||||
import { FunnelComponent as FunnelComponentType } from '@/types/mockup';
|
||||
|
||||
interface FunnelComponentProps {
|
||||
id: string;
|
||||
position: FunnelComponentType['position'];
|
||||
size?: FunnelComponentType['size'];
|
||||
style?: FunnelComponentType['style'];
|
||||
data: FunnelComponentType['data'];
|
||||
isSelected?: boolean;
|
||||
onClick?: (e: any) => void;
|
||||
}
|
||||
|
||||
const FunnelComponent: React.FC<FunnelComponentProps> = ({
|
||||
size,
|
||||
data,
|
||||
style,
|
||||
isSelected,
|
||||
onClick
|
||||
}) => {
|
||||
if (!size) return null;
|
||||
|
||||
const { steps, colors = {}, options = {} } = data;
|
||||
const {
|
||||
primary = '#1a2b47',
|
||||
secondary = '#e0756b',
|
||||
tertiary = '#d3d3d3',
|
||||
success = '#4caf50'
|
||||
} = colors;
|
||||
|
||||
const {
|
||||
showPercentages = true,
|
||||
showLabels = true,
|
||||
orientation = 'vertical'
|
||||
} = options;
|
||||
|
||||
const stepHeight = size.height / (steps.length + 1);
|
||||
|
||||
return (
|
||||
<Group onClick={onClick}>
|
||||
{/* Funnel background/container */}
|
||||
<Rect
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
fill={style?.backgroundColor || 'white'}
|
||||
cornerRadius={style?.borderRadius || 0}
|
||||
shadowColor="rgba(0,0,0,0.1)"
|
||||
shadowBlur={5}
|
||||
shadowOffsetY={2}
|
||||
/>
|
||||
|
||||
{/* Funnel steps */}
|
||||
{steps.map((step, index) => {
|
||||
const yPos = stepHeight * (index + 0.5);
|
||||
const stepWidth = size.width * (1 - (index * 0.15));
|
||||
const xPos = (size.width - stepWidth) / 2;
|
||||
const fillColor = step.conversion ? success : primary;
|
||||
|
||||
return (
|
||||
<Group key={index} y={yPos}>
|
||||
{/* Step bar */}
|
||||
<Rect
|
||||
x={xPos}
|
||||
width={stepWidth}
|
||||
height={stepHeight * 0.6}
|
||||
fill={fillColor}
|
||||
cornerRadius={4}
|
||||
/>
|
||||
|
||||
{/* Step label */}
|
||||
{showLabels && (
|
||||
<Text
|
||||
text={step.label}
|
||||
x={xPos + 10}
|
||||
y={10}
|
||||
fontSize={style?.fontSize || 12}
|
||||
fontFamily={style?.fontFamily || 'Arial'}
|
||||
fill="white"
|
||||
width={stepWidth - 20}
|
||||
align="left"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dropoff percentage */}
|
||||
{showPercentages && step.dropoff && (
|
||||
<Text
|
||||
text={`${step.dropoff}%`}
|
||||
x={xPos + stepWidth - 50}
|
||||
y={10}
|
||||
fontSize={style?.fontSize || 12}
|
||||
fontFamily={style?.fontFamily || 'Arial'}
|
||||
fill="white"
|
||||
width={40}
|
||||
align="right"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default FunnelComponent;
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import { Stage, Layer } from 'react-konva';
|
||||
import ChartComponent from '../ChartComponent';
|
||||
import { ChartData } from '@/types/mockup';
|
||||
|
||||
// Mock Konva as it doesn't work well in testing environments
|
||||
vi.mock('react-konva', () => {
|
||||
return {
|
||||
Stage: ({ children }: { children: React.ReactNode }) => <div data-testid="stage">{children}</div>,
|
||||
Layer: ({ children }: { children: React.ReactNode }) => <div data-testid="layer">{children}</div>,
|
||||
Group: ({ children }: { children: React.ReactNode }) => <div data-testid="group">{children}</div>,
|
||||
Rect: () => <div data-testid="rect"></div>,
|
||||
Line: () => <div data-testid="line"></div>,
|
||||
Text: ({ text }: { text: string }) => <div data-testid="text">{text}</div>,
|
||||
}
|
||||
});
|
||||
|
||||
describe('ChartComponent', () => {
|
||||
const mockChartData: ChartData = {
|
||||
title: 'Test Chart',
|
||||
labels: ['Jan', 'Feb', 'Mar'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Dataset 1',
|
||||
data: [10, 20, 30],
|
||||
backgroundColor: '#4caf50'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
id: 'chart-1',
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 400, height: 300 },
|
||||
style: { backgroundColor: 'white', fontSize: 12 },
|
||||
data: mockChartData,
|
||||
isSelected: false,
|
||||
onClick: vi.fn()
|
||||
};
|
||||
|
||||
test('renders chart with correct data', () => {
|
||||
render(
|
||||
<Stage>
|
||||
<Layer>
|
||||
<ChartComponent {...defaultProps} />
|
||||
</Layer>
|
||||
</Stage>
|
||||
);
|
||||
|
||||
// Check if chart title is rendered
|
||||
expect(screen.queryByText('Test Chart')).toBeTruthy();
|
||||
|
||||
// Check if all labels are rendered
|
||||
expect(screen.queryByText('Jan')).toBeTruthy();
|
||||
expect(screen.queryByText('Feb')).toBeTruthy();
|
||||
expect(screen.queryByText('Mar')).toBeTruthy();
|
||||
|
||||
// Check if data values are rendered
|
||||
expect(screen.queryByText('10')).toBeTruthy();
|
||||
expect(screen.queryByText('20')).toBeTruthy();
|
||||
expect(screen.queryByText('30')).toBeTruthy();
|
||||
|
||||
// Check chart structure - we can't use getAllByTestId with the mocked components
|
||||
const rects = document.querySelectorAll('[data-testid="rect"]');
|
||||
const lines = document.querySelectorAll('[data-testid="line"]');
|
||||
expect(rects.length).toBeGreaterThan(0);
|
||||
expect(lines.length).toBe(2); // X and Y axis
|
||||
});
|
||||
|
||||
test('handles undefined size gracefully', () => {
|
||||
const propsWithoutSize = { ...defaultProps, size: undefined };
|
||||
const { container } = render(
|
||||
<Stage>
|
||||
<Layer>
|
||||
<ChartComponent {...propsWithoutSize} />
|
||||
</Layer>
|
||||
</Stage>
|
||||
);
|
||||
|
||||
// Component should return null if size is undefined
|
||||
expect(container.querySelector('[data-testid="group"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('handles selection state correctly', () => {
|
||||
const selectedProps = { ...defaultProps, isSelected: true };
|
||||
render(
|
||||
<Stage>
|
||||
<Layer>
|
||||
<ChartComponent {...selectedProps} />
|
||||
</Layer>
|
||||
</Stage>
|
||||
);
|
||||
|
||||
// When selected, we should have an extra rect for the selection indicator
|
||||
const rects = document.querySelectorAll('[data-testid="rect"]');
|
||||
expect(rects.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Button,
|
||||
HStack,
|
||||
useDisclosure,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiSave,
|
||||
FiUpload,
|
||||
FiDownload,
|
||||
FiCode,
|
||||
FiSettings,
|
||||
FiChevronDown,
|
||||
FiUndo,
|
||||
FiRedo
|
||||
} from 'react-icons/fi';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { undo, redo } from '@/store/slices/historySlice';
|
||||
import ExportDialog from '../dialogs/ExportDialog';
|
||||
import ImportDialog from '../dialogs/ImportDialog';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
isOpen: isExportOpen,
|
||||
onOpen: onExportOpen,
|
||||
onClose: onExportClose
|
||||
} = useDisclosure();
|
||||
|
||||
const {
|
||||
isOpen: isImportOpen,
|
||||
onOpen: onImportOpen,
|
||||
onClose: onImportClose
|
||||
} = useDisclosure();
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="header"
|
||||
height="60px"
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.200"
|
||||
bg="white"
|
||||
px={4}
|
||||
>
|
||||
<Flex h="100%" alignItems="center" justifyContent="space-between">
|
||||
{/* Logo/Title */}
|
||||
<Heading size="md" color="brand.600">UI Mockup Generator</Heading>
|
||||
|
||||
{/* Main toolbar */}
|
||||
<HStack spacing={2}>
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo" placement="bottom">
|
||||
<IconButton
|
||||
aria-label="Undo"
|
||||
icon={<FiUndo />}
|
||||
variant="ghost"
|
||||
onClick={() => dispatch(undo())}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Redo" placement="bottom">
|
||||
<IconButton
|
||||
aria-label="Redo"
|
||||
icon={<FiRedo />}
|
||||
variant="ghost"
|
||||
onClick={() => dispatch(redo())}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* File operations */}
|
||||
<Menu>
|
||||
<MenuButton as={Button} rightIcon={<FiChevronDown />} variant="ghost">
|
||||
File
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem icon={<FiSave />} command="⌘S">
|
||||
Save Project
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FiUpload />} onClick={onImportOpen}>
|
||||
Import JSON
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FiDownload />} onClick={onExportOpen}>
|
||||
Export
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FiCode />}>
|
||||
View JSON
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip label="Settings" placement="bottom">
|
||||
<IconButton
|
||||
aria-label="Settings"
|
||||
icon={<FiSettings />}
|
||||
variant="ghost"
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Export Dialog */}
|
||||
<ExportDialog isOpen={isExportOpen} onClose={onExportClose} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
<ImportDialog isOpen={isImportOpen} onClose={onImportClose} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,411 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Heading,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
Button,
|
||||
HStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { RootState } from '@/store';
|
||||
import { updateComponent, removeComponent } from '@/store/slices/mockupSlice';
|
||||
import { Component } from '@/types/mockup';
|
||||
|
||||
const PropertyPanel: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { activeId } = useSelector((state: RootState) => state.selection);
|
||||
const { components } = useSelector((state: RootState) => state.mockup);
|
||||
|
||||
// Find the active component
|
||||
const activeComponent = activeId
|
||||
? components.find(component => component.id === activeId)
|
||||
: null;
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// Handle position change
|
||||
const handlePositionChange = (axis: 'x' | 'y', value: number) => {
|
||||
if (!activeComponent) return;
|
||||
|
||||
dispatch(updateComponent({
|
||||
id: activeComponent.id,
|
||||
changes: {
|
||||
position: {
|
||||
...activeComponent.position,
|
||||
[axis]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle size change
|
||||
const handleSizeChange = (dimension: 'width' | 'height', value: number) => {
|
||||
if (!activeComponent || !activeComponent.size) return;
|
||||
|
||||
dispatch(updateComponent({
|
||||
id: activeComponent.id,
|
||||
changes: {
|
||||
size: {
|
||||
...activeComponent.size,
|
||||
[dimension]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle style change
|
||||
const handleStyleChange = (property: string, value: any) => {
|
||||
if (!activeComponent) return;
|
||||
|
||||
dispatch(updateComponent({
|
||||
id: activeComponent.id,
|
||||
changes: {
|
||||
style: {
|
||||
...(activeComponent.style || {}),
|
||||
[property]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle text content change
|
||||
const handleContentChange = (content: string) => {
|
||||
if (!activeComponent || activeComponent.type !== 'text') return;
|
||||
|
||||
dispatch(updateComponent({
|
||||
id: activeComponent.id,
|
||||
changes: {
|
||||
data: {
|
||||
...activeComponent.data,
|
||||
content,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle delete component
|
||||
const handleDeleteComponent = () => {
|
||||
if (!activeComponent) return;
|
||||
dispatch(removeComponent(activeComponent.id));
|
||||
};
|
||||
|
||||
// Render component-specific properties
|
||||
const renderComponentProperties = (component: Component) => {
|
||||
switch (component.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<FormControl mt={4}>
|
||||
<FormLabel>Text Content</FormLabel>
|
||||
<Input
|
||||
value={(component.data as any)?.content || ''}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
case 'funnel':
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="sm" mt={4} mb={2}>Funnel Settings</Heading>
|
||||
<Text fontSize="sm">Funnel properties editor would go here</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'chart':
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="sm" mt={4} mb={2}>Chart Settings</Heading>
|
||||
<Text fontSize="sm">Chart properties editor would go here</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="sm" mt={4} mb={2}>Card Settings</Heading>
|
||||
<Text fontSize="sm">Card properties editor would go here</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// If no component is selected
|
||||
if (!activeComponent) {
|
||||
return (
|
||||
<Box
|
||||
w="300px"
|
||||
h="100%"
|
||||
borderLeft="1px solid"
|
||||
borderColor={borderColor}
|
||||
bg={bgColor}
|
||||
p={4}
|
||||
>
|
||||
<Text color="gray.500">Select a component to edit its properties</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
w="300px"
|
||||
h="100%"
|
||||
borderLeft="1px solid"
|
||||
borderColor={borderColor}
|
||||
bg={bgColor}
|
||||
overflowY="auto"
|
||||
>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Properties</Tab>
|
||||
<Tab>Style</Tab>
|
||||
<Tab>Data</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Properties Panel */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Heading size="sm">
|
||||
{activeComponent.type.charAt(0).toUpperCase() + activeComponent.type.slice(1)} Properties
|
||||
</Heading>
|
||||
|
||||
{/* Position */}
|
||||
<Accordion defaultIndex={[0]} allowMultiple>
|
||||
<AccordionItem>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Text fontWeight="bold">Position</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<FormControl>
|
||||
<FormLabel>X Position</FormLabel>
|
||||
<NumberInput
|
||||
value={activeComponent.position.x}
|
||||
onChange={(_, val) => handlePositionChange('x', val)}
|
||||
min={0}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
|
||||
<FormControl mt={2}>
|
||||
<FormLabel>Y Position</FormLabel>
|
||||
<NumberInput
|
||||
value={activeComponent.position.y}
|
||||
onChange={(_, val) => handlePositionChange('y', val)}
|
||||
min={0}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Size */}
|
||||
{activeComponent.size && (
|
||||
<AccordionItem>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Text fontWeight="bold">Size</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Width</FormLabel>
|
||||
<NumberInput
|
||||
value={activeComponent.size.width}
|
||||
onChange={(_, val) => handleSizeChange('width', val)}
|
||||
min={10}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
|
||||
<FormControl mt={2}>
|
||||
<FormLabel>Height</FormLabel>
|
||||
<NumberInput
|
||||
value={activeComponent.size.height}
|
||||
onChange={(_, val) => handleSizeChange('height', val)}
|
||||
min={10}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Component-specific properties */}
|
||||
<AccordionItem>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Text fontWeight="bold">Content</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
{renderComponentProperties(activeComponent)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Delete button */}
|
||||
<Box mt={4}>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
size="sm"
|
||||
onClick={handleDeleteComponent}
|
||||
width="100%"
|
||||
>
|
||||
Delete Component
|
||||
</Button>
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Style Panel */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Heading size="sm">Style Properties</Heading>
|
||||
|
||||
{activeComponent.style && (
|
||||
<Accordion defaultIndex={[0]} allowMultiple>
|
||||
{/* Color */}
|
||||
<AccordionItem>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Text fontWeight="bold">Colors</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
{/* Background Color */}
|
||||
<FormControl mt={2}>
|
||||
<FormLabel>Background Color</FormLabel>
|
||||
<Box mb={2}>
|
||||
<HexColorPicker
|
||||
color={activeComponent.style.backgroundColor || '#ffffff'}
|
||||
onChange={(color) => handleStyleChange('backgroundColor', color)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
<Input
|
||||
value={activeComponent.style.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => handleStyleChange('backgroundColor', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Text Color */}
|
||||
<FormControl mt={4}>
|
||||
<FormLabel>Text Color</FormLabel>
|
||||
<Box mb={2}>
|
||||
<HexColorPicker
|
||||
color={activeComponent.style.color || '#000000'}
|
||||
onChange={(color) => handleStyleChange('color', color)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
<Input
|
||||
value={activeComponent.style.color || '#000000'}
|
||||
onChange={(e) => handleStyleChange('color', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Typography */}
|
||||
<AccordionItem>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Text fontWeight="bold">Typography</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
{/* Font Size */}
|
||||
<FormControl>
|
||||
<FormLabel>Font Size</FormLabel>
|
||||
<NumberInput
|
||||
value={activeComponent.style.fontSize || 16}
|
||||
onChange={(_, val) => handleStyleChange('fontSize', val)}
|
||||
min={8}
|
||||
max={72}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
|
||||
{/* Font Family */}
|
||||
<FormControl mt={2}>
|
||||
<FormLabel>Font Family</FormLabel>
|
||||
<Input
|
||||
value={activeComponent.style.fontFamily || 'Inter'}
|
||||
onChange={(e) => handleStyleChange('fontFamily', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Data Panel */}
|
||||
<TabPanel>
|
||||
<Text>Data properties would go here</Text>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyPanel;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Heading,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store';
|
||||
import { addComponent } from '@/store/slices/mockupSlice';
|
||||
import { setActiveTool } from '@/store/slices/uiSlice';
|
||||
import { FiLayout, FiType, FiBarChart2, FiBox, FiImage, FiSquare } from 'react-icons/fi';
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const activeTool = useSelector((state: RootState) => state.ui.activeTool);
|
||||
const { sidebarWidth } = useSelector((state: RootState) => state.ui);
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// Handle component creation
|
||||
const handleAddComponent = (type: string) => {
|
||||
let newComponent: any = {
|
||||
type,
|
||||
position: { x: 100, y: 100 },
|
||||
};
|
||||
|
||||
// Add type-specific properties
|
||||
switch (type) {
|
||||
case 'funnel':
|
||||
newComponent = {
|
||||
...newComponent,
|
||||
size: { width: 300, height: 400 },
|
||||
data: {
|
||||
steps: [
|
||||
{ label: 'Step 1', value: 100, dropoff: 20 },
|
||||
{ label: 'Step 2', value: 80, dropoff: 25 },
|
||||
{ label: 'Step 3', value: 60, conversion: true },
|
||||
],
|
||||
options: {
|
||||
showPercentages: true,
|
||||
showLabels: true,
|
||||
orientation: 'vertical',
|
||||
},
|
||||
colors: {
|
||||
primary: '#1a2b47',
|
||||
secondary: '#e0756b',
|
||||
tertiary: '#d3d3d3',
|
||||
success: '#4caf50',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'chart':
|
||||
newComponent = {
|
||||
...newComponent,
|
||||
size: { width: 300, height: 300 },
|
||||
chartType: 'bar',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D', 'E'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Dataset 1',
|
||||
data: [12, 19, 3, 5, 2],
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'text':
|
||||
newComponent = {
|
||||
...newComponent,
|
||||
size: { width: 200, height: 50 },
|
||||
data: {
|
||||
content: 'Text Content',
|
||||
variant: 'body1',
|
||||
},
|
||||
style: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
color: '#333333',
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'card':
|
||||
newComponent = {
|
||||
...newComponent,
|
||||
size: { width: 300, height: 200 },
|
||||
data: {
|
||||
header: {
|
||||
title: 'Card Title',
|
||||
},
|
||||
body: {
|
||||
content: 'Card content goes here',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
dispatch(addComponent(newComponent));
|
||||
};
|
||||
|
||||
// Tool buttons for the sidebar
|
||||
const tools = [
|
||||
{ id: 'select', label: 'Select', icon: FiSquare },
|
||||
{ id: 'text', label: 'Text', icon: FiType },
|
||||
{ id: 'funnel', label: 'Funnel', icon: FiLayout },
|
||||
{ id: 'chart', label: 'Chart', icon: FiBarChart2 },
|
||||
{ id: 'card', label: 'Card', icon: FiBox },
|
||||
{ id: 'image', label: 'Image', icon: FiImage },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
w={`${sidebarWidth}px`}
|
||||
h="100%"
|
||||
borderRight="1px solid"
|
||||
borderColor={borderColor}
|
||||
bg={bgColor}
|
||||
overflowY="auto"
|
||||
>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Components</Tab>
|
||||
<Tab>Styles</Tab>
|
||||
<Tab>Data</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Components Panel */}
|
||||
<TabPanel p={3}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Tools</Heading>
|
||||
<SimpleGrid columns={3} spacing={2}>
|
||||
{tools.map((tool) => (
|
||||
<Button
|
||||
key={tool.id}
|
||||
variant={activeTool === tool.id ? 'solid' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => dispatch(setActiveTool(tool.id))}
|
||||
colorScheme={activeTool === tool.id ? 'brand' : 'gray'}
|
||||
h="60px"
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon as={tool.icon} mb={1} />
|
||||
<Text fontSize="xs">{tool.label}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Insert</Heading>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Button
|
||||
size="sm"
|
||||
justifyContent="flex-start"
|
||||
leftIcon={<Icon as={FiLayout} />}
|
||||
onClick={() => handleAddComponent('funnel')}
|
||||
>
|
||||
Funnel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
justifyContent="flex-start"
|
||||
leftIcon={<Icon as={FiBarChart2} />}
|
||||
onClick={() => handleAddComponent('chart')}
|
||||
>
|
||||
Chart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
justifyContent="flex-start"
|
||||
leftIcon={<Icon as={FiType} />}
|
||||
onClick={() => handleAddComponent('text')}
|
||||
>
|
||||
Text
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
justifyContent="flex-start"
|
||||
leftIcon={<Icon as={FiBox} />}
|
||||
onClick={() => handleAddComponent('card')}
|
||||
>
|
||||
Card
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Styles Panel */}
|
||||
<TabPanel>
|
||||
<Text>Style options go here</Text>
|
||||
</TabPanel>
|
||||
|
||||
{/* Data Panel */}
|
||||
<TabPanel>
|
||||
<Text>Data options go here</Text>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface ContainerSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SIZE = { width: 100, height: 100 };
|
||||
|
||||
/**
|
||||
* Custom hook to handle canvas container resizing
|
||||
*/
|
||||
const useCanvasSize = () => {
|
||||
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerSize, setContainerSize] = useState<ContainerSize>(DEFAULT_SIZE);
|
||||
|
||||
const updateSize = useCallback(() => {
|
||||
if (canvasContainerRef.current) {
|
||||
const { width, height } = canvasContainerRef.current.getBoundingClientRect();
|
||||
setContainerSize({ width, height });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update size on resize
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', updateSize);
|
||||
|
||||
// Initial size calculation
|
||||
updateSize();
|
||||
|
||||
// Additional timeout to handle initial render edge cases
|
||||
const timeout = setTimeout(updateSize, 100);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSize);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [updateSize]);
|
||||
|
||||
return { containerSize, canvasContainerRef, updateSize };
|
||||
};
|
||||
|
||||
export default useCanvasSize;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
const MAX_SCALE = 5;
|
||||
const SCALE_FACTOR = 1.1;
|
||||
|
||||
/**
|
||||
* Custom hook to handle canvas zooming and panning
|
||||
*/
|
||||
const useCanvasZoom = () => {
|
||||
const [scale, setScale] = useState<number>(1);
|
||||
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
|
||||
|
||||
// Handle wheel event for zooming
|
||||
const handleWheel = useCallback((e: any) => {
|
||||
e.evt.preventDefault();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
const oldScale = scale;
|
||||
const pointer = stage.getPointerPosition();
|
||||
const mousePointTo = {
|
||||
x: (pointer.x - position.x) / oldScale,
|
||||
y: (pointer.y - position.y) / oldScale,
|
||||
};
|
||||
|
||||
// Calculate new scale
|
||||
const newScale = e.evt.deltaY < 0 ?
|
||||
Math.min(oldScale * SCALE_FACTOR, MAX_SCALE) :
|
||||
Math.max(oldScale / SCALE_FACTOR, MIN_SCALE);
|
||||
|
||||
// Calculate new position that keeps the point under the mouse in the same position
|
||||
const newPos = {
|
||||
x: pointer.x - mousePointTo.x * newScale,
|
||||
y: pointer.y - mousePointTo.y * newScale,
|
||||
};
|
||||
|
||||
setScale(newScale);
|
||||
setPosition(newPos);
|
||||
}, [scale, position]);
|
||||
|
||||
// Handle drag move for panning
|
||||
const handleDragMove = useCallback((e: any) => {
|
||||
setPosition({
|
||||
x: e.target.x(),
|
||||
y: e.target.y(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset zoom and position
|
||||
const resetView = useCallback(() => {
|
||||
setScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
scale,
|
||||
position,
|
||||
handleWheel,
|
||||
handleDragMove,
|
||||
resetView,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCanvasZoom;
|
||||
@@ -0,0 +1,67 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Canvas grid background */
|
||||
.canvas-grid {
|
||||
background-size: 40px 40px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.no-select {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import mockupReducer from './slices/mockupSlice';
|
||||
import selectionReducer from './slices/selectionSlice';
|
||||
import historyReducer from './slices/historySlice';
|
||||
import uiReducer from './slices/uiSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
mockup: mockupReducer,
|
||||
selection: selectionReducer,
|
||||
history: historyReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
// Ignore these action types for serializability checks
|
||||
ignoredActions: ['history/push', 'history/undo', 'history/redo'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { HistoryState, Mockup } from '@/types/mockup';
|
||||
|
||||
const initialState: HistoryState = {
|
||||
past: [],
|
||||
current: {
|
||||
version: '1.0',
|
||||
canvas: {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
background: '#f5f5f5',
|
||||
gridSize: 8,
|
||||
showGrid: true,
|
||||
},
|
||||
components: [],
|
||||
},
|
||||
future: [],
|
||||
};
|
||||
|
||||
const MAX_HISTORY_LENGTH = 30;
|
||||
|
||||
export const historySlice = createSlice({
|
||||
name: 'history',
|
||||
initialState,
|
||||
reducers: {
|
||||
push: (state, action: PayloadAction<Mockup>) => {
|
||||
return {
|
||||
past: [...state.past, state.current].slice(-MAX_HISTORY_LENGTH),
|
||||
current: action.payload,
|
||||
future: [],
|
||||
};
|
||||
},
|
||||
undo: (state) => {
|
||||
if (state.past.length === 0) return state;
|
||||
|
||||
const previous = state.past[state.past.length - 1];
|
||||
const newPast = state.past.slice(0, state.past.length - 1);
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
current: previous,
|
||||
future: [state.current, ...state.future],
|
||||
};
|
||||
},
|
||||
redo: (state) => {
|
||||
if (state.future.length === 0) return state;
|
||||
|
||||
const next = state.future[0];
|
||||
const newFuture = state.future.slice(1);
|
||||
|
||||
return {
|
||||
past: [...state.past, state.current],
|
||||
current: next,
|
||||
future: newFuture,
|
||||
};
|
||||
},
|
||||
clear: () => {
|
||||
return initialState;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { push, undo, redo, clear } = historySlice.actions;
|
||||
|
||||
export default historySlice.reducer;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Mockup, Component } from '@/types/mockup';
|
||||
|
||||
const initialState: Mockup = {
|
||||
version: '1.0',
|
||||
canvas: {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
background: '#f5f5f5',
|
||||
gridSize: 8,
|
||||
showGrid: true,
|
||||
},
|
||||
components: [],
|
||||
};
|
||||
|
||||
export const mockupSlice = createSlice({
|
||||
name: 'mockup',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMockup: (state, action: PayloadAction<Mockup>) => {
|
||||
return action.payload;
|
||||
},
|
||||
updateCanvas: (state, action: PayloadAction<Partial<Mockup['canvas']>>) => {
|
||||
state.canvas = { ...state.canvas, ...action.payload };
|
||||
},
|
||||
addComponent: (state, action: PayloadAction<Omit<Component, 'id'>>) => {
|
||||
const component = { ...action.payload, id: uuidv4() };
|
||||
state.components.push(component as Component);
|
||||
},
|
||||
updateComponent: (state, action: PayloadAction<{ id: string; changes: Partial<Component> }>) => {
|
||||
const { id, changes } = action.payload;
|
||||
const index = state.components.findIndex(component => component.id === id);
|
||||
if (index !== -1) {
|
||||
state.components[index] = { ...state.components[index], ...changes };
|
||||
}
|
||||
},
|
||||
removeComponent: (state, action: PayloadAction<string>) => {
|
||||
state.components = state.components.filter(component => component.id !== action.payload);
|
||||
},
|
||||
moveComponent: (state, action: PayloadAction<{ id: string; position: { x: number; y: number } }>) => {
|
||||
const { id, position } = action.payload;
|
||||
const index = state.components.findIndex(component => component.id === id);
|
||||
if (index !== -1) {
|
||||
state.components[index].position = {
|
||||
...state.components[index].position,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
};
|
||||
}
|
||||
},
|
||||
resizeComponent: (state, action: PayloadAction<{ id: string; size: { width: number; height: number } }>) => {
|
||||
const { id, size } = action.payload;
|
||||
const index = state.components.findIndex(component => component.id === id);
|
||||
if (index !== -1 && state.components[index].size) {
|
||||
state.components[index].size = {
|
||||
...state.components[index].size,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
}
|
||||
},
|
||||
reorderComponents: (state, action: PayloadAction<{ id: string; newIndex: number }>) => {
|
||||
const { id, newIndex } = action.payload;
|
||||
const currentIndex = state.components.findIndex(component => component.id === id);
|
||||
if (currentIndex !== -1 && newIndex >= 0 && newIndex < state.components.length) {
|
||||
const [removed] = state.components.splice(currentIndex, 1);
|
||||
state.components.splice(newIndex, 0, removed);
|
||||
}
|
||||
},
|
||||
clearMockup: (state) => {
|
||||
state.components = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setMockup,
|
||||
updateCanvas,
|
||||
addComponent,
|
||||
updateComponent,
|
||||
removeComponent,
|
||||
moveComponent,
|
||||
resizeComponent,
|
||||
reorderComponents,
|
||||
clearMockup,
|
||||
} = mockupSlice.actions;
|
||||
|
||||
export default mockupSlice.reducer;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { SelectionState } from '@/types/mockup';
|
||||
|
||||
const initialState: SelectionState = {
|
||||
selectedIds: [],
|
||||
activeId: null,
|
||||
};
|
||||
|
||||
export const selectionSlice = createSlice({
|
||||
name: 'selection',
|
||||
initialState,
|
||||
reducers: {
|
||||
selectComponent: (state, action: PayloadAction<string>) => {
|
||||
state.selectedIds = [action.payload];
|
||||
state.activeId = action.payload;
|
||||
},
|
||||
selectAdditionalComponent: (state, action: PayloadAction<string>) => {
|
||||
if (!state.selectedIds.includes(action.payload)) {
|
||||
state.selectedIds.push(action.payload);
|
||||
}
|
||||
state.activeId = action.payload;
|
||||
},
|
||||
deselectComponent: (state, action: PayloadAction<string>) => {
|
||||
state.selectedIds = state.selectedIds.filter(id => id !== action.payload);
|
||||
if (state.activeId === action.payload) {
|
||||
state.activeId = state.selectedIds.length > 0 ? state.selectedIds[0] : null;
|
||||
}
|
||||
},
|
||||
setActiveComponent: (state, action: PayloadAction<string | null>) => {
|
||||
state.activeId = action.payload;
|
||||
},
|
||||
clearSelection: (state) => {
|
||||
state.selectedIds = [];
|
||||
state.activeId = null;
|
||||
},
|
||||
selectMultipleComponents: (state, action: PayloadAction<string[]>) => {
|
||||
state.selectedIds = action.payload;
|
||||
state.activeId = action.payload.length > 0 ? action.payload[0] : null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
selectComponent,
|
||||
selectAdditionalComponent,
|
||||
deselectComponent,
|
||||
setActiveComponent,
|
||||
clearSelection,
|
||||
selectMultipleComponents,
|
||||
} = selectionSlice.actions;
|
||||
|
||||
export default selectionSlice.reducer;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { UIState } from '@/types/mockup';
|
||||
|
||||
const initialState: UIState = {
|
||||
activeTool: 'select',
|
||||
activePanel: 'components',
|
||||
sidebarWidth: 260,
|
||||
colorPalette: [
|
||||
'#1a2b47', // Dark blue
|
||||
'#2563eb', // Blue
|
||||
'#4ade80', // Green
|
||||
'#f59e0b', // Yellow
|
||||
'#ef4444', // Red
|
||||
'#8b5cf6', // Purple
|
||||
'#ec4899', // Pink
|
||||
'#0d9488', // Teal
|
||||
'#64748b', // Gray blue
|
||||
'#0f172a', // Dark gray
|
||||
'#ffffff', // White
|
||||
'#000000', // Black
|
||||
],
|
||||
theme: 'light',
|
||||
};
|
||||
|
||||
export const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveTool: (state, action: PayloadAction<string>) => {
|
||||
state.activeTool = action.payload;
|
||||
},
|
||||
setActivePanel: (state, action: PayloadAction<string>) => {
|
||||
state.activePanel = action.payload;
|
||||
},
|
||||
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||
state.sidebarWidth = action.payload;
|
||||
},
|
||||
addColorToPalette: (state, action: PayloadAction<string>) => {
|
||||
if (!state.colorPalette.includes(action.payload)) {
|
||||
state.colorPalette.push(action.payload);
|
||||
}
|
||||
},
|
||||
removeColorFromPalette: (state, action: PayloadAction<string>) => {
|
||||
state.colorPalette = state.colorPalette.filter(color => color !== action.payload);
|
||||
},
|
||||
setTheme: (state, action: PayloadAction<'light' | 'dark'>) => {
|
||||
state.theme = action.payload;
|
||||
},
|
||||
resetUI: () => {
|
||||
return initialState;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTool,
|
||||
setActivePanel,
|
||||
setSidebarWidth,
|
||||
addColorToPalette,
|
||||
removeColorFromPalette,
|
||||
setTheme,
|
||||
resetUI,
|
||||
} = uiSlice.actions;
|
||||
|
||||
export default uiSlice.reducer;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import matchers from '@testing-library/jest-dom/matchers';
|
||||
|
||||
// Extend Vitest's expect with testing-library matchers
|
||||
expect.extend(matchers);
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
|
||||
|
||||
// Define the color mode config
|
||||
const config: ThemeConfig = {
|
||||
initialColorMode: 'light',
|
||||
useSystemColorMode: false,
|
||||
};
|
||||
|
||||
// Define custom colors
|
||||
const colors = {
|
||||
brand: {
|
||||
50: '#e6f6ff',
|
||||
100: '#bae3ff',
|
||||
200: '#7cc4fa',
|
||||
300: '#47a3f3',
|
||||
400: '#2186eb',
|
||||
500: '#0967d2',
|
||||
600: '#0552b5',
|
||||
700: '#03449e',
|
||||
800: '#01337d',
|
||||
900: '#002159',
|
||||
},
|
||||
};
|
||||
|
||||
// Define custom component styles
|
||||
const components = {
|
||||
Button: {
|
||||
baseStyle: {
|
||||
fontWeight: 'semibold',
|
||||
borderRadius: 'md',
|
||||
},
|
||||
variants: {
|
||||
solid: {
|
||||
bg: 'brand.500',
|
||||
color: 'white',
|
||||
_hover: {
|
||||
bg: 'brand.600',
|
||||
},
|
||||
},
|
||||
outline: {
|
||||
border: '1px solid',
|
||||
borderColor: 'brand.500',
|
||||
color: 'brand.500',
|
||||
},
|
||||
ghost: {
|
||||
color: 'brand.500',
|
||||
_hover: {
|
||||
bg: 'brand.50',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tooltip: {
|
||||
baseStyle: {
|
||||
borderRadius: 'md',
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
px: 2,
|
||||
py: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Define custom text styles
|
||||
const textStyles = {
|
||||
h1: {
|
||||
fontSize: ['2xl', '3xl'],
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '110%',
|
||||
},
|
||||
h2: {
|
||||
fontSize: ['xl', '2xl'],
|
||||
fontWeight: 'semibold',
|
||||
lineHeight: '110%',
|
||||
},
|
||||
};
|
||||
|
||||
// Extend the theme
|
||||
const theme = extendTheme({
|
||||
config,
|
||||
colors,
|
||||
components,
|
||||
textStyles,
|
||||
fonts: {
|
||||
heading: `'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif`,
|
||||
body: `'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif`,
|
||||
},
|
||||
styles: {
|
||||
global: {
|
||||
body: {
|
||||
bg: 'gray.50',
|
||||
color: 'gray.800',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
@@ -0,0 +1,222 @@
|
||||
// Base types for the mockup generator
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Canvas {
|
||||
width: number;
|
||||
height: number;
|
||||
background: string;
|
||||
gridSize: number;
|
||||
showGrid: boolean;
|
||||
}
|
||||
|
||||
export interface Style {
|
||||
backgroundColor?: string;
|
||||
borderRadius?: number;
|
||||
borderWidth?: number;
|
||||
borderColor?: string;
|
||||
borderStyle?: 'solid' | 'dashed' | 'dotted' | 'none';
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string;
|
||||
fontStyle?: 'normal' | 'italic' | 'oblique';
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
boxShadow?: string;
|
||||
padding?: string;
|
||||
margin?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Base component interface
|
||||
export interface BaseComponent {
|
||||
id: string;
|
||||
type: string;
|
||||
position: Position;
|
||||
size?: Size;
|
||||
style?: Style;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Funnel component
|
||||
export interface FunnelStep {
|
||||
label: string;
|
||||
value: number;
|
||||
dropoff?: number;
|
||||
color?: string;
|
||||
conversion?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface FunnelOptions {
|
||||
showPercentages?: boolean;
|
||||
showLabels?: boolean;
|
||||
showValues?: boolean;
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
stepNumbering?: boolean;
|
||||
}
|
||||
|
||||
export interface FunnelColors {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
tertiary?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
export interface FunnelData {
|
||||
title?: string;
|
||||
steps: FunnelStep[];
|
||||
options?: FunnelOptions;
|
||||
colors?: FunnelColors;
|
||||
}
|
||||
|
||||
export interface FunnelComponent extends BaseComponent {
|
||||
type: 'funnel';
|
||||
data: FunnelData;
|
||||
}
|
||||
|
||||
// Chart component
|
||||
export interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor?: string | string[];
|
||||
borderColor?: string | string[];
|
||||
borderWidth?: number;
|
||||
fill?: boolean;
|
||||
tension?: number;
|
||||
pointRadius?: number;
|
||||
}
|
||||
|
||||
export interface ChartOptions {
|
||||
showLegend?: boolean;
|
||||
legendPosition?: 'top' | 'bottom' | 'left' | 'right';
|
||||
showGrid?: boolean;
|
||||
showTooltip?: boolean;
|
||||
stacked?: boolean;
|
||||
aspectRatio?: number;
|
||||
axisLabels?: {
|
||||
x?: string;
|
||||
y?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
title?: string;
|
||||
labels: string[];
|
||||
datasets: ChartDataset[];
|
||||
}
|
||||
|
||||
export interface ChartComponent extends BaseComponent {
|
||||
type: 'chart';
|
||||
chartType: 'bar' | 'line' | 'pie' | 'area' | 'scatter' | 'radar' | 'bubble' | 'heatmap';
|
||||
data: ChartData;
|
||||
options?: ChartOptions;
|
||||
}
|
||||
|
||||
// Text component
|
||||
export interface TextData {
|
||||
content: string;
|
||||
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'body1' | 'body2' | 'caption' | 'overline';
|
||||
truncate?: boolean;
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
export interface TextComponent extends BaseComponent {
|
||||
type: 'text';
|
||||
data: TextData;
|
||||
}
|
||||
|
||||
// Card component
|
||||
export interface CardHeader {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface CardBody {
|
||||
content?: string;
|
||||
image?: string;
|
||||
imagePosition?: 'top' | 'bottom' | 'left' | 'right' | 'background';
|
||||
}
|
||||
|
||||
export interface CardAction {
|
||||
label: string;
|
||||
icon?: string;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link';
|
||||
}
|
||||
|
||||
export interface CardFooter {
|
||||
content?: string;
|
||||
actions?: CardAction[];
|
||||
}
|
||||
|
||||
export interface CardOptions {
|
||||
elevation?: number;
|
||||
hoverable?: boolean;
|
||||
outlined?: boolean;
|
||||
}
|
||||
|
||||
export interface CardData {
|
||||
header?: CardHeader;
|
||||
body?: CardBody;
|
||||
footer?: CardFooter;
|
||||
}
|
||||
|
||||
export interface CardComponent extends BaseComponent {
|
||||
type: 'card';
|
||||
data: CardData;
|
||||
options?: CardOptions;
|
||||
}
|
||||
|
||||
// Union type for all components
|
||||
export type Component =
|
||||
| FunnelComponent
|
||||
| ChartComponent
|
||||
| TextComponent
|
||||
| CardComponent
|
||||
| BaseComponent;
|
||||
|
||||
// Mockup type (root JSON structure)
|
||||
export interface Mockup {
|
||||
version: string;
|
||||
canvas: Canvas;
|
||||
components: Component[];
|
||||
}
|
||||
|
||||
// Application state types
|
||||
export interface SelectionState {
|
||||
selectedIds: string[];
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
past: Mockup[];
|
||||
current: Mockup;
|
||||
future: Mockup[];
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
activeTool: string;
|
||||
activePanel: string;
|
||||
sidebarWidth: number;
|
||||
colorPalette: string[];
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
mockup: Mockup;
|
||||
selection: SelectionState;
|
||||
history: HistoryState;
|
||||
ui: UIState;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
// Proxy API requests to the backend during development
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.js'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user