First push

This commit is contained in:
Sam Redfern committed 2025-04-28 10:06:04 +10:00
1 parent db25d6dbda
commit 7299578599
48 files changed
+17513 -1

No files matched your search

+63
View File
@@ -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"
]
}
}
+22
View File
@@ -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>
+28
View File
@@ -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;
+93
View File
@@ -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);
});
});
+120
View File
@@ -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;
+228
View File
@@ -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;
+43
View File
@@ -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;
+69
View File
@@ -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;
+67
View File
@@ -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;
}
+14
View File
@@ -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>
);
+24
View File
@@ -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;
+65
View File
@@ -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;
+89
View File
@@ -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;
+52
View File
@@ -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;
+65
View File
@@ -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;
+11
View File
@@ -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();
});
+99
View File
@@ -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;
+222
View File
@@ -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;
}
+25
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -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,
},
},
},
});
+17
View File
@@ -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'),
},
},
});