Skip to content

Instantly share code, notes, and snippets.

@charanjit-singh
Created September 22, 2025 11:34
Show Gist options
  • Save charanjit-singh/45ec3298afd497b124e2bb21e2962b73 to your computer and use it in GitHub Desktop.
Save charanjit-singh/45ec3298afd497b124e2bb21e2962b73 to your computer and use it in GitHub Desktop.
import { S3Client } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: process.env.AWS_REGION,
});
export default s3;
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);
"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