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
+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'],
},
});