Compare commits

...

14 Commits

Author SHA1 Message Date
Possible 9f01f61611 Update 2024-12-09 18:13:52 +01:00
Possible 0b0ebd29c2 Updated Expected Output 2024-12-09 18:12:04 +01:00
ryanwong 79300ac9db fix formatting 2024-12-09 05:10:36 -05:00
ryanwong 05811962b2 updated task 2024-12-09 05:08:35 -05:00
ryanwong 167e9fe6cd switch state management to context api 2024-11-18 14:11:51 -05:00
ryanwong 183d273de2 fixed the issue 2024-11-18 13:32:19 -05:00
ryanwong 2c682bcc12 set default values 2024-11-18 13:30:35 -05:00
ryanwong 07306b9a0a fixing handle change 2024-11-18 12:16:11 -05:00
ryanwong 40749d0b12 fix delete 2024-11-16 09:00:17 -05:00
ryanwong 91bd7b308e add delete 2024-11-16 08:59:03 -05:00
ryanwong 21d2a9c45e added in new crud apis made 2024-11-16 08:52:13 -05:00
ryanwong b9d1173965 still doesnt work 2024-11-15 06:22:30 -05:00
ryanwong 05895231b8 updated config panel 2024-11-15 06:12:48 -05:00
ryanwong 576dff4e39 add in admin and member automatically generated 2024-11-15 06:00:35 -05:00
107 changed files with 12932 additions and 548 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"template": "bolt-vite-react-ts"
}
-8
View File
@@ -1,8 +0,0 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
+74 -2
View File
@@ -1,3 +1,75 @@
# mkd-backend-flow-builder
# This project is a toy project for training and quality assurance purposes
[Edit in StackBlitz next generation editor ⚡️](https://stackblitz.com/~/github.com/mytechpassport/mkd-backend-flow-builder)
## Task
All this task must be done within 8 hours of working time
- go into folder task_1 and setup the project
- click route and add a new route
> see screenshot ![route editor screenshot](task_1/screenshots/create_route.png)
- click the <> icon and see the editor for the route
> see screenshot ![route editor screenshot](task_1/screenshots/route_flow_editor.png)
- click on URL component in the editor, the right side bar will pop up
> see screenshot ![route editor screenshot](task_1/screenshots/route_flow_editor.png)
- try typing into Body Field new field input, nothing happens. Fix it.
> see screenshot ![route editor screenshot](task_1/screenshots/route_flow_editor.png)
- once we type into the input, click the plus icon, we will see a gray area below the label Body Field stating the fields : type.
- try typing into Query Field new field input, nothing happens. Fix it.
> see screenshot ![route editor screenshot](task_1/screenshots/route_flow_editor.png)
- once we type into the input, click the plus icon, we will see a gray area below the label Query Field stating the fields : type.
- Switch the state system from zustand to react Context API
- when you click "Back to Routes" and click on <> again, the state is lost, meaning all edits made are lost, fix it.
- open task_2 and setup the project
- type anything into the textarea, click generate.
- type typing into the code editor and you will see the cursor not aligned with what your typing, fix it.,
> the text needs to appear in the same line as your cursor. see screenshots ![typing in editor](task_2/screenshots/typing.png)
- when I click the files in the code editor on sidebar, nothing happens. Fix it.
> you need to load the content of the file into the editor. see screenshots ![changing files](task_2/screenshots/changing_files.png)
- open task_3 and setup the project
- when I upload a PDF file, I dont see a file attached below the dotted box. Fix it.
> see screenshots ![uploading pdf](task_3/screenshots/upload_pdf.png)
- the lines are overlapping the circles in the wizard steps. Fix it.
> see screenshots ![form fields lines overlapping steps](task_3/screenshots/form_fields_lines_overlaping_steps.png)
- On editor page, when I drag over any of form fields, I don't see them on the document. Fix it. I should be able to edit text there.
> see screenshots ![drag over form fields](task_3/screenshots/drag_over_form_fields.png)
- When I click save, I see table of my document. If I click send icon, I should see document preview with the input fields on same spot as I edited it. Fix it.
> see screenshots ![save and send](task_3/screenshots/save_and_send.png)
- go into task_4 and run docker-compose up --build, the api will now run on localhost:3000
- Make a dashboard page like figma file https://www.figma.com/file/veiESwD61KJBa7BpEHtbdl/react-task-2?node-id=1086%3A15525
- Call paginate api as shown below to get video data. Show 10 per page. Have a next button at bottom when clicked, load next 10 videos
```
URL: http://localhost:3000/v1/api/rest/video/PAGINATE
METHOD: POST
BODY: {
"page": 1,
"limit": 10
}
Response:
{
"error": false,
"list": [
{
"id": 1,
"title": "Rune raises $100,000 for marketing through NFT butterflies sale",
"photo": "https://picsum.photos/200/200",
"user_id": 1,
"created_at": "2024-12-09T09:40:08.000Z",
"updated_at": "2024-12-09T09:40:08.000Z",
"likes": 10
}
],
"page": 1,
"limit": 50,
"total": 81,
"num_pages": 2
}
```
- Call paginate api as shown below to get video data. Show 10 per page. Have a prev button at bottom when clicked, load prev 10 videos
- Use React Drag and drop library https://react-dnd.github.io/react-dnd/about to be able to rearrange the rows and columns in the table in dashboard. On Refresh, the columns go back to default
-408
View File
@@ -1,408 +0,0 @@
import React, { useState } from 'react';
import { X, Plus, Trash } from 'lucide-react';
import { Node } from 'reactflow';
import { useFlowStore } from '../store/flowStore';
interface ConfigPanelProps {
node: Node | null;
onClose: () => void;
onUpdateNode: (id: string, data: any) => void;
}
interface Field {
name: string;
type: string;
validation?: string;
}
export function ConfigPanel({ node, onClose, onUpdateNode }: ConfigPanelProps) {
const { models } = useFlowStore();
const [newField, setNewField] = useState<Field>({ name: '', type: 'string' });
if (!node) return null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
onUpdateNode(node.id, {
...node.data,
[e.target.name]: e.target.value,
});
};
const handleArrayChange = (index: number, field: string, value: string, arrayName: string) => {
const array = [...(node.data[arrayName] || [])];
array[index] = { ...array[index], [field]: value };
onUpdateNode(node.id, {
...node.data,
[arrayName]: array,
});
};
const addField = (arrayName: string) => {
const array = [...(node.data[arrayName] || []), newField];
onUpdateNode(node.id, {
...node.data,
[arrayName]: array,
});
setNewField({ name: '', type: 'string' });
};
const removeField = (index: number, arrayName: string) => {
const array = [...(node.data[arrayName] || [])];
array.splice(index, 1);
onUpdateNode(node.id, {
...node.data,
[arrayName]: array,
});
};
const renderDatabaseFields = () => (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Model</label>
<select
name="model"
value={node.data.model || ''}
onChange={handleChange}
className="w-full p-2 border rounded"
>
<option value="">Select Model</option>
{models.map((model) => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">SQL Query</label>
<textarea
name="query"
value={node.data.query || ''}
onChange={handleChange}
className="w-full p-2 border rounded h-32 font-mono text-sm"
placeholder="SELECT * FROM table WHERE id = :id"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Save Result In</label>
<input
type="text"
name="resultVar"
value={node.data.resultVar || ''}
onChange={handleChange}
className="w-full p-2 border rounded"
placeholder="result"
/>
</div>
</>
);
const renderFields = () => {
switch (node.type) {
case 'auth':
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Auth Type</label>
<select
name="authType"
value={node.data.authType || 'bearer'}
onChange={handleChange}
className="w-full p-2 border rounded"
>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth</option>
<option value="jwt">JWT</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Token Variable</label>
<input
type="text"
name="tokenVar"
value={node.data.tokenVar || ''}
onChange={handleChange}
className="w-full p-2 border rounded"
placeholder="token"
/>
</div>
</>
);
case 'url':
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Method</label>
<select
name="method"
value={node.data.method || 'GET'}
onChange={handleChange}
className="w-full p-2 border rounded"
>
{['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(method => (
<option key={method} value={method}>{method}</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Route Path</label>
<input
type="text"
name="path"
value={node.data.path || ''}
onChange={handleChange}
className="w-full p-2 border rounded"
placeholder="/api/users/:id"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Fields</label>
{(node.data.fields || []).map((field: Field, index: number) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={field.name}
onChange={(e) => handleArrayChange(index, 'name', e.target.value, 'fields')}
className="flex-1 p-2 border rounded"
placeholder="Field name"
/>
<select
value={field.type}
onChange={(e) => handleArrayChange(index, 'type', e.target.value, 'fields')}
className="w-24 p-2 border rounded"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="date">Date</option>
</select>
<input
type="text"
value={field.validation}
onChange={(e) => handleArrayChange(index, 'validation', e.target.value, 'fields')}
className="flex-1 p-2 border rounded"
placeholder="Validation"
/>
<button
onClick={() => removeField(index, 'fields')}
className="p-2 text-red-500 hover:bg-red-50 rounded"
>
<Trash className="w-4 h-4" />
</button>
</div>
))}
<div className="flex gap-2 mt-2">
<input
type="text"
value={newField.name}
onChange={(e) => setNewField({ ...newField, name: e.target.value })}
className="flex-1 p-2 border rounded"
placeholder="New field name"
/>
<select
value={newField.type}
onChange={(e) => setNewField({ ...newField, type: e.target.value })}
className="w-24 p-2 border rounded"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="date">Date</option>
</select>
<button
onClick={() => addField('fields')}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</>
);
case 'output':
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Fields</label>
{(node.data.fields || []).map((field: Field, index: number) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={field.name}
onChange={(e) => handleArrayChange(index, 'name', e.target.value, 'fields')}
className="flex-1 p-2 border rounded"
placeholder="Field name"
/>
<select
value={field.type}
onChange={(e) => handleArrayChange(index, 'type', e.target.value, 'fields')}
className="w-24 p-2 border rounded"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="date">Date</option>
</select>
<button
onClick={() => removeField(index, 'fields')}
className="p-2 text-red-500 hover:bg-red-50 rounded"
>
<Trash className="w-4 h-4" />
</button>
</div>
))}
<div className="flex gap-2 mt-2">
<input
type="text"
value={newField.name}
onChange={(e) => setNewField({ ...newField, name: e.target.value })}
className="flex-1 p-2 border rounded"
placeholder="New field name"
/>
<select
value={newField.type}
onChange={(e) => setNewField({ ...newField, type: e.target.value })}
className="w-24 p-2 border rounded"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="date">Date</option>
</select>
<button
onClick={() => addField('fields')}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</>
);
case 'variable':
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Variable Name</label>
<input
type="text"
name="name"
value={node.data.name || ''}
onChange={handleChange}
className="w-full p-2 border rounded"
placeholder="myVariable"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Type</label>
<select
name="type"
value={node.data.type || 'string'}
onChange={handleChange}
className="w-full p-2 border rounded"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object</option>
<option value="array">Array</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Default Value</label>
<input
type="text"
name="defaultValue"
value={node.data.defaultValue || ''}
onChange={handleChange}
className="w-full p-2 border rounded"
placeholder="Default value"
/>
</div>
</>
);
case 'logic':
return (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">JavaScript Code</label>
<textarea
name="code"
value={node.data.code || ''}
onChange={handleChange}
className="w-full p-2 border rounded h-40 font-mono text-sm"
placeholder="// Write your JavaScript code here"
/>
</div>
);
case 'db-find':
case 'db-query':
return renderDatabaseFields();
case 'db-insert':
return (
<>
{renderDatabaseFields()}
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Variables</label>
<textarea
name="variables"
value={node.data.variables || ''}
onChange={handleChange}
className="w-full p-2 border rounded h-20 font-mono text-sm"
placeholder="name: string&#10;age: number"
/>
</div>
</>
);
case 'db-update':
case 'db-delete':
return (
<>
{renderDatabaseFields()}
<div className="mb-4">
<label className="block text-sm font-medium mb-1">ID Field</label>
<input
type="text"
name="idField"
value={node.data.idField || ''}
onChange={handleChange}
className="w-full p-2 border rounded"
placeholder="id"
/>
</div>
{node.type === 'db-update' && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Variables</label>
<textarea
name="variables"
value={node.data.variables || ''}
onChange={handleChange}
className="w-full p-2 border rounded h-20 font-mono text-sm"
placeholder="name: string&#10;age: number"
/>
</div>
)}
</>
);
default:
return null;
}
};
return (
<div className="fixed right-0 top-0 h-full w-80 bg-white border-l border-gray-200 p-4 shadow-lg transform transition-transform overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Configure Node</h3>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
<X className="w-5 h-5" />
</button>
</div>
{renderFields()}
</div>
);
}
-84
View File
@@ -1,84 +0,0 @@
import React, { useState } from 'react';
import { Plus, Edit2, Code } from 'lucide-react';
import { useFlowStore } from '../store/flowStore';
import { RouteModal } from './RouteModal';
import { RouteFlowEditor } from './RouteFlowEditor';
export function RoutesPanel() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedRoute, setSelectedRoute] = useState<any>(null);
const [editingRoute, setEditingRoute] = useState<any>(null);
const { routes } = useFlowStore();
return (
<div className="space-y-4">
{!editingRoute ? (
<>
<button
onClick={() => {
setSelectedRoute(null);
setIsModalOpen(true);
}}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<Plus className="w-4 h-4" />
Add Route
</button>
<div className="space-y-3">
{routes.map((route) => (
<div
key={route.id}
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium">{route.name}</h3>
<p className="text-sm text-gray-500">
{route.method} {route.url}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setSelectedRoute(route);
setIsModalOpen(true);
}}
className="p-1 hover:bg-gray-100 rounded"
title="Edit Route"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setEditingRoute(route)}
className="p-1 hover:bg-gray-100 rounded"
title="Edit Components"
>
<Code className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
<RouteModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedRoute(null);
}}
route={selectedRoute}
/>
</>
) : (
<div className="fixed inset-0 bg-white">
<RouteFlowEditor
route={editingRoute}
onClose={() => setEditingRoute(null)}
/>
</div>
)}
</div>
);
}
View File
View File
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File
+786
View File
@@ -0,0 +1,786 @@
import React, { useState, useEffect } from "react";
import { X, Plus, Trash } from "lucide-react";
import { Node } from "reactflow";
import { useFlowStore } from "../store/flowStore";
interface ConfigPanelProps {
node: Node | null;
onClose: () => void;
onUpdateNode: (id: string, data: any) => void;
}
interface Field {
name: string;
type: string;
validation?: string;
}
const getDefaultDataForType = (type: string) => {
const baseData = {
label: type.charAt(0).toUpperCase() + type.slice(1).replace("-", " ")
};
switch (type) {
case "variable":
return {
...baseData,
name: "",
type: "string",
defaultValue: ""
};
case "url":
return {
...baseData,
method: "GET",
path: "",
fields: [],
queryFields: []
};
case "auth":
return {
...baseData,
authType: "bearer",
tokenVar: ""
};
case "output":
return {
...baseData,
outputType: "definition",
statusCode: 200,
fields: [],
responseRaw: ""
};
case "logic":
return {
...baseData,
code: ""
};
case "db-find":
case "db-query":
return {
...baseData,
model: "",
operation: "findMany",
query: "",
resultVar: "result"
};
case "db-insert":
return {
...baseData,
model: "",
variables: "",
resultVar: "result"
};
case "db-update":
case "db-delete":
return {
...baseData,
model: "",
idField: "id",
variables: "",
resultVar: "result"
};
default:
return baseData;
}
};
export function ConfigPanel({ node, onClose, onUpdateNode }: ConfigPanelProps) {
const { models, updateNode } = useFlowStore();
const [newField, setNewField] = useState<Field>({ name: "", type: "string" });
const [newQueryField, setNewQueryField] = useState<Field>({
name: "",
type: "string",
});
useEffect(() => {
if (node) {
// Initialize node data with defaults if not already set
const defaultData = getDefaultDataForType(node.type);
const newData = {
...defaultData,
...node.data // This will override defaults with any existing data
};
// Only update if the data is different
if (JSON.stringify(newData) !== JSON.stringify(node.data)) {
onUpdateNode(node.id, newData);
updateNode(node.id, newData);
}
}
}, [node?.id, node?.type]);
useEffect(() => {
console.log("ConfigPanel re-rendered with node:", node);
}, [node]);
if (!node) return null;
console.log("what up");
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
if (!node) return;
console.log("Handling change for:", e.target.name, "with value:", e.target.value);
const newData = {
...node.data,
[e.target.name]: e.target.value,
};
console.log("New data to update:", newData);
onUpdateNode(node.id, newData);
updateNode(node.id, newData);
};
const handleArrayChange = (
index: number,
field: string,
value: string,
arrayName: string
) => {
const array = [...node.data[arrayName]];
array[index] = { ...array[index], [field]: value };
const newData = {
...node.data,
[arrayName]: array,
};
onUpdateNode(node.id, newData);
updateNode(node.id, newData);
};
const addField = (arrayName: string) => {
const fieldToAdd = arrayName === "queryFields" ? newQueryField : newField;
if (!fieldToAdd.name.trim()) return;
const array = [...node.data[arrayName], { ...fieldToAdd }];
const newData = {
...node.data,
[arrayName]: array,
};
onUpdateNode(node.id, newData);
updateNode(node.id, newData);
// Reset the appropriate state
if (arrayName === "queryFields") {
setNewQueryField({ name: "", type: "string" });
} else {
setNewField({ name: "", type: "string" });
}
};
const removeField = (index: number, arrayName: string) => {
const array = [...node.data[arrayName]];
array.splice(index, 1);
const newData = {
...node.data,
[arrayName]: array,
};
onUpdateNode(node.id, newData);
updateNode(node.id, newData);
};
const copyQueryFields = () => {
const currentFields = node.data.fields || [];
navigator.clipboard.writeText(JSON.stringify(currentFields, null, 2));
};
const extractQueryParams = (path: string) => {
const params = path.match(/:[a-zA-Z]+/g) || [];
return params.map((param) => ({
name: param.substring(1),
type: "string",
validation: "",
}));
};
const renderDatabaseFields = () => (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Model</label>
<select
name="model"
value={node.data.model}
onBlur={handleChange}
className="w-full p-2 border rounded"
>
<option value="">Select Model</option>
{models.map((model) => (
<option key={model.id} value={model.name}>
{model.name}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Operation</label>
<select
name="operation"
value={node.data.operation}
onBlur={handleChange}
className="w-full p-2 border rounded"
>
<option value="findMany">Find Many</option>
<option value="findOne">Find One</option>
<option value="findFirst">Find First</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">SQL Query</label>
<textarea
name="query"
value={node.data.query}
onBlur={handleChange}
className="w-full p-2 border rounded h-32 font-mono text-sm"
placeholder="SELECT * FROM table WHERE id = :id"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Save Result In</label>
<input
type="text"
name="resultVar"
value={node.data.resultVar}
onBlur={handleChange}
className="w-full p-2 border rounded"
placeholder="result"
/>
</div>
</>
);
const renderFields = () => {
switch (node.type) {
case "auth":
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Auth Type
</label>
<select
name="authType"
value={node.data.authType}
onBlur={handleChange}
className="w-full p-2 border rounded"
>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth</option>
<option value="jwt">JWT</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Token Variable
</label>
<input
type="text"
name="tokenVar"
value={node.data.tokenVar}
onBlur={handleChange}
className="w-full p-2 border rounded"
placeholder="token"
/>
</div>
</>
);
case "url":
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Method</label>
<select
name="method"
value={node.data.method}
onBlur={handleChange}
className="w-full p-2 border rounded"
>
{["GET", "POST", "PUT", "DELETE", "PATCH"].map((method) => (
<option key={method} value={method}>
{method}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Route Path
</label>
<input
type="text"
name="path"
value={node.data.path}
onChange={handleChange}
className="w-full p-2 border rounded"
placeholder="/api/users/:id"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Body Fields
</label>
{node.data.fields?.length > 0 && (
<div className="mb-2 p-2 bg-gray-50 rounded text-sm">
{node.data.fields.map((field: Field) => (
<div key={field.name} className="text-gray-600">
{field.name}: {field.type}
{field.validation ? ` (${field.validation})` : ""}
</div>
))}
</div>
)}
{(node.data.fields || []).map((field: Field, index: number) => (
<div key={index} className="flex gap-1 mb-2">
<input
type="text"
value={field.name}
onBlur={(e) =>
handleArrayChange(index, "name", e.target.value, "fields")
}
className="flex-1 p-2 border rounded text-sm"
placeholder="Field name"
/>
<select
value={field.type}
onBlur={(e) =>
handleArrayChange(index, "type", e.target.value, "fields")
}
className="w-20 p-2 border rounded text-sm"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Bool</option>
<option value="date">Date</option>
</select>
<button
onClick={() => removeField(index, "fields")}
className="p-2 text-red-500 hover:bg-red-50 rounded"
>
<Trash className="w-4 h-4" />
</button>
</div>
))}
<div className="flex gap-1 mt-2">
<input
type="text"
value={newField.name}
onBlur={(e) =>
setNewField({ ...newField, name: e.target.value })
}
className="flex-1 p-2 border rounded text-sm"
placeholder="New field name"
/>
<select
value={newField.type}
onBlur={(e) =>
setNewField({ ...newField, type: e.target.value })
}
className="w-20 p-2 border rounded text-sm"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Bool</option>
<option value="date">Date</option>
</select>
<button
onClick={() => {
if (newField.name.trim()) {
addField("fields");
}
}}
className="p-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Query Parameters
</label>
{node.data.queryFields?.length > 0 && (
<div className="mb-2 p-2 bg-gray-50 rounded text-sm">
{node.data.queryFields.map((field: Field) => (
<div key={field.name} className="text-gray-600">
{field.name}: {field.type}
{field.validation ? ` (${field.validation})` : ""}
</div>
))}
</div>
)}
{(node.data.queryFields || []).map(
(field: Field, index: number) => (
<div key={index} className="flex gap-1 mb-2">
<input
type="text"
value={field.name}
onBlur={(e) =>
handleArrayChange(
index,
"name",
e.target.value,
"queryFields"
)
}
className="flex-1 p-2 border rounded text-sm"
placeholder="Field name"
/>
<select
value={field.type}
onBlur={(e) =>
handleArrayChange(
index,
"type",
e.target.value,
"queryFields"
)
}
className="w-20 p-2 border rounded text-sm"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Bool</option>
<option value="date">Date</option>
</select>
<button
onClick={() => removeField(index, "queryFields")}
className="p-2 text-red-500 hover:bg-red-50 rounded"
>
<Trash className="w-4 h-4" />
</button>
</div>
)
)}
<div className="flex gap-1 mt-2">
<input
type="text"
value={newQueryField.name}
onBlur={(e) =>
setNewQueryField({ ...newQueryField, name: e.target.value })
}
className="flex-1 p-2 border rounded text-sm"
placeholder="New query param"
/>
<select
value={newQueryField.type}
onBlur={(e) =>
setNewQueryField({ ...newQueryField, type: e.target.value })
}
className="w-20 p-2 border rounded text-sm"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Bool</option>
<option value="date">Date</option>
</select>
<button
onClick={() => {
if (newQueryField.name.trim()) {
const updatedQueryFields = [
...(node.data.queryFields || []),
{ ...newQueryField },
];
onUpdateNode(node.id, {
...node.data,
queryFields: updatedQueryFields,
});
setNewQueryField({ name: "", type: "string" });
}
}}
className="p-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</>
);
case "output":
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Output Type</label>
<select
name="outputType"
value={node.data.outputType}
onBlur={handleChange}
className="w-full p-2 border rounded"
>
<option value="definition">Definition</option>
<option value="mockup">Mockup</option>
</select>
</div>
{node.data.outputType === "mockup" ? (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Response Raw</label>
<textarea
name="responseRaw"
value={node.data.responseRaw}
onBlur={handleChange}
className="w-full p-2 border rounded h-32"
placeholder="Enter raw response here..."
/>
</div>
) : (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Fields</label>
<div className="flex flex-wrap gap-2 mb-2">
{(node.data.fields || []).map((field: Field, index: number) => (
<div key={index} className="flex items-center">
<input
type="text"
value={field.name}
onBlur={(e) =>
handleArrayChange(index, "name", e.target.value, "fields")
}
className="flex-1 p-2 border rounded text-sm"
placeholder="Field name"
/>
<select
value={field.type}
onBlur={(e) =>
handleArrayChange(index, "type", e.target.value, "fields")
}
className="w-24 p-2 border rounded text-sm"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="date">Date</option>
<option value="object">Object</option>
<option value="array">Array</option>
</select>
<button
onClick={() => removeField(index, "fields")}
className="p-2 text-red-500 hover:bg-red-50 rounded"
>
<Trash className="w-4 h-4" />
</button>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-2">
<input
type="text"
value={newField.name}
onBlur={(e) =>
setNewField({ ...newField, name: e.target.value })
}
className="flex-1 p-2 border rounded text-sm"
placeholder="New field name"
/>
<select
value={newField.type}
onBlur={(e) =>
setNewField({ ...newField, type: e.target.value })
}
className="w-24 p-2 border rounded text-sm"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="date">Date</option>
<option value="object">Object</option>
<option value="array">Array</option>
</select>
<button
onClick={() => {
if (newField.name.trim()) {
const updatedFields = [
...(node.data.fields || []),
{ ...newField },
];
onUpdateNode(node.id, {
...node.data,
fields: updatedFields,
});
setNewField({ name: "", type: newField.type }); // Use the selected type here
}
}}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
)}
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Status Code</label>
<input
type="number"
name="statusCode"
value={node.data.statusCode}
onBlur={handleChange}
className="w-full p-2 border rounded"
placeholder="200"
/>
</div>
</>
);
case "variable":
return (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Variable Name
</label>
<input
type="text"
name="name"
value={node.data.name}
onBlur={handleChange}
className="w-full p-2 border rounded"
placeholder="myVariable"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Type</label>
<select
name="type"
value={node.data.type}
onBlur={handleChange}
className="w-full p-2 border rounded"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object</option>
<option value="array">Array</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Default Value
</label>
<input
type="text"
name="defaultValue"
value={node.data.defaultValue}
onBlur={handleChange}
className="w-full p-2 border rounded"
placeholder="Default value"
/>
</div>
</>
);
case "logic":
return (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
JavaScript Code
</label>
<textarea
name="code"
value={node.data.code}
onBlur={handleChange}
className="w-full p-2 border rounded h-40 font-mono text-sm"
placeholder="// Write your JavaScript code here"
/>
</div>
);
case "db-find":
return renderDatabaseFields();
case "db-query":
return (
<>
{renderDatabaseFields()}
<div className="mb-4">
<button
onClick={copyQueryFields}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded"
>
Copy Fields
</button>
</div>
</>
);
case "db-insert":
return (
<>
{renderDatabaseFields()}
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Variables
</label>
<textarea
name="variables"
value={node.data.variables}
onBlur={handleChange}
className="w-full p-2 border rounded h-20 font-mono text-sm"
placeholder="name: string&#10;age: number"
/>
</div>
</>
);
case "db-update":
case "db-delete":
return (
<>
{renderDatabaseFields()}
<div className="mb-4">
<label className="block text-sm font-medium mb-1">ID Field</label>
<input
type="text"
name="idField"
value={node.data.idField}
onBlur={handleChange}
className="w-full p-2 border rounded"
placeholder="id"
/>
</div>
{node.type === "db-update" && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Variables
</label>
<textarea
name="variables"
value={node.data.variables}
onBlur={handleChange}
className="w-full p-2 border rounded h-20 font-mono text-sm"
placeholder="name: string&#10;age: number"
/>
</div>
)}
</>
);
default:
return null;
}
};
return (
<div className="fixed right-0 top-0 h-full w-80 bg-white border-l border-gray-200 p-4 shadow-lg transform transition-transform overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Configure Node</h3>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
<X className="w-5 h-5" />
</button>
</div>
{renderFields()}
</div>
);
}
@@ -67,7 +67,9 @@ export function DefaultTablesModal({
onClose,
}: DefaultTablesModalProps) {
const [selectedTables, setSelectedTables] = useState<string[]>([]);
const { addModel } = useFlowStore();
const [includeAdminRole, setIncludeAdminRole] = useState(true);
const [includeMemberRole, setIncludeMemberRole] = useState(true);
const { addModel, addRole } = useFlowStore();
const handleToggleTable = (tableId: string) => {
setSelectedTables((prev) =>
@@ -88,6 +90,42 @@ export function DefaultTablesModal({
});
}
});
// Add roles based on checkbox selection
if (selectedTables.includes("user")) {
if (includeAdminRole) {
addRole({
id: `role_admin_${Date.now()}`,
name: "Admin",
slug: "admin",
permissions: {
authRequired: true,
routes: [],
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canManageRoles: true,
},
});
}
if (includeMemberRole) {
addRole({
id: `role_member_${Date.now()}`,
name: "Member",
slug: "member",
permissions: {
authRequired: true,
routes: [],
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
},
});
}
}
onClose();
};
@@ -127,6 +165,39 @@ export function DefaultTablesModal({
<div className="mt-2 text-sm text-gray-500">
Fields: {table.fields.map((f) => f.name).join(", ")}
</div>
{/* Add role options when User table is selected */}
{table.id === "user" && selectedTables.includes("user") && (
<div className="mt-3 pl-2 border-l-2 border-gray-200">
<p className="text-sm font-medium text-gray-700 mb-2">
Include roles:
</p>
<div className="space-y-2">
<label className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={includeAdminRole}
onChange={(e) =>
setIncludeAdminRole(e.target.checked)
}
className="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
/>
<span>Admin Role</span>
</label>
<label className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={includeMemberRole}
onChange={(e) =>
setIncludeMemberRole(e.target.checked)
}
className="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
/>
<span>Member Role</span>
</label>
</div>
</div>
)}
</div>
</label>
</div>
@@ -80,10 +80,15 @@ const createInitialModelData = () => ({
});
export function ModelModal({ isOpen, onClose, model }: ModelModalProps) {
const [modelData, setModelData] = useState(createInitialModelData());
const [modelData, setModelData] = useState<{
id: string;
name: string;
fields: Field[];
}>(createInitialModelData());
const [newField, setNewField] = useState<Field>(initialNewField);
const [createCrudApis, setCreateCrudApis] = useState(false); // New state for CRUD API checkbox
const { addModel, updateModel } = useFlowStore();
const { addModel, updateModel, addRoute } = useFlowStore();
useEffect(() => {
if (model) {
@@ -122,8 +127,198 @@ export function ModelModal({ isOpen, onClose, model }: ModelModalProps) {
updateModel(modelData);
} else {
addModel(modelData);
if (createCrudApis) {
// Create GET route for fetching all records
const uniqueId = Date.now(); // Generate a unique identifier based on the current timestamp
const getRoute = {
id: `route_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: `Get All ${modelData.name}`,
method: 'GET',
url: `/api/${modelData.name.toLowerCase()}`,
flowData: {
nodes: [
{
id: `url_node_${uniqueId}`,
type: 'url',
position: { x: 100, y: 100 },
data: {
label: 'URL',
path: `/api/${modelData.name.toLowerCase()}`,
method: 'GET'
}
},
{
id: `db_find_node_${uniqueId}`,
type: 'db-find',
position: { x: 100, y: 200 },
data: {
label: 'Database Find',
model: modelData.name,
operation: 'findMany',
query: `SELECT * FROM ${modelData.name}`,
resultVar: `${modelData.name}Result`
}
},
{
id: `output_node_${uniqueId}`,
type: 'output',
position: { x: 100, y: 300 },
data: {
label: 'Output',
outputType: 'definition',
fields: modelData.fields.map(field => ({
name: field.name,
type: field.type === 'primary key' ? 'number' :
field.type === 'long text' ? 'string' :
field.type === 'big number' ? 'number' :
field.type
})),
statusCode: 200
}
}
],
edges: [
{
id: `url-to-db_${uniqueId}`,
source: `url_node_${uniqueId}`,
target: `db_find_node_${uniqueId}`
},
{
id: `db-to-output_${uniqueId}`,
source: `db_find_node_${uniqueId}`,
target: `output_node_${uniqueId}`
}
]
}
};
const getOneRoute = {
id: `route_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: `Get One ${modelData.name}`,
method: 'GET',
url: `/api/${modelData.name.toLowerCase()}/:id`,
flowData: {
nodes: [
{
id: `url_node_${uniqueId}_1`,
type: 'url',
position: { x: 100, y: 100 },
data: {
label: 'URL',
path: `/api/${modelData.name.toLowerCase()}/:id`,
method: 'GET'
}
},
{
id: `db_find_node_${uniqueId}_1`,
type: 'db-find',
position: { x: 100, y: 200 },
data: {
label: 'Database Find',
model: modelData.name,
operation: 'findOne',
query: `SELECT * FROM ${modelData.name} WHERE id=id`,
resultVar: `${modelData.name}OneResult`
}
},
{
id: `output_node_${uniqueId}_1`,
type: 'output',
position: { x: 100, y: 300 },
data: {
label: 'Output',
outputType: 'definition',
fields: modelData.fields.map(field => ({
name: field.name,
type: field.type === 'primary key' ? 'number' :
field.type === 'long text' ? 'string' :
field.type === 'big number' ? 'number' :
field.type
})),
statusCode: 200
}
}
],
edges: [
{
id: `url-to-db_${uniqueId}_1`,
source: `url_node_${uniqueId}_1`,
target: `db_find_node_${uniqueId}_1`
},
{
id: `db-to-output_${uniqueId}_1`,
source: `db_find_node_${uniqueId}_1`,
target: `output_node_${uniqueId}_1`
}
]
}
};
const deleteRoute = {
id: `route_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: `Delete One ${modelData.name}`,
method: 'DELETE',
url: `/api/${modelData.name.toLowerCase()}/:id`,
flowData: {
nodes: [
{
id: `url_node_${uniqueId}_1`,
type: 'url',
position: { x: 100, y: 100 },
data: {
label: 'URL',
path: `/api/${modelData.name.toLowerCase()}/:id`,
method: 'DELETE'
}
},
{
id: `db_find_node_${uniqueId}_2`,
type: 'db-delete',
position: { x: 100, y: 200 },
data: {
label: 'Database Delete',
model: modelData.name,
operation: 'findOne',
query: `DELETE FROM ${modelData.name} WHERE id=id`,
resultVar: `${modelData.name}DeleteResult`
}
},
{
id: `output_node_${uniqueId}_2`,
type: 'output',
position: { x: 100, y: 300 },
data: {
label: 'Output',
outputType: 'definition',
fields: [
{name: "error", type: "boolean"},
{name: "id", type: "integer"}
],
statusCode: 200
}
}
],
edges: [
{
id: `url-to-db_${uniqueId}_1`,
source: `url_node_${uniqueId}_1`,
target: `db_find_node_${uniqueId}_2`
},
{
id: `db-to-output_${uniqueId}_2`,
source: `db_find_node_${uniqueId}_2`,
target: `output_node_${uniqueId}_2 `
}
]
}
};
addRoute(deleteRoute as any);
addRoute(getRoute as any);
addRoute(getOneRoute as any);
}
onClose();
}
onClose();
}
};
@@ -327,6 +522,18 @@ export function ModelModal({ isOpen, onClose, model }: ModelModalProps) {
<Plus className="w-4 h-4" />
Add Field
</button>
<div className="flex items-center mt-4">
<input
type="checkbox"
checked={createCrudApis}
onChange={(e) => setCreateCrudApis(e.target.checked)}
className="mr-2"
/>
<label className="text-sm">Create CRUD APIs</label>
</div>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useEffect } from 'react';
import React, { useCallback, useRef, useEffect } from "react";
import ReactFlow, {
Background,
Controls,
@@ -9,13 +9,13 @@ import ReactFlow, {
applyNodeChanges,
applyEdgeChanges,
Panel,
} from 'reactflow';
import { ArrowLeft, Save } from 'lucide-react';
import { useFlowStore } from '../store/flowStore';
import { ConfigPanel } from './ConfigPanel';
import { ComponentsPanel } from './ComponentsPanel';
import CustomNode from './CustomNode';
import 'reactflow/dist/style.css';
} from "reactflow";
import { ArrowLeft, Save } from "lucide-react";
import { useFlowStore } from "../store/flowStore";
import { ConfigPanel } from "./ConfigPanel";
import { ComponentsPanel } from "./ComponentsPanel";
import CustomNode from "./CustomNode";
import "reactflow/dist/style.css";
const nodeTypes = {
auth: CustomNode,
@@ -23,11 +23,11 @@ const nodeTypes = {
output: CustomNode,
logic: CustomNode,
variable: CustomNode,
'db-find': CustomNode,
'db-insert': CustomNode,
'db-update': CustomNode,
'db-delete': CustomNode,
'db-query': CustomNode,
"db-find": CustomNode,
"db-insert": CustomNode,
"db-update": CustomNode,
"db-delete": CustomNode,
"db-query": CustomNode,
};
interface FlowEditorContentProps {
@@ -55,7 +55,18 @@ function FlowEditorContent({ route, onClose }: FlowEditorContentProps) {
setNodes(route.flowData.nodes);
setEdges(route.flowData.edges);
} else {
setNodes([]);
// Create default URL node for new routes
const defaultNode = {
id: `node_${Date.now()}`,
type: "url",
position: { x: 100, y: 100 },
data: {
label: "URL",
path: route.url,
method: route.method,
},
};
setNodes([defaultNode]);
setEdges([]);
}
}, [route, setNodes, setEdges]);
@@ -77,14 +88,14 @@ function FlowEditorContent({ route, onClose }: FlowEditorContentProps) {
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow');
const type = event.dataTransfer.getData("application/reactflow");
if (!type || !reactFlowWrapper.current) return;
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
@@ -97,7 +108,9 @@ function FlowEditorContent({ route, onClose }: FlowEditorContentProps) {
id: `node_${Date.now()}`,
type,
position,
data: { label: type.charAt(0).toUpperCase() + type.slice(1).replace('-', ' ') },
data: {
label: type.charAt(0).toUpperCase() + type.slice(1).replace("-", " "),
},
};
setNodes((nds) => [...nds, newNode]);
@@ -194,4 +207,4 @@ export function RouteFlowEditor({ route, onClose }: FlowEditorContentProps) {
<FlowEditorContent route={route} onClose={onClose} />
</ReactFlowProvider>
);
}
}
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { X, Trash2 } from 'lucide-react';
import { useFlowStore } from '../store/flowStore';
import React, { useState, useEffect } from "react";
import { X, Trash2 } from "lucide-react";
import { useFlowStore } from "../store/flowStore";
interface RouteModalProps {
isOpen: boolean;
@@ -15,9 +15,9 @@ interface RouteModalProps {
const createInitialFormData = () => ({
id: `route_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: '',
url: '',
method: 'GET',
name: "",
url: "",
method: "GET",
});
export function RouteModal({ isOpen, onClose, route }: RouteModalProps) {
@@ -32,7 +32,9 @@ export function RouteModal({ isOpen, onClose, route }: RouteModalProps) {
}
}, [route, isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData({
...formData,
@@ -63,12 +65,9 @@ export function RouteModal({ isOpen, onClose, route }: RouteModalProps) {
<div className="bg-white rounded-lg w-full max-w-md">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-lg font-semibold">
{route ? 'Edit Route' : 'Add New Route'}
{route ? "Edit Route" : "Add New Route"}
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded"
>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
<X className="w-5 h-5" />
</button>
</div>
@@ -76,7 +75,9 @@ export function RouteModal({ isOpen, onClose, route }: RouteModalProps) {
<div className="p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Route Name</label>
<label className="block text-sm font-medium mb-1">
Route Name
</label>
<input
type="text"
name="name"
@@ -138,11 +139,11 @@ export function RouteModal({ isOpen, onClose, route }: RouteModalProps) {
onClick={handleSave}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
{route ? 'Update' : 'Save'} Route
{route ? "Update" : "Save"} Route
</button>
</div>
</div>
</div>
</div>
);
}
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from "react";
import { Plus, Edit2, Code } from "lucide-react";
import { useFlowStore } from "../store/flowStore";
import { RouteModal } from "./RouteModal";
import { RouteFlowEditor } from "./RouteFlowEditor";
export function RoutesPanel() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedRoute, setSelectedRoute] = useState<any>(null);
const [editingRoute, setEditingRoute] = useState<any>(null);
const { routes } = useFlowStore();
return (
<div className="flex flex-col h-full">
{!editingRoute ? (
<>
<div className="p-4">
<button
onClick={() => {
setSelectedRoute(null);
setIsModalOpen(true);
}}
className="flex items-center justify-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 w-full"
>
<Plus className="w-4 h-4" />
Add Route
</button>
</div>
<div className="flex-1 overflow-y-auto px-4">
<div className="space-y-3">
{routes.map((route) => (
<div
key={route.id}
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium">{route.name}</h3>
<p className="text-sm text-gray-500">
{route.method} {route.url}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setSelectedRoute(route);
setIsModalOpen(true);
}}
className="p-1 hover:bg-gray-100 rounded"
title="Edit Route"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setEditingRoute(route)}
className="p-1 hover:bg-gray-100 rounded"
title="Edit Components"
>
<Code className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
<RouteModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedRoute(null);
}}
route={selectedRoute}
/>
</>
) : (
<div className="fixed inset-0 bg-white">
<RouteFlowEditor
route={editingRoute}
onClose={() => setEditingRoute(null)}
/>
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { useFlowContext } from '../store/FlowContext';
const SomeComponent = () => {
const { nodes, setNodes } = useFlowContext();
// Example usage
const addNode = () => {
setNodes([...nodes, { id: 'new-node', data: {}, position: { x: 0, y: 0 } }]);
};
return (
<div>
<button onClick={addNode}>Add Node</button>
</div>
);
};
export default SomeComponent;
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { FlowProvider } from './store/FlowContext';
ReactDOM.render(
<React.StrictMode>
<FlowProvider>
<App />
</FlowProvider>
</React.StrictMode>,
document.getElementById('root')
);
+161
View File
@@ -0,0 +1,161 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { Node, Edge } from 'reactflow';
interface Model {
id: string;
name: string;
fields: {
name: string;
type: string;
defaultValue: string;
validation: string;
mapping?: string;
}[];
}
interface Role {
id: string;
name: string;
slug: string;
permissions: {
authRequired: boolean;
routes: string[];
canCreateUsers?: boolean;
canEditUsers?: boolean;
canDeleteUsers?: boolean;
canManageRoles?: boolean;
};
}
interface Route {
id: string;
name: string;
method: string;
url: string;
flowData?: {
nodes: any[];
edges: any[];
};
}
interface Settings {
globalKey: string;
databaseType: string;
authType: string;
timezone: string;
dbHost: string;
dbPort: string;
dbUser: string;
dbPassword: string;
dbName: string;
}
interface FlowState {
nodes: Node[];
edges: Edge[];
selectedNode: Node | null;
models: Model[];
roles: Role[];
routes: Route[];
settings: Settings;
defaultTablesShown: boolean;
setNodes: (nodes: Node[] | ((prev: Node[]) => Node[])) => void;
setEdges: (edges: Edge[] | ((prev: Edge[]) => Edge[])) => void;
setSelectedNode: (node: Node | null) => void;
updateNodeData: (nodeId: string, newData: any) => void;
addModel: (model: Model) => void;
updateModel: (model: Model) => void;
addRole: (role: Role) => void;
updateRole: (role: Role) => void;
deleteRole: (roleId: string) => void;
addRoute: (route: Route) => void;
updateRoute: (route: Route) => void;
deleteRoute: (routeId: string) => void;
updateSettings: (settings: Settings) => void;
setDefaultTablesShown: (shown: boolean) => void;
updateNode: (nodeId: string, newData: any) => void;
}
const FlowContext = createContext<FlowState | undefined>(undefined);
export const FlowProvider = ({ children }: { children: ReactNode }) => {
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [models, setModels] = useState<Model[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [routes, setRoutes] = useState<Route[]>([]);
const [settings, setSettings] = useState<Settings>({
globalKey: `key_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
databaseType: "mysql",
authType: "session",
timezone: "UTC",
dbHost: "localhost",
dbPort: "3306",
dbUser: "root",
dbPassword: "root",
dbName: `database_${new Date().toISOString().split("T")[0]}`,
});
const [defaultTablesShown, setDefaultTablesShown] = useState<boolean>(false);
const updateNodeData = (nodeId: string, newData: any) => {
setNodes((prevNodes) =>
prevNodes.map((node) =>
node.id === nodeId ? { ...node, data: { ...node.data, ...newData } } : node
)
);
};
const addModel = (model: Model) => setModels((prev) => [...prev, model]);
const updateModel = (model: Model) => setModels((prev) => prev.map((m) => (m.id === model.id ? model : m)));
const addRole = (role: Role) => setRoles((prev) => [...prev, role]);
const updateRole = (role: Role) => setRoles((prev) => prev.map((r) => (r.id === role.id ? role : r)));
const deleteRole = (roleId: string) => setRoles((prev) => prev.filter((r) => r.id !== roleId));
const addRoute = (route: Route) => setRoutes((prev) => [...prev, route]);
const updateRoute = (route: Route) => setRoutes((prev) => prev.map((r) => (r.id === route.id ? route : r)));
const deleteRoute = (routeId: string) => setRoutes((prev) => prev.filter((r) => r.id !== routeId));
const updateNode = (nodeId: string, newData: any) => {
console.log("Updating node in store:", nodeId, newData);
updateNodeData(nodeId, newData);
};
return (
<FlowContext.Provider
value={{
nodes,
edges,
selectedNode,
models,
roles,
routes,
settings,
defaultTablesShown,
setNodes,
setEdges,
setSelectedNode,
updateNodeData,
addModel,
updateModel,
addRole,
updateRole,
deleteRole,
addRoute,
updateRoute,
deleteRoute,
updateSettings: setSettings,
setDefaultTablesShown,
updateNode,
}}
>
{children}
</FlowContext.Provider>
);
};
export const useFlowContext = () => {
const context = useContext(FlowContext);
if (!context) {
throw new Error("useFlowContext must be used within a FlowProvider");
}
return context;
};
@@ -30,11 +30,11 @@ interface Role {
interface Route {
id: string;
name: string;
url: string;
method: string;
url: string;
flowData?: {
nodes: Node[];
edges: Edge[];
nodes: any[];
edges: any[];
};
}
@@ -73,6 +73,7 @@ interface FlowState {
deleteRoute: (routeId: string) => void;
updateSettings: (settings: Settings) => void;
setDefaultTablesShown: (shown: boolean) => void;
updateNode: (nodeId: string, newData: any) => void;
}
export const useFlowStore = create<FlowState>((set) => ({
@@ -145,4 +146,20 @@ export const useFlowStore = create<FlowState>((set) => ({
})),
updateSettings: (settings) => set({ settings }),
setDefaultTablesShown: (shown) => set({ defaultTablesShown: shown }),
updateNode: (nodeId: string, newData: any) => {
console.log("Updating node in store:", nodeId, newData);
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === nodeId
? {
...node,
data: {
...node.data,
...newData,
},
}
: node
),
}));
},
}));
View File
+3 -3
View File
@@ -1,10 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
exclude: ["lucide-react"],
},
});
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4106
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.3",
"prismjs": "^1.29.0"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/prismjs": "^1.26.3",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Landing } from './pages/Landing';
import { Editor } from './pages/Editor';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/editor" element={<Editor />} />
</Routes>
</BrowserRouter>
);
}
export default App;
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { ChevronRight } from 'lucide-react';
interface BreadcrumbProps {
path: string;
}
export function Breadcrumb({ path }: BreadcrumbProps) {
const parts = path.split('/').filter(Boolean);
return (
<div className="flex items-center gap-1 px-3 py-1 bg-[#1E1E1E] text-gray-400 text-sm border-b border-gray-800">
{parts.map((part, index) => (
<React.Fragment key={index}>
{index > 0 && <ChevronRight className="w-3 h-3" />}
<span className="hover:text-white cursor-pointer">
{part}
</span>
</React.Fragment>
))}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import { MessageSquare, Send } from 'lucide-react';
export function Chat() {
const [messages, setMessages] = React.useState([
{ role: 'assistant', content: 'Hello! I\'m Bolt, your AI programming assistant. How can I help you today?' }
]);
return (
<div className="h-full flex flex-col bg-white">
<div className="flex items-center gap-2 p-3 border-b">
<MessageSquare className="w-5 h-5" />
<span className="font-medium">Chat</span>
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{messages.map((message, i) => (
<div
key={i}
className={`flex ${
message.role === 'assistant' ? 'justify-start' : 'justify-end'
}`}
>
<div
className={`max-w-[80%] rounded-lg p-3 ${
message.role === 'assistant'
? 'bg-gray-100'
: 'bg-blue-500 text-white'
}`}
>
{message.content}
</div>
</div>
))}
</div>
<div className="p-4 border-t">
<div className="flex gap-2">
<input
type="text"
placeholder="Ask me anything..."
className="flex-1 px-3 py-2 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button className="p-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors">
<Send className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
import Prism from 'prismjs';
import 'prismjs/themes/prism-tomorrow.css';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-tsx';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
}
export function CodeEditor({ value, onChange }: CodeEditorProps) {
const lines = value.split('\n');
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const [highlighted, setHighlighted] = React.useState('');
React.useEffect(() => {
const highlighted = Prism.highlight(
value,
Prism.languages.tsx,
'tsx'
);
setHighlighted(highlighted);
}, [value]);
return (
<div className="relative h-full bg-[#1E1E1E] text-gray-300 font-mono text-sm">
<div className="absolute left-0 top-0 bottom-0 w-12 flex flex-col items-end pr-2 pt-4 text-gray-500 select-none bg-[#1E1E1E] border-r border-gray-800">
{lines.map((_, i) => (
<div key={i} className="leading-6">
{i + 1}
</div>
))}
</div>
<pre className="absolute left-12 right-0 top-0 bottom-0 m-0 p-4 overflow-hidden pointer-events-none">
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
</pre>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full h-full pl-14 pr-4 pt-4 bg-transparent resize-none focus:outline-none leading-6 text-transparent caret-white"
spellCheck={false}
/>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { FileTree } from './FileTree';
import { CodeEditor } from './CodeEditor';
import { EditorTabs } from './EditorTabs';
import { EditorNavBar } from './EditorNavBar';
import { Breadcrumb } from './Breadcrumb';
export function Editor() {
const [code, setCode] = React.useState(`// Your code will appear here
import React from 'react';
import { Prompt } from './components/Prompt';
import { Chat } from './components/Chat';
import { Editor } from './components/Editor';
function App() {
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<header className="bg-white border-b">
<div className="text-xl font-bold text-blue-500">bolt.new</div>
</header>
<Prompt />
<main className="flex-1 grid grid-cols-2 gap-4 p-4">
<Chat />
<Editor />
</main>
</div>
);
}
export default App;`);
const [currentFile, setCurrentFile] = React.useState('/src/App.tsx');
return (
<>
<EditorTabs />
<EditorNavBar />
<div className="h-full flex bg-[#1E1E1E]">
<FileTree onFileSelect={(path) => {
setCurrentFile(path);
// In a real app, we would load the file content here
}} />
<div className="flex-1 flex flex-col">
<Breadcrumb path={currentFile} />
<CodeEditor
value={code}
onChange={setCode}
/>
</div>
</div>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import { RefreshCw } from 'lucide-react';
export function EditorNavBar() {
return (
<div className="flex items-center gap-2 p-2 bg-[#1E1E1E] border-b border-gray-800">
<input
type="text"
value="http://localhost:5173"
readOnly
className="flex-1 px-3 py-1 text-sm bg-[#2D2D2D] text-gray-300 rounded border border-gray-800 focus:outline-none focus:border-gray-600"
/>
<button className="p-1.5 text-gray-400 hover:text-white rounded hover:bg-[#2D2D2D] transition-colors">
<RefreshCw className="w-4 h-4" />
</button>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
export function EditorTabs() {
return (
<div className="flex items-center bg-[#252526] border-b border-gray-800">
<button className="px-4 py-2 text-sm text-white bg-[#1E1E1E] border-r border-gray-800">
Code
</button>
<button className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors">
Preview
</button>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import { ChevronDown, File, Folder } from 'lucide-react';
interface FileTreeProps {
onFileSelect: (path: string) => void;
}
export function FileTree({ onFileSelect }: FileTreeProps) {
const [expandedFolders, setExpandedFolders] = React.useState<Set<string>>(new Set(['src', 'components']));
const toggleFolder = (folder: string) => {
setExpandedFolders(prev => {
const next = new Set(prev);
if (next.has(folder)) {
next.delete(folder);
} else {
next.add(folder);
}
return next;
});
};
return (
<div className="h-full w-64 bg-[#1E1E1E] text-gray-300 border-r border-gray-800">
<div
className="flex items-center gap-2 p-2 text-sm cursor-pointer hover:bg-[#2D2D2D]"
onClick={() => toggleFolder('src')}
>
<span className="flex items-center gap-1">
<ChevronDown className={`w-4 h-4 transition-transform ${
expandedFolders.has('src') ? '' : '-rotate-90'
}`} />
<Folder className="w-4 h-4" />
src
</span>
</div>
{expandedFolders.has('src') && <div className="pl-4">
<div
className="flex items-center gap-2 p-2 text-sm cursor-pointer hover:bg-[#2D2D2D]"
onClick={() => toggleFolder('components')}
>
<span className="flex items-center gap-1">
<ChevronDown className={`w-4 h-4 transition-transform ${
expandedFolders.has('components') ? '' : '-rotate-90'
}`} />
<Folder className="w-4 h-4" />
components
</span>
</div>
{expandedFolders.has('components') && <div className="pl-4">
<div
className="flex items-center gap-2 p-2 text-sm text-gray-400 cursor-pointer hover:bg-[#2D2D2D]"
onClick={() => onFileSelect('/src/components/Chat.tsx')}
>
<File className="w-4 h-4" />
Chat.tsx
</div>
<div
className="flex items-center gap-2 p-2 text-sm text-gray-400 cursor-pointer hover:bg-[#2D2D2D]"
onClick={() => onFileSelect('/src/components/Editor.tsx')}
>
<File className="w-4 h-4" />
Editor.tsx
</div>
<div
className="flex items-center gap-2 p-2 text-sm text-gray-400 cursor-pointer hover:bg-[#2D2D2D]"
onClick={() => onFileSelect('/src/components/Prompt.tsx')}
>
<File className="w-4 h-4" />
Prompt.tsx
</div>
</div>}
<div
className="flex items-center gap-2 p-2 text-sm cursor-pointer hover:bg-[#2D2D2D] bg-[#2D2D2D] border-l-2 border-blue-500"
onClick={() => onFileSelect('/src/App.tsx')}
>
<File className="w-4 h-4" />
App.tsx
</div>
</div>}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import { Sparkles } from 'lucide-react';
export function Prompt() {
return (
<div className="border-b bg-white">
<div className="max-w-screen-xl mx-auto p-4">
<div className="flex items-start gap-4">
<textarea
className="flex-1 p-3 h-32 rounded-lg border resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Describe what you want to build..."
/>
<button className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<Sparkles className="w-5 h-5" />
Generate
</button>
</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
interface TextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function TextEditor({ value, onChange, placeholder }: TextEditorProps) {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};
return (
<textarea
value={value}
onChange={handleChange}
placeholder={placeholder}
className="w-full min-h-[100px] p-5 bg-white border border-[#E5E5E5] rounded-lg shadow-[0_2px_4px_rgba(0,0,0,0.1)] text-[#333333] text-base leading-relaxed resize-y font-sans placeholder:text-gray-400 hover:border-[#D1D1D1] focus:border-[#D1D1D1] focus:outline-none transition-colors duration-200"
/>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import { Chat } from '../components/Chat';
import { Editor as CodeEditor } from '../components/Editor';
export function Editor() {
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<header className="bg-white border-b">
<div className="max-w-screen-xl mx-auto px-4 py-3">
<div className="text-xl font-bold text-blue-500">Coding Challenge</div>
</div>
</header>
<main className="flex-1 grid grid-cols-[25%_75%] gap-4 p-4">
<div className="h-[calc(100vh-5rem)] rounded-lg border shadow-sm overflow-hidden">
<Chat />
</div>
<div className="h-[calc(100vh-5rem)] rounded-lg border shadow-sm overflow-hidden">
<CodeEditor />
</div>
</main>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Sparkles } from 'lucide-react';
export function Landing() {
const navigate = useNavigate();
const [prompt, setPrompt] = React.useState('');
const handleGenerate = () => {
if (prompt.trim()) {
navigate('/editor');
}
};
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<header className="bg-white border-b">
<div className="max-w-screen-xl mx-auto px-4 py-3">
<div className="text-xl font-bold text-blue-500">Coding Challenge</div>
</div>
</header>
<main className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-3xl space-y-4">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full p-4 h-40 text-lg rounded-lg border resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Describe what you want to build..."
/>
<div className="flex justify-end">
<button
onClick={handleGenerate}
className="flex items-center gap-2 px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-lg"
>
<Sparkles className="w-5 h-5" />
Generate
</button>
</div>
</div>
</main>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ["lucide-react"],
},
});
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5360
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.3",
"zustand": "^4.5.2",
"react-dropzone": "^14.2.3",
"react-pdf": "^7.7.1",
"@react-pdf/renderer": "^3.4.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { UploadPage } from './pages/UploadPage';
import { RecipientsPage } from './pages/RecipientsPage';
import { EditorPage } from './pages/EditorPage';
import { SummaryPage } from './pages/SummaryPage';
import { SigningPage } from './pages/SigningPage';
function App() {
return (
<Router>
<div className="min-h-screen bg-gray-100">
<Routes>
<Route path="/" element={<Navigate to="/upload" replace />} />
<Route path="/upload" element={<UploadPage />} />
<Route path="/recipients" element={<RecipientsPage />} />
<Route path="/editor" element={<EditorPage />} />
<Route path="/summary" element={<SummaryPage />} />
<Route path="/signing/:documentId" element={<SigningPage />} />
</Routes>
</div>
</Router>
);
}
export default App;
+75
View File
@@ -0,0 +1,75 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Send, Eye, Trash2 } from 'lucide-react';
import { Document } from '../types';
import { formatDate } from '../utils/dateUtils';
interface DocumentTableProps {
documents: Document[];
onDelete: (documentId: string) => void;
}
export const DocumentTable: React.FC<DocumentTableProps> = ({ documents, onDelete }) => {
const navigate = useNavigate();
return (
<div className="overflow-x-auto">
<table className="min-w-full bg-white rounded-lg overflow-hidden">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Document</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Recipients</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{documents.map((doc) => (
<tr key={doc.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{doc.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{formatDate(doc.uploadDate)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${doc.status === 'completed' ? 'bg-green-100 text-green-800' :
doc.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'}`}>
{doc.status.charAt(0).toUpperCase() + doc.status.slice(1)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{doc.recipients.length} recipient(s)
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
onClick={() => navigate(`/signing/${doc.id}`)}
className="text-blue-600 hover:text-blue-900"
>
<Send className="w-4 h-4" />
</button>
<button
onClick={() => navigate(`/editor/${doc.id}`)}
className="text-gray-600 hover:text-gray-900"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(doc.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import { PenLine, Type, Calendar, CheckSquare, Edit } from 'lucide-react';
import { DocumentField } from '../types';
interface DraggableFieldProps {
type: DocumentField['type'];
}
const fieldIcons = {
signature: PenLine,
text: Type,
date: Calendar,
checkbox: CheckSquare,
initial: Edit,
};
export const DraggableField: React.FC<DraggableFieldProps> = ({
type
}) => {
const Icon = fieldIcons[type];
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData('fieldType', type);
};
return (
<div
draggable
onDragStart={handleDragStart}
className="flex items-center p-3 mb-2 bg-white rounded-lg shadow-sm cursor-move hover:bg-gray-50 transition-colors"
>
<Icon className="w-5 h-5 mr-2 text-blue-600" />
<span className="capitalize">{type}</span>
</div>
);
};
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import { DraggableField } from './DraggableField';
import type { DocumentField } from '../types';
export const EditorSidebar: React.FC = () => {
const fieldTypes: DocumentField['type'][] = [
'signature',
'text',
'date',
'checkbox',
'initial',
];
return (
<div className="w-64 bg-white p-4 border-r">
<h3 className="text-lg font-semibold mb-4">Form Fields</h3>
<div className="space-y-2">
{fieldTypes.map((type) => (
<DraggableField key={type} type={type} />
))}
</div>
</div>
);
};
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
interface PDFViewerProps {
file: File;
onLoadSuccess?: (numPages: number) => void;
pageNumber: number;
onPageChange?: (pageNumber: number) => void;
scale?: number;
}
export const PDFViewer: React.FC<PDFViewerProps> = ({
file,
onLoadSuccess,
pageNumber,
onPageChange,
scale = 1,
}) => {
const handlePageChange = (direction: 'prev' | 'next') => {
if (onPageChange) {
onPageChange(direction === 'next' ? pageNumber + 1 : pageNumber - 1);
}
};
return (
<div className="pdf-viewer">
<Document
file={file}
onLoadSuccess={({ numPages }) => onLoadSuccess?.(numPages)}
className="max-w-full"
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={false}
renderAnnotationLayer={false}
className="shadow-lg"
/>
</Document>
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center space-x-4 bg-white rounded-lg shadow px-4 py-2">
<button
onClick={() => handlePageChange('prev')}
disabled={pageNumber <= 1}
className="text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
Previous
</button>
<span className="text-sm text-gray-600">Page {pageNumber}</span>
<button
onClick={() => handlePageChange('next')}
className="text-gray-600 hover:text-gray-900"
>
Next
</button>
</div>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import { Check } from 'lucide-react';
import { useLocation } from 'react-router-dom';
const steps = [
{ path: '/upload', label: 'Upload' },
{ path: '/recipients', label: 'Recipients' },
{ path: '/editor', label: 'Editor' },
{ path: '/summary', label: 'Summary' },
];
export const ProgressBar: React.FC = () => {
const location = useLocation();
const currentStepIndex = steps.findIndex((step) => step.path === location.pathname);
return (
<div className="w-full max-w-3xl mx-auto mb-8">
<div className="flex justify-between">
{steps.map((step, index) => (
<div
key={step.path}
className="flex flex-col items-center relative flex-1"
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
index <= currentStepIndex
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-500'
}`}
>
{index < currentStepIndex ? (
<Check className="w-5 h-5" />
) : (
index + 1
)}
</div>
<div className="text-sm mt-2">{step.label}</div>
{index < steps.length - 1 && (
<div
className={`absolute top-4 -right-1/2 h-0.5 w-full ${
index < currentStepIndex ? 'bg-blue-600' : 'bg-gray-200'
}`}
/>
)}
</div>
))}
</div>
</div>
);
};
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import { PenLine, Type, Calendar, CheckSquare, Edit } from 'lucide-react';
import { DocumentField } from '../types';
interface SigningFieldProps {
field: DocumentField;
onChange: (fieldId: string, value: string) => void;
}
const fieldIcons = {
signature: PenLine,
text: Type,
date: Calendar,
checkbox: CheckSquare,
initial: Edit,
};
export const SigningField: React.FC<SigningFieldProps> = ({ field, onChange }) => {
const Icon = fieldIcons[field.type];
const renderInput = () => {
switch (field.type) {
case 'signature':
case 'initial':
return (
<button
className="w-full h-full min-h-[40px] border-2 border-dashed border-blue-500 rounded flex items-center justify-center text-blue-600 hover:bg-blue-50"
onClick={() => onChange(field.id, 'Signed')}
>
<Icon className="w-5 h-5 mr-2" />
Click to {field.type}
</button>
);
case 'date':
return (
<input
type="date"
onChange={(e) => onChange(field.id, e.target.value)}
className="w-full p-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
);
case 'checkbox':
return (
<input
type="checkbox"
onChange={(e) => onChange(field.id, e.target.checked ? 'true' : 'false')}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
);
default:
return (
<input
type="text"
onChange={(e) => onChange(field.id, e.target.value)}
className="w-full p-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={`Enter ${field.type}`}
/>
);
}
};
return (
<div
style={{
position: 'absolute',
left: field.position.x,
top: field.position.y,
width: field.size.width,
height: field.size.height,
}}
className="bg-white shadow-sm"
>
{renderInput()}
{field.required && (
<span className="absolute -top-2 -right-2 text-red-500">*</span>
)}
</div>
);
};
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
+132
View File
@@ -0,0 +1,132 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Save, GripHorizontal } from 'lucide-react';
import { useDocumentStore } from '../store/documentStore';
import { ProgressBar } from '../components/ProgressBar';
import { PDFViewer } from '../components/PDFViewer';
import { EditorSidebar } from '../components/EditorSidebar';
import type { DocumentField } from '../types';
const FIELD_DEFAULT_SIZES = {
signature: { width: 200, height: 50 },
text: { width: 200, height: 40 },
date: { width: 150, height: 40 },
checkbox: { width: 30, height: 30 },
initial: { width: 100, height: 50 },
};
export const EditorPage: React.FC = () => {
const navigate = useNavigate();
const { currentDocument, addField } = useDocumentStore();
const [selectedField, setSelectedField] = useState<DocumentField | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (!currentDocument || !containerRef.current) return;
const type = e.dataTransfer.getData('fieldType') as DocumentField['type'];
const rect = containerRef.current.getBoundingClientRect();
// Get the scroll position of the container
const scrollX = containerRef.current.scrollLeft;
const scrollY = containerRef.current.scrollTop;
// Calculate position relative to the container
const x = ((e.clientX - rect.left + scrollX) / rect.width) * 100;
const y = ((e.clientY - rect.top + scrollY) / rect.height) * 100;
if (type) {
const newField: DocumentField = {
id: crypto.randomUUID(),
type,
recipientId: currentDocument.recipients[0].id,
position: { x: Math.max(0, Math.min(x, 100)), y: Math.max(0, Math.min(y, 100)) },
size: FIELD_DEFAULT_SIZES[type],
required: true,
page: currentPage,
};
addField(currentDocument.id, newField);
setSelectedField(newField);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleSave = () => {
navigate('/summary');
};
if (!currentDocument) {
navigate('/upload');
return null;
}
return (
<div className="min-h-screen flex flex-col">
<div className="container mx-auto px-4 py-4">
<ProgressBar />
</div>
<div className="flex flex-1">
<EditorSidebar />
<div className="flex-1 p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold">Document Editor</h2>
<button
onClick={handleSave}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Save className="w-4 h-4 mr-2" />
Save
</button>
</div>
<div
ref={containerRef}
className="bg-gray-100 rounded-lg p-4 flex justify-center relative overflow-auto min-h-[600px]"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<div className="relative">
{currentDocument.file && (
<PDFViewer
file={currentDocument.file}
pageNumber={currentPage}
onPageChange={setCurrentPage}
onLoadSuccess={setTotalPages}
/>
)}
{currentDocument.fields
.filter(field => field.page === currentPage)
.map(field => (
<div
key={field.id}
style={{
position: 'absolute',
left: `${field.position.x}%`,
top: `${field.position.y}%`,
width: field.size.width,
height: field.size.height,
}}
className="border-2 border-blue-500 bg-white/80 rounded-md shadow-sm"
>
<div className="text-xs bg-blue-500 text-white px-2 py-1 flex items-center justify-between rounded-t-sm">
<span className="capitalize">{field.type}</span>
<GripHorizontal className="w-3 h-3 cursor-move" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
+114
View File
@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Trash2, Mail, User } from 'lucide-react';
import { useDocumentStore } from '../store/documentStore';
import { ProgressBar } from '../components/ProgressBar';
import type { Recipient } from '../types';
export const RecipientsPage: React.FC = () => {
const navigate = useNavigate();
const { currentDocument, addRecipient, removeRecipient } = useDocumentStore();
const [newRecipient, setNewRecipient] = useState({ name: '', email: '' });
const handleAddRecipient = () => {
if (currentDocument && newRecipient.name && newRecipient.email) {
const recipient: Recipient = {
id: crypto.randomUUID(),
...newRecipient
};
addRecipient(currentDocument.id, recipient);
setNewRecipient({ name: '', email: '' });
}
};
const handleSubmit = () => {
if (currentDocument?.recipients.length > 0) {
navigate('/editor');
}
};
if (!currentDocument) {
navigate('/upload');
return null;
}
return (
<div className="container mx-auto px-4 py-8">
<ProgressBar />
<div className="max-w-2xl mx-auto">
<h2 className="text-2xl font-semibold mb-6">Add Recipients</h2>
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="relative">
<User className="absolute left-3 top-3 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Full Name"
value={newRecipient.name}
onChange={(e) => setNewRecipient(prev => ({ ...prev, name: e.target.value }))}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
<div className="relative">
<Mail className="absolute left-3 top-3 w-5 h-5 text-gray-400" />
<input
type="email"
placeholder="Email Address"
value={newRecipient.email}
onChange={(e) => setNewRecipient(prev => ({ ...prev, email: e.target.value }))}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
</div>
<button
onClick={handleAddRecipient}
disabled={!newRecipient.name || !newRecipient.email}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4 mr-2" />
Add Recipient
</button>
</div>
{currentDocument.recipients.length > 0 && (
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 className="text-lg font-medium mb-4">Recipients</h3>
<div className="space-y-3">
{currentDocument.recipients.map((recipient) => (
<div
key={recipient.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center">
<User className="w-5 h-5 text-gray-400 mr-3" />
<div>
<p className="font-medium">{recipient.name}</p>
<p className="text-sm text-gray-500">{recipient.email}</p>
</div>
</div>
<button
onClick={() => removeRecipient(currentDocument.id, recipient.id)}
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end">
<button
onClick={handleSubmit}
disabled={currentDocument.recipients.length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue to Editor
</button>
</div>
</div>
</div>
);
};
+79
View File
@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useDocumentStore } from '../store/documentStore';
import { PDFViewer } from '../components/PDFViewer';
import { SigningField } from '../components/SigningField';
export const SigningPage: React.FC = () => {
const { documentId } = useParams<{ documentId: string }>();
const navigate = useNavigate();
const { documents, updateDocument } = useDocumentStore();
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const document = documents.find(d => d.id === documentId);
if (!document) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center">Document not found</div>
</div>
);
}
const handleFieldChange = (fieldId: string, value: string) => {
setFieldValues(prev => ({
...prev,
[fieldId]: value
}));
};
const handleComplete = () => {
const updatedDocument = {
...document,
status: 'completed' as const,
fields: document.fields.map(field => ({
...field,
value: fieldValues[field.id] || ''
}))
};
updateDocument(updatedDocument);
navigate('/summary');
};
const allFieldsFilled = document.fields
.filter(f => f.required)
.every(f => fieldValues[f.id]);
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-semibold mb-6">Sign Document: {document.name}</h1>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="relative">
{document.file && (
<PDFViewer file={document.file} />
)}
{document.fields.map((field) => (
<SigningField
key={field.id}
field={field}
onChange={handleFieldChange}
/>
))}
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleComplete}
disabled={!allFieldsFilled}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Complete Signing
</button>
</div>
</div>
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { useDocumentStore } from '../store/documentStore';
import { DocumentTable } from '../components/DocumentTable';
export const SummaryPage: React.FC = () => {
const navigate = useNavigate();
const { documents } = useDocumentStore();
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold">Documents</h1>
<button
onClick={() => navigate('/upload')}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4 mr-2" />
New Document
</button>
</div>
{documents.length > 0 ? (
<DocumentTable
documents={documents}
onDelete={(id) => console.log('Delete document:', id)}
/>
) : (
<div className="text-center py-12 bg-white rounded-lg">
<p className="text-gray-500">No documents yet. Start by uploading a new document.</p>
</div>
)}
</div>
);
};
+65
View File
@@ -0,0 +1,65 @@
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDropzone } from 'react-dropzone';
import { Upload } from 'lucide-react';
import { useDocumentStore } from '../store/documentStore';
import { ProgressBar } from '../components/ProgressBar';
export const UploadPage: React.FC = () => {
const navigate = useNavigate();
const { addDocument, setCurrentDocument } = useDocumentStore();
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (file) {
const newDocument = {
id: crypto.randomUUID(),
name: file.name,
uploadDate: new Date(),
status: 'draft' as const,
recipients: [],
fields: [],
file,
};
addDocument(newDocument);
setCurrentDocument(newDocument);
}
}, [addDocument, setCurrentDocument]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
},
multiple: false,
});
return (
<div className="container mx-auto px-4 py-8">
<ProgressBar />
<div className="max-w-2xl mx-auto">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-12 text-center ${
isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
>
<input {...getInputProps()} />
<Upload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-xl mb-2">
{isDragActive
? 'Drop your PDF here'
: 'Drag and drop your PDF here, or click to select'}
</p>
<p className="text-sm text-gray-500">Supported format: PDF</p>
</div>
<button
onClick={() => navigate('/recipients')}
className="mt-6 w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Next
</button>
</div>
</div>
);
};
+86
View File
@@ -0,0 +1,86 @@
import { create } from 'zustand';
import { Document, Recipient, DocumentField } from '../types';
interface DocumentStore {
currentDocument: Document | null;
documents: Document[];
setCurrentDocument: (document: Document | null) => void;
addDocument: (document: Document) => void;
updateDocument: (document: Document) => void;
addRecipient: (documentId: string, recipient: Recipient) => void;
removeRecipient: (documentId: string, recipientId: string) => void;
addField: (documentId: string, field: DocumentField) => void;
updateField: (documentId: string, field: DocumentField) => void;
removeField: (documentId: string, fieldId: string) => void;
}
export const useDocumentStore = create<DocumentStore>((set) => ({
currentDocument: null,
documents: [],
setCurrentDocument: (document) => set({ currentDocument: document }),
addDocument: (document) =>
set((state) => ({ documents: [...state.documents, document] })),
updateDocument: (document) =>
set((state) => ({
documents: state.documents.map((d) =>
d.id === document.id ? document : d
),
})),
addRecipient: (documentId, recipient) =>
set((state) => ({
documents: state.documents.map((d) =>
d.id === documentId
? { ...d, recipients: [...d.recipients, recipient] }
: d
),
currentDocument: state.currentDocument?.id === documentId
? { ...state.currentDocument, recipients: [...state.currentDocument.recipients, recipient] }
: state.currentDocument
})),
removeRecipient: (documentId, recipientId) =>
set((state) => ({
documents: state.documents.map((d) =>
d.id === documentId
? {
...d,
recipients: d.recipients.filter((r) => r.id !== recipientId),
}
: d
),
currentDocument: state.currentDocument?.id === documentId
? { ...state.currentDocument, recipients: state.currentDocument.recipients.filter((r) => r.id !== recipientId) }
: state.currentDocument
})),
addField: (documentId, field) =>
set((state) => ({
documents: state.documents.map((d) =>
d.id === documentId
? { ...d, fields: [...d.fields, field] }
: d
),
})),
updateField: (documentId, field) =>
set((state) => ({
documents: state.documents.map((d) =>
d.id === documentId
? {
...d,
fields: d.fields.map((f) =>
f.id === field.id ? field : f
),
}
: d
),
})),
removeField: (documentId, fieldId) =>
set((state) => ({
documents: state.documents.map((d) =>
d.id === documentId
? {
...d,
fields: d.fields.filter((f) => f.id !== fieldId),
}
: d
),
})),
}));
+26
View File
@@ -0,0 +1,26 @@
export interface Recipient {
id: string;
name: string;
email: string;
}
export interface DocumentField {
id: string;
type: 'signature' | 'text' | 'date' | 'checkbox' | 'initial';
recipientId: string;
position: { x: number; y: number };
size: { width: number; height: number };
required: boolean;
page: number;
value?: string;
}
export interface Document {
id: string;
name: string;
uploadDate: Date;
status: 'draft' | 'pending' | 'completed';
recipients: Recipient[];
fields: DocumentField[];
file: File | null;
}
+9
View File
@@ -0,0 +1,9 @@
export const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

Some files were not shown because too many files have changed in this diff Show More