Compare commits

...

4 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
105 changed files with 11516 additions and 16 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
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
View File
+3 -3
View File
@@ -1,10 +1,10 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
optimizeDeps: { 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" }
]
}
+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"],
},
});

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