Created
September 22, 2025 11:34
-
-
Save charanjit-singh/45ec3298afd497b124e2bb21e2962b73 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { S3Client } from "@aws-sdk/client-s3"; | |
const s3 = new S3Client({ | |
region: process.env.AWS_REGION, | |
}); | |
export default s3; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import client from "@/lib/s3/client"; | |
import withWorkspaceAuth from "@/lib/workspaces/withWorkspaceAuth"; | |
import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; | |
import { Role } from "@prisma/client"; | |
import { NextRequest, NextResponse } from "next/server"; | |
import { ObjectId } from "bson"; | |
export const POST = withWorkspaceAuth(async (req: NextRequest, context) => { | |
const contentType = "application/pdf"; | |
const conversionId = new ObjectId().toHexString(); | |
const key = `public/v1/workspaces/${context.workspace.id}/conversions/${conversionId}/uploaded.pdf`; | |
const maxUploadSize = context.plan.quotas.maxFileSize * 1024 * 1024; | |
const { url, fields } = await createPresignedPost(client, { | |
Bucket: process.env.AWS_BUCKET_NAME as string, | |
Key: key, | |
Conditions: [ | |
["content-length-range", 0, maxUploadSize], | |
["starts-with", "$Content-Type", contentType], | |
], | |
Fields: { | |
"Content-Type": contentType, | |
}, | |
Expires: 3600, // Seconds before the presigned post expires. 3600 by default. | |
}); | |
return NextResponse.json({ url, fields, conversionId }); | |
}, Role.USER); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import React, { useRef, useState } from "react"; | |
import { Input } from "@/components/ui/input"; | |
import { Label } from "@/components/ui/label"; | |
import { | |
Card, | |
CardContent, | |
CardDescription, | |
CardFooter, | |
CardHeader, | |
CardTitle, | |
} from "@/components/ui/card"; | |
import { FileUp, AlertCircle, CheckCircle } from "lucide-react"; | |
import { Button } from "@/components/ui/button"; | |
import { useRouter } from "next/navigation"; | |
import useUser from "@/lib/users/useUser"; | |
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; | |
import { CreateConversionInputSchema } from "@/app/api/conversions/create/create-conversion.schema"; | |
function Uploader() { | |
const router = useRouter(); | |
const { user, isLoading } = useUser(); | |
const fileUploaderInputRef = useRef<HTMLInputElement>(null); | |
const [file, setFile] = useState<File | null>(null); | |
const [fileError, setFileError] = useState<string | null>(null); | |
const [uploadStatus, setUploadStatus] = useState< | |
"idle" | "uploading" | "success" | "error" | |
>("idle"); | |
const [uploadProgress, setUploadProgress] = useState(0); | |
const validateFile = (file: File) => { | |
if (!file.name.toLowerCase().endsWith(".pdf")) { | |
setFileError("Please upload a PDF file"); | |
return false; | |
} | |
if (file.size > 10 * 1024 * 1024) { | |
// 10MB limit | |
setFileError("File size should not exceed 10MB"); | |
return false; | |
} | |
// Check other limits here like number of pages, etc. | |
setFileError(null); | |
return true; | |
}; | |
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
const selectedFile = e.target.files?.[0]; | |
if (selectedFile && validateFile(selectedFile)) { | |
setFile(selectedFile); | |
setUploadStatus("idle"); | |
setUploadProgress(0); | |
} else { | |
setUploadStatus("idle"); | |
setUploadProgress(0); | |
setFile(null); | |
} | |
}; | |
const handleUpload = async () => { | |
if (!file) return; | |
setUploadStatus("uploading"); | |
setUploadProgress(2); | |
const numberOfPages = await new Promise((resolve, reject) => { | |
const reader = new FileReader(); | |
reader.readAsBinaryString(file); | |
reader.onloadend = function () { | |
const count = (reader.result as string).match( | |
/\/Type[\s]*\/Page[^s]/g | |
)?.length; | |
resolve(count); | |
}; | |
reader.onerror = reject; | |
}); | |
setUploadProgress(5); | |
// Get presigned url to upload the file | |
// Upload the file to S3 | |
const formData = new FormData(); | |
const createUploadUrlResponse = await fetch("/api/conversions/upload-new", { | |
method: "POST", | |
}).then((res) => res.json()); | |
setUploadProgress(10); | |
if (!createUploadUrlResponse.url) { | |
setUploadStatus("error"); | |
setFileError("Failed to upload file. Please try again."); | |
return; | |
} | |
const fields = createUploadUrlResponse.fields; | |
for (const [key, value] of Object.entries(fields)) { | |
formData.append(key, value as string); | |
} | |
formData.append("file", file); | |
// Upload to S3 | |
const uploadResponse = await fetch(createUploadUrlResponse.url, { | |
method: "POST", | |
body: formData, | |
}); | |
setUploadProgress(60); | |
if (!uploadResponse.ok) { | |
setUploadStatus("error"); | |
setFileError("Failed to upload file. Please try again."); | |
return; | |
} | |
const { conversionId } = createUploadUrlResponse; | |
// Create conversion | |
const conversionCreateBody: CreateConversionInputSchema = { | |
conversionId, | |
fileName: file.name, | |
pdfUrl: createUploadUrlResponse.url + fields.key, | |
numberOfPages: numberOfPages as number, | |
sizeInMB: file.size / (1024 * 1024), | |
}; | |
// Create a new conversion object | |
// with following properties: | |
// - conversion id (using bson) (might not be available) and we'll throw error in this case | |
// - Filename | |
// - File size | |
// - Number of pages | |
// - S3 URL to upload the file | |
const conversionCreateResponse = await fetch("/api/conversions/create", { | |
method: "POST", | |
body: JSON.stringify(conversionCreateBody), | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
}).then((res) => res.json()); | |
setUploadProgress(100); | |
if (conversionCreateResponse.error) { | |
setUploadStatus("error"); | |
setFileError( | |
conversionCreateResponse.message || "Failed to create conversion" | |
); | |
return; | |
} | |
// On success, API will return conversionId | |
const { conversionId: newConversionId } = conversionCreateResponse; | |
// Redirect user to the conversion page | |
router.push(`/app/conversions/${newConversionId}/results`); | |
}; | |
if (isLoading) { | |
return ( | |
<div className="w-full max-w-md mx-auto bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> | |
<div className="p-6 space-y-4"> | |
<div className="h-8 bg-gray-800 rounded-md w-3/4 animate-pulse"></div> | |
<div className="h-4 bg-gray-800 rounded-md w-1/2 animate-pulse"></div> | |
<div className="space-y-2"> | |
<div className="h-4 bg-gray-800 rounded-md w-1/4 animate-pulse"></div> | |
<div className="h-20 bg-gray-800 rounded-md animate-pulse"></div> | |
</div> | |
<div className="h-10 bg-gray-800 rounded-md animate-pulse"></div> | |
</div> | |
</div> | |
); | |
} | |
return ( | |
<Card | |
className="w-full max-w-md mx-auto bg-gray-900 border-gray-800" | |
onClick={(e: React.MouseEvent) => { | |
if (!user?.id) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
router.push("/api/auth/login?redirectUrl=/"); | |
} | |
}} | |
> | |
<CardHeader> | |
<CardTitle className="text-2xl font-bold text-white"> | |
Bank Statement Converter | |
</CardTitle> | |
<CardDescription className="text-gray-400"> | |
Upload your PDF statement to get started | |
</CardDescription> | |
</CardHeader> | |
<CardContent className="space-y-6"> | |
<div className="space-y-2"> | |
<Label | |
htmlFor="pdf-upload" | |
className="text-sm font-medium text-gray-300" | |
> | |
Upload Your PDF Statement | |
</Label> | |
<div className="relative"> | |
<Input | |
id="pdf-upload" | |
type="file" | |
accept=".pdf" | |
className="hidden" | |
ref={fileUploaderInputRef} | |
onChange={handleFileChange} | |
/> | |
<Button | |
variant="outline" | |
className="w-full text-sm h-20 flex flex-col items-center justify-center space-y-2 border-dashed border-gray-700 bg-gray-800 hover:bg-gray-700 transition-colors text-gray-300 hover:text-white" | |
onClick={(e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
fileUploaderInputRef.current?.click(); | |
}} | |
> | |
<FileUp className="h-6 w-6 mb-2" /> | |
{file ? file.name : "Click to Upload"} | |
</Button> | |
</div> | |
{fileError && ( | |
<Alert variant="destructive"> | |
<AlertCircle className="h-4 w-4" /> | |
<AlertTitle>Error</AlertTitle> | |
<AlertDescription>{fileError}</AlertDescription> | |
</Alert> | |
)} | |
{uploadStatus === "uploading" && ( | |
<div className="space-y-2"> | |
<div className="w-full bg-gray-700 rounded-full h-2.5 dark:bg-gray-700"> | |
<div | |
className="bg-white h-2.5 rounded-full transition-all duration-300" | |
style={{ width: `${uploadProgress}%` }} | |
></div> | |
</div> | |
<p className="text-sm text-gray-400"> | |
Uploading: {uploadProgress}% | |
</p> | |
</div> | |
)} | |
{uploadStatus === "success" && ( | |
<Alert> | |
<CheckCircle className="h-4 w-4" /> | |
<AlertTitle>Success</AlertTitle> | |
<AlertDescription> | |
Your file has been successfully uploaded. Please wait while we | |
convert it to CSV/Excel. | |
</AlertDescription> | |
</Alert> | |
)} | |
</div> | |
<Button | |
className="w-full bg-blue-600 hover:bg-blue-700 text-white transition-colors" | |
onClick={handleUpload} | |
disabled={!file || uploadStatus === "uploading"} | |
> | |
{uploadStatus === "uploading" | |
? "Uploading..." | |
: "Convert to CSV/Excel"} | |
</Button> | |
</CardContent> | |
<CardFooter> | |
<p className="text-xs text-gray-500"> | |
By uploading, you agree to our{" "} | |
<a href="#" className="text-blue-400 hover:underline"> | |
Terms of Service | |
</a>{" "} | |
and{" "} | |
<a href="#" className="text-blue-400 hover:underline"> | |
Privacy Policy | |
</a> | |
. | |
</p> | |
</CardFooter> | |
</Card> | |
); | |
} | |
export default Uploader; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment