updated task

This commit is contained in:
ryanwong
2024-12-09 05:08:35 -05:00
parent 167e9fe6cd
commit 05811962b2
97 changed files with 11496 additions and 13 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.
+57 -2
View File
@@ -1,3 +1,58 @@
# mkd-backend-flow-builder
# This project is a toy project for training and quality assurance purposes
## Task
All this task must be done in 1 day
- go into folder task_1 and setup the project
- click route and add a new route
- click the <> icon and see the editor for the route
- click on URL component in the editor, the right side bar will pop up
- try typing into Body Field new field input, nothing happens. Fix it.
- try typing into Query Field new field input, nothing happens. Fix it.
- 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.
- 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
- 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.
- when I click the files in the code editor on sidebar, nothing happens. Fix it.
- 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.
- the lines are overlapping the circles in the wizard steps. Fix it.
- 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.
- 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.
- 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
[Edit in StackBlitz next generation editor ⚡️](https://stackblitz.com/~/github.com/mytechpassport/mkd-backend-flow-builder)
View File
View File
View File
View File
View File
+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: {},
},
};
+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: {},
},
};
+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'],
},
});
+13
View File
@@ -0,0 +1,13 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
+102
View File
@@ -0,0 +1,102 @@
/**
* TODO:
* - create basic express server
* - connect to mysql database
* - create proper docker compose file and dockerfile to setup mysql, express server
* - make api to look like this:
* /v1/api/rest/video/PAGINATE
Method POST
body
{
"payload": {},
"page": 1,
"limit": 10
}
Response
http code 200
{
"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,
"username": "boss",
"create_at": "2022-01-01",
"update_at": "2022-01-01T04:00:00.000Z",
"like": 10
}
],
"page": 1,
"limit": 10,
"total": 112,
"num_pages": 12
}
*/
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
// Middleware to parse JSON bodies
app.use(express.json());
// Database connection configuration
const dbConfig = {
host: process.env.DB_HOST || 'mysql',
user: process.env.DB_USER || 'user',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'videodb'
};
// API endpoint for video pagination
app.get('/', async (req, res) => {
res.send('Hello World');
});
app.post('/v1/api/rest/video/PAGINATE', async (req, res) => {
try {
const page = parseInt(req.body.page) || 1;
const limit = parseInt(req.body.limit) || 10;
const offset = (page - 1) * limit;
const connection = await mysql.createConnection(dbConfig);
// Get total count
const [countResult] = await connection.execute(
'SELECT COUNT(*) as total FROM videos'
);
const total = countResult[0].total;
// Fix: Use a different query format for LIMIT
const [rows] = await connection.query(
`SELECT
v.id, v.title, v.photo, v.user_id,
v.created_at, v.updated_at, v.likes
FROM videos v
LIMIT ${offset}, ${limit}`
);
await connection.end();
res.json({
error: false,
list: rows,
page: page,
limit: limit,
total,
num_pages: Math.ceil(total / limit)
});
} catch (error) {
console.error('Error:', error);
res.status(500).json({ error: true, message: 'Internal server error' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DB_HOST=mysql
- DB_USER=user
- DB_PASSWORD=password
- DB_NAME=videodb
volumes:
- .:/app
- /app/node_modules
depends_on:
- mysql
command: npm run dev
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: videodb
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: rootpassword
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
mysql-data:
+103
View File
@@ -0,0 +1,103 @@
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE videos (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
photo VARCHAR(255),
user_id INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
likes INT DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Insert some sample data
INSERT INTO users (username) VALUES ('boss');
INSERT INTO videos (title, photo, user_id, likes) VALUES
('Rune raises $100,000 for marketing through NFT butterflies sale', 'https://picsum.photos/200/200', 1, 10),
('Exploring the future of NFTs in gaming', 'https://picsum.photos/200/201', 1, 15),
('How to create a successful YouTube channel', 'https://picsum.photos/200/202', 1, 20),
('The rise of remote work: Pros and cons', 'https://picsum.photos/200/203', 1, 5),
('Top 10 programming languages to learn in 2023', 'https://picsum.photos/200/204', 1, 8),
('Understanding blockchain technology', 'https://picsum.photos/200/205', 1, 12),
('The impact of AI on the job market', 'https://picsum.photos/200/206', 1, 7),
('How to invest in cryptocurrency safely', 'https://picsum.photos/200/207', 1, 9),
('The benefits of meditation for mental health', 'https://picsum.photos/200/208', 1, 11),
('Traveling on a budget: Tips and tricks', 'https://picsum.photos/200/209', 1, 6),
('The importance of cybersecurity in 2023', 'https://picsum.photos/200/210', 1, 14),
('How to build a personal brand online', 'https://picsum.photos/200/211', 1, 13),
('The future of electric vehicles', 'https://picsum.photos/200/212', 1, 10),
('Exploring the metaverse: What you need to know', 'https://picsum.photos/200/213', 1, 15),
('The best productivity tools for remote teams', 'https://picsum.photos/200/214', 1, 20),
('How to create engaging content for social media', 'https://picsum.photos/200/215', 1, 5),
('The role of data science in business', 'https://picsum.photos/200/216', 1, 8),
('Understanding the gig economy', 'https://picsum.photos/200/217', 1, 12),
('The benefits of learning a second language', 'https://picsum.photos/200/218', 1, 7),
('How to stay motivated while working from home', 'https://picsum.photos/200/219', 1, 9),
('The impact of climate change on our planet', 'https://picsum.photos/200/220', 1, 11),
('How to create a successful online course', 'https://picsum.photos/200/221', 1, 6),
('The importance of emotional intelligence in leadership', 'https://picsum.photos/200/222', 1, 14),
('How to network effectively in a digital world', 'https://picsum.photos/200/223', 1, 13),
('The rise of plant-based diets', 'https://picsum.photos/200/224', 1, 10),
('Exploring the world of virtual reality', 'https://picsum.photos/200/225', 1, 15),
('The best practices for remote team management', 'https://picsum.photos/200/226', 1, 20),
('How to create a successful blog', 'https://picsum.photos/200/227', 1, 5),
('The impact of social media on mental health', 'https://picsum.photos/200/228', 1, 8),
('Understanding the basics of SEO', 'https://picsum.photos/200/229', 1, 12),
('The benefits of regular exercise', 'https://picsum.photos/200/230', 1, 7),
('How to manage stress effectively', 'https://picsum.photos/200/231', 1, 9),
('The future of work: Trends to watch', 'https://picsum.photos/200/232', 1, 11),
('How to create a successful marketing strategy', 'https://picsum.photos/200/233', 1, 6),
('The importance of financial literacy', 'https://picsum.photos/200/234', 1, 14),
('How to improve your public speaking skills', 'https://picsum.photos/200/235', 1, 13),
('The rise of e-commerce in 2023', 'https://picsum.photos/200/236', 1, 10),
('Exploring the benefits of mindfulness', 'https://picsum.photos/200/237', 1, 15),
('The best tools for online collaboration', 'https://picsum.photos/200/238', 1, 20),
('How to create a successful YouTube channel', 'https://picsum.photos/200/239', 1, 5),
('The impact of technology on education', 'https://picsum.photos/200/240', 1, 8),
('Understanding the basics of digital marketing', 'https://picsum.photos/200/241', 1, 12),
('The benefits of volunteering', 'https://picsum.photos/200/242', 1, 7),
('How to build a strong online presence', 'https://picsum.photos/200/243', 1, 9),
('The importance of work-life balance', 'https://picsum.photos/200/244', 1, 11),
('How to create a successful podcast', 'https://picsum.photos/200/245', 1, 6),
('The role of technology in healthcare', 'https://picsum.photos/200/246', 1, 14),
('How to stay productive while working from home', 'https://picsum.photos/200/247', 1, 13),
('The rise of remote learning', 'https://picsum.photos/200/248', 1, 10),
('Exploring the world of cryptocurrency', 'https://picsum.photos/200/249', 1, 15),
('The best practices for online security', 'https://picsum.photos/200/250', 1, 20),
('How to create engaging email campaigns', 'https://picsum.photos/200/251', 1, 5),
('The impact of artificial intelligence on society', 'https://picsum.photos/200/252', 1, 8),
('Understanding the basics of web development', 'https://picsum.photos/200/253', 1, 12),
('The benefits of a healthy diet', 'https://picsum.photos/200/254', 1, 7),
('How to manage your time effectively', 'https://picsum.photos/200/255', 1, 9),
('The future of renewable energy', 'https://picsum.photos/200/256', 1, 11),
('How to create a successful business plan', 'https://picsum.photos/200/257', 1, 6),
('The importance of customer service', 'https://picsum.photos/200/258', 1, 14),
('How to improve your writing skills', 'https://picsum.photos/200/259', 1, 13),
('The rise of mobile apps', 'https://picsum.photos/200/260', 1, 10),
('Exploring the benefits of yoga', 'https://picsum.photos/200/261', 1, 15),
('The best tools for project management', 'https://picsum.photos/200/262', 1, 20),
('How to create a successful online store', 'https://picsum.photos/200/263', 1, 5),
('The impact of social media on business', 'https://picsum.photos/200/264', 1, 8),
('Understanding the basics of graphic design', 'https://picsum.photos/200/265', 1, 12),
('The benefits of lifelong learning', 'https://picsum.photos/200/266', 1, 7),
('How to build a strong professional network', 'https://picsum.photos/200/267', 1, 9),
('The importance of adaptability in the workplace', 'https://picsum.photos/200/268', 1, 11),
('How to create a successful fundraising campaign', 'https://picsum.photos/200/269', 1, 6),
('The role of social media in modern marketing', 'https://picsum.photos/200/270', 1, 14),
('How to stay ahead in a competitive job market', 'https://picsum.photos/200/271', 1, 13),
('The rise of subscription services', 'https://picsum.photos/200/272', 1, 10),
('Exploring the world of augmented reality', 'https://picsum.photos/200/273', 1, 15),
('The best practices for content marketing', 'https://picsum.photos/200/274', 1, 20),
('How to create a successful affiliate marketing strategy', 'https://picsum.photos/200/275', 1, 5),
('The impact of globalization on local businesses', 'https://picsum.photos/200/276', 1, 8),
('Understanding the basics of user experience design', 'https://picsum.photos/200/277', 1, 12),
('The benefits of a positive mindset', 'https://picsum.photos/200/278', 1, 7),
('How to manage your finances effectively', 'https://picsum.photos/200/279', 1, 9),
('The future of smart home technology', 'https://picsum.photos/200/280', 1, 11);
+17
View File
@@ -0,0 +1,17 @@
{
"name": "video-api",
"version": "1.0.0",
"description": "Video API with pagination",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js"
},
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.6.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}