diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..1a089f4 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no-install commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..8659aa2 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +yarn format +yarn lint-staged +yarn typecheck +yarn build \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..15cf6fb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 80, + "jsxSingleQuote": true, + "tabWidth": 2, + "arrowParens": "avoid", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..91b1101 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,5 @@ +compressionLevel: mixed + +enableGlobalCache: false + +nodeLinker: node-modules diff --git a/README.md b/README.md index 686a1af..a8fd0e1 100644 --- a/README.md +++ b/README.md @@ -1 +1,12 @@ -# pdf-text-parsing \ No newline at end of file +## Getting Started + +To ruun the development server: + +```bash +yarn dev +``` + +## TODO + +- edge cases +- tests diff --git a/app/api/parse/route.ts b/app/api/parse/route.ts new file mode 100644 index 0000000..a1175bc --- /dev/null +++ b/app/api/parse/route.ts @@ -0,0 +1,37 @@ +import { Response } from '@/utils/data'; +import { parser } from '@/utils/parser'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + if (!req.headers.get('content-type')?.startsWith('multipart/form-data')) { + return new NextResponse('This API only accepts FormData.', { + status: 415 + }); + } + + const data = await req.formData(); + + const file = data.get('File'); + + if (!file || !(file instanceof File)) { + return new NextResponse('No file provided', { + status: 400 + }); + } + + try { + const parsedText = await parser(file); + + const response: Response = { + text: parsedText + }; + + return new NextResponse(JSON.stringify(response), { + status: 200 + }); + } catch (err) { + return new NextResponse('Failed to parse PDF', { + status: 500 + }); + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..47ef008 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,73 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 255, 255, 255; + --background-end-rgb: 0, 0, 255; +} + +main { + display: flex; + min-height: 100vh; + flex-direction: column; + align-items: center; + justify-content: center; +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom right, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} + +input { + border: 1px solid #ccc; +} + +p { + text-align: center; +} + +.dashboard { + display: flex; + flex-direction: column; + width: 60vw; + height: 80vh; + border: 1px solid #ccc; + border-radius: 15px; + padding: 20px; + box-shadow: 2px 2px 10px rgba(0, 0, 0, 5); + background-color: white; + gap: 20px; + overflow: auto; +} + +.form { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; +} + +.content { + flex: 1; + border: 1px solid #ccc; + overflow: auto; + padding: 10px; +} + +.button { + background-color: #007bff; + color: white; + padding: 10px 20px; + border-radius: 5px; + border: none; + cursor: pointer; + max-width: 200px; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8d2abb5 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'PDF text parser', + description: 'Get the text content a PDF file in the browser (demo)' +}; + +export default function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..51047d4 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import Dashboard from '@/components/Dashboard'; + +export default function Home() { + return ( +
+ +
+ ); +} diff --git a/commitlint.config.ts b/commitlint.config.ts new file mode 100644 index 0000000..c34aa79 --- /dev/null +++ b/commitlint.config.ts @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'] +}; diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..5704d88 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,13 @@ +interface ButtonProps { + label: string; + disabled?: boolean; + onClick: () => void; +} + +export function Button({ onClick, disabled = false, label }: ButtonProps) { + return ( + + ); +} diff --git a/components/Content.tsx b/components/Content.tsx new file mode 100644 index 0000000..727dddd --- /dev/null +++ b/components/Content.tsx @@ -0,0 +1,13 @@ +interface ContentProps { + loading: boolean; + error?: string; + text: string; +} + +export function Content({ loading, error, text }: ContentProps) { + return ( +
+ {loading ? 'Parsing file...' : <>{error ?

{error}

: text}} +
+ ); +} diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx new file mode 100644 index 0000000..ac648b1 --- /dev/null +++ b/components/Dashboard.tsx @@ -0,0 +1,116 @@ +import { responseSchema } from '@/utils/data'; +import axios from 'axios'; +import { useCallback, useState } from 'react'; +import { Button } from './Button'; +import { Content } from './Content'; + +export default function Dashboard() { + const [clearing, setClearing] = useState(false); + const [file, setFile] = useState(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [parsedText, setParsedText] = useState(''); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + if (e.target.files) { + setFile(e.target.files[0]); + } else { + setFile(undefined); + } + }, + [] + ); + + const handleClear = useCallback(() => { + setClearing(false); + setFile(undefined); + setParsedText(''); + }, []); + + const validateFile = useCallback((file: File) => { + if (file.type !== 'application/pdf') { + setError( + "The file you've selected is not a PDF file. Please select a PDF file." + ); + setLoading(false); + + return false; + } + + // This limit is caused by the project being deployed on Vercel using a free tier, with limited resources + if (file.size > 1024 * 1024) { + setError('The file must be smaller than 1MB.'); + setLoading(false); + + return false; + } + + return true; + }, []); + + const uploadFile = useCallback(async () => { + if (!file) return; + + if (!validateFile(file)) return; + + const formData = new FormData(); + + formData.append('File', file); + + try { + const response = await axios.post('/api/parse', formData); + const validatedData = responseSchema.safeParse(response.data); + + if (!validatedData.success) { + setError('There was an error parsing the file. Please try again.'); + return; + } + + setParsedText(validatedData.data.text); + setClearing(true); + } catch { + setError('Internal server error. Please try again later.'); + } finally { + setLoading(false); + } + }, [file, validateFile]); + + const handleUpload = useCallback(() => { + setLoading(true); + setError(undefined); + setParsedText(''); + + uploadFile(); + }, [uploadFile]); + + function renderForm() { + if (clearing) { + return ( + <> +

File: {file?.name}

+