updated task
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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?
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
Generated
+4106
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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?
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
Generated
+5360
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
),
|
||||
})),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
+102
@@ -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}`);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user