Skip to content

Commit 0aa3743

Browse files
Feat: Add FileReader - Web API + Add image reorder
1 parent 021d5f3 commit 0aa3743

1 file changed

Lines changed: 144 additions & 227 deletions

File tree

src/app/upload/page.tsx

Lines changed: 144 additions & 227 deletions
Original file line numberDiff line numberDiff line change
@@ -1,250 +1,167 @@
1-
"use client";
2-
import React, { useState, useEffect } from "react";
3-
import axios, { AxiosError } from "axios";
4-
import toast from "react-hot-toast";
5-
import { handleAPIError } from "../../util/error";
6-
import { Button } from "@/components/ui/button";
7-
8-
import { type APIResponse } from "@/interface";
9-
import Dropzone from "react-dropzone";
10-
import { Upload } from "lucide-react";
11-
12-
const Page = () => {
13-
const [campus, setCampus] = useState("Vellore");
14-
const [files, setFiles] = useState<File[]>([]);
15-
const [isUploading, setIsUploading] = useState(false);
16-
const [, setResetSearch] = useState(false);
17-
const [isDragging, setIsDragging] = useState(false);
18-
const [isGlobalDragging, setIsGlobalDragging] = useState(false);
19-
1+
'use client'
2+
3+
import { useCallback, useEffect, useState } from 'react'
4+
import { useDropzone } from 'react-dropzone'
5+
import toast from 'react-hot-toast'
6+
import { Button } from '@/components/ui/button'
7+
import axios from 'axios'
8+
9+
export default function Page() {
10+
const [campus] = useState<'vellore' | 'chennai'>('vellore')
11+
const [files, setFiles] = useState<File[]>([])
12+
const [previews, setPreviews] = useState<{ file: File; preview: string }[]>([])
13+
const [isUploading, setIsUploading] = useState(false)
14+
const [isDragging, setIsDragging] = useState(false)
15+
const [isGlobalDragging, setIsGlobalDragging] = useState(false)
16+
17+
// Handle drag & drop global visual feedback
2018
useEffect(() => {
21-
const handleDragEnter = (e: DragEvent) => {
22-
e.preventDefault();
23-
e.stopPropagation();
24-
setIsGlobalDragging(true);
25-
};
26-
27-
const handleDragOver = (e: DragEvent) => {
28-
e.preventDefault();
29-
e.stopPropagation();
30-
};
31-
32-
const handleDragLeave = (e: DragEvent) => {
33-
e.preventDefault();
34-
e.stopPropagation();
35-
36-
if (
37-
!e.relatedTarget ||
38-
(e.currentTarget !== e.relatedTarget &&
39-
!(e.currentTarget as Element)?.contains(e.relatedTarget as Node))
40-
) {
41-
setIsGlobalDragging(false);
42-
}
43-
};
44-
45-
const handleDrop = (e: DragEvent) => {
46-
e.preventDefault();
47-
e.stopPropagation();
48-
setIsGlobalDragging(false);
49-
};
50-
51-
document.addEventListener("dragenter", handleDragEnter);
52-
document.addEventListener("dragover", handleDragOver);
53-
document.addEventListener("dragleave", handleDragLeave);
54-
document.addEventListener("drop", handleDrop);
19+
const onDragEnter = () => setIsGlobalDragging(true)
20+
const onDragLeave = () => setIsGlobalDragging(false)
21+
const onDrop = () => setIsGlobalDragging(false)
22+
23+
document.addEventListener('dragenter', onDragEnter)
24+
document.addEventListener('dragleave', onDragLeave)
25+
document.addEventListener('drop', onDrop)
5526

5627
return () => {
57-
document.removeEventListener("dragenter", handleDragEnter);
58-
document.removeEventListener("dragover", handleDragOver);
59-
document.removeEventListener("dragleave", handleDragLeave);
60-
document.removeEventListener("drop", handleDrop);
61-
};
62-
}, []);
63-
64-
function fileCheckAndSelect<T extends File>(acceptedFiles: T[]) {
65-
const maxFileSize = 5 * 1024 * 1024;
66-
const allowedFileTypes = [
67-
"application/pdf",
68-
"image/jpeg",
69-
"image/png",
70-
"image/gif",
71-
];
72-
73-
const toastId = toast.loading("uploading your files");
74-
if (!acceptedFiles || acceptedFiles.length === 0) {
75-
toast.error("No files selected", {
76-
id: toastId,
77-
});
78-
return;
28+
document.removeEventListener('dragenter', onDragEnter)
29+
document.removeEventListener('dragleave', onDragLeave)
30+
document.removeEventListener('drop', onDrop)
7931
}
32+
}, [])
8033

81-
if (acceptedFiles.length > 5) {
82-
toast.error("More than 5 files selected", {
83-
id: toastId,
84-
});
85-
return;
34+
// Clean up URLs
35+
useEffect(() => {
36+
return () => {
37+
previews.forEach((item) => URL.revokeObjectURL(item.preview))
8638
}
39+
}, [previews])
8740

88-
const invalidFiles = acceptedFiles.filter(
89-
(file) =>
90-
file.size > maxFileSize || !allowedFileTypes.includes(file.type),
91-
);
92-
if (invalidFiles.length > 0) {
93-
toast.error(
94-
`Some files are invalid. Ensure each file is below 5MB and of an allowed type (PDF, JPEG, PNG, GIF).`,
95-
{
96-
id: toastId,
97-
},
98-
);
99-
return;
100-
}
41+
const fileCheckAndSelect = useCallback((acceptedFiles: File[]) => {
42+
const filtered = acceptedFiles.filter((file) => {
43+
const isValidType = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif'].includes(file.type)
44+
const isValidSize = file.size <= 5 * 1024 * 1024
45+
return isValidType && isValidSize
46+
})
10147

102-
const isPdf = acceptedFiles.reduce(
103-
(reducer, file) => file.type === "application/pdf" || reducer,
104-
false,
105-
);
106-
if (isPdf && acceptedFiles.length > 1) {
107-
toast.error("PDFs must be uploaded separately", {
108-
id: toastId,
109-
});
110-
return;
48+
if (filtered.length !== acceptedFiles.length) {
49+
toast.error('Some files were rejected (invalid type or >5MB)')
11150
}
11251

113-
const orderedFiles = acceptedFiles.sort((a, b) => {
114-
return a.lastModified - b.lastModified;
115-
});
116-
setFiles(orderedFiles);
117-
toast.success(`${orderedFiles.length} files selected!`, {
118-
id: toastId,
119-
});
120-
}
121-
122-
const handlePrint = async () => {
123-
if (!campus) {
124-
setCampus("Vellore");
52+
filtered.sort((a, b) => b.lastModified - a.lastModified)
53+
54+
setFiles(filtered)
55+
setPreviews(filtered.map((file) => ({
56+
file,
57+
preview: URL.createObjectURL(file),
58+
})))
59+
}, [])
60+
61+
const onDrop = useCallback((acceptedFiles: File[]) => {
62+
fileCheckAndSelect(acceptedFiles)
63+
}, [fileCheckAndSelect])
64+
65+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
66+
onDrop,
67+
multiple: true,
68+
noClick: false,
69+
noKeyboard: false,
70+
accept: {
71+
'application/pdf': ['.pdf'],
72+
'image/*': ['.jpeg', '.jpg', '.png', '.gif']
12573
}
74+
})
12675

127-
const isPdf = files.length === 1 && files[0]?.type === "application/pdf";
128-
129-
const formData = new FormData();
130-
files.forEach((file) => {
131-
formData.append("files", file);
132-
});
76+
const handlePrint = async () => {
77+
if (files.length === 0) return
13378

134-
formData.append("campus", campus);
135-
formData.append("isPdf", String(isPdf));
79+
setIsUploading(true)
80+
const formData = new FormData()
13681

137-
setIsUploading(true);
82+
previews.forEach(({ file }) => formData.append('files', file))
83+
formData.append('campus', campus)
84+
formData.append('isPdf', 'true')
13885

13986
try {
140-
await toast.promise(
141-
async () => {
142-
try {
143-
await axios.post<APIResponse>("/api/ai-upload", formData);
144-
} catch (error) {
145-
if (error instanceof AxiosError && error.response?.data) {
146-
const errorData = error.response.data as APIResponse;
147-
const errorMessage =
148-
errorData.message || "Failed to upload papers";
149-
throw new Error(errorMessage);
150-
}
151-
throw new Error("Failed to upload papers");
152-
}
153-
},
154-
{
155-
loading: "Uploading papers...",
156-
success: "Papers uploaded successfully!",
157-
error: (error: Error) => {
158-
return error.message;
159-
},
160-
},
161-
);
162-
163-
setFiles([]);
164-
setResetSearch(true);
165-
setTimeout(() => setResetSearch(false), 100);
166-
} catch (error) {
167-
handleAPIError(error);
87+
const toastId = toast.loading('Uploading files...')
88+
await axios.post('/api/ai-upload', formData)
89+
toast.success('Files uploaded successfully!', { id: toastId })
90+
} catch (err) {
91+
toast.error('Upload failed.')
16892
} finally {
169-
setIsUploading(false);
93+
setIsUploading(false)
17094
}
171-
};
95+
}
17296

173-
const isCurrentlyDragging = isDragging || isGlobalDragging;
97+
const reorderFiles = (from: number, to: number) => {
98+
if (to < 0 || to >= previews.length) return
99+
const newPreviews = [...previews]
100+
const [moved] = newPreviews.splice(from, 1)
101+
newPreviews.splice(to, 0, moved)
102+
103+
setPreviews(newPreviews)
104+
setFiles(newPreviews.map(p => p.file))
105+
}
174106

175107
return (
176-
<div className="font-play flex h-[calc(100vh-85px)] flex-col justify-center px-6">
177-
<div className="2xl:my-15 flex flex-col items-center">
178-
<fieldset className="mb-4 w-full max-w-md rounded-lg border-2 border-gray-300 p-4 pr-8">
179-
<div className="flex w-full flex-col 2xl:gap-y-4">
180-
{/* File Dropzone */}
181-
<div>
182-
<Dropzone
183-
onDrop={fileCheckAndSelect}
184-
onDragEnter={() => setIsDragging(true)}
185-
onDragLeave={() => setIsDragging(false)}
186-
onDropAccepted={() => {
187-
setIsDragging(false);
188-
setIsGlobalDragging(false);
189-
}}
190-
onDropRejected={() => {
191-
setIsDragging(false);
192-
setIsGlobalDragging(false);
193-
}}
194-
>
195-
{({ getRootProps, getInputProps }) => (
196-
<section
197-
{...getRootProps()}
198-
className={`my-2 -mr-2 cursor-pointer rounded-2xl border-2 ${
199-
isCurrentlyDragging
200-
? "border-solid border-[#6D28D9] bg-purple-50 dark:bg-[#130E1F]"
201-
: "border-dashed border-gray-300"
202-
} p-8 text-center transition-all duration-200`}
108+
<main className="max-w-3xl mx-auto px-4 py-8">
109+
<fieldset
110+
{...getRootProps()}
111+
className={`rounded-lg border-2 border-dashed p-6 text-center transition-colors duration-300
112+
${isDragActive || isDragging || isGlobalDragging ? 'border-primary bg-primary/10' : 'border-muted'}
113+
`}
114+
onDragEnter={() => setIsDragging(true)}
115+
onDragLeave={() => setIsDragging(false)}
116+
>
117+
<input {...getInputProps()} />
118+
<p className="text-lg font-semibold">
119+
{isDragActive || isDragging || isGlobalDragging
120+
? 'Drop your PDFs or images here'
121+
: 'Drag & drop PDFs or images here, or click to select'}
122+
</p>
123+
<p className="text-sm text-muted-foreground mt-2">Max 5MB. PDF, JPG, PNG, GIF supported.</p>
124+
</fieldset>
125+
126+
{previews.length > 0 && (
127+
<section className="mt-6 space-y-4">
128+
{previews.map((item, index) => (
129+
<div key={index} className="flex items-center gap-4 border p-4 rounded-md bg-gray-50">
130+
{item.file.type.startsWith('image/') ? (
131+
<img src={item.preview} alt="preview" className="w-32 h-32 object-cover rounded-md" />
132+
) : (
133+
<iframe
134+
src={item.preview}
135+
className="w-32 h-32 border rounded-md"
136+
title={`PDF preview ${index}`}
137+
/>
138+
)}
139+
<div className="flex flex-col gap-2 flex-1">
140+
<p className="font-semibold text-gray-700 break-words">{item.file.name}</p>
141+
<div className="flex gap-2">
142+
<button
143+
onClick={() => reorderFiles(index, index - 1)}
144+
disabled={index === 0}
145+
className="px-2 py-1 text-sm bg-gray-200 rounded disabled:opacity-50"
146+
>
147+
148+
</button>
149+
<button
150+
onClick={() => reorderFiles(index, index + 1)}
151+
disabled={index === previews.length - 1}
152+
className="px-2 py-1 text-sm bg-gray-200 rounded disabled:opacity-50"
203153
>
204-
<input {...getInputProps()} />
205-
{isCurrentlyDragging ? (
206-
<div className="flex flex-col items-center">
207-
<p className="text-lg font-medium text-[#6D28D9]">
208-
Drop files here
209-
</p>
210-
<Upload className="mt-2 h-10 w-10 animate-bounce text-[#6D28D9]" />
211-
</div>
212-
) : (
213-
<p>
214-
Drag &apos;n&apos; drop some files here, or{" "}
215-
<span className="text-[#6D28D9]">click</span> to select
216-
files
217-
</p>
218-
)}
219-
<div
220-
className={`mt-2 text-xs ${
221-
files?.length === 0 ? "text-red-500" : "text-gray-600"
222-
}`}
223-
>
224-
{files?.length || 0} files selected
225-
</div>
226-
</section>
227-
)}
228-
</Dropzone>
229-
<label className="mx-2 -mr-2 block text-center text-xs font-medium text-gray-700">
230-
Only Images and PDF are allowed
231-
<sup className="text-red-500">*</sup>
232-
</label>
154+
155+
</button>
156+
</div>
157+
</div>
233158
</div>
234-
</div>
235-
</fieldset>
236-
<Button
237-
onClick={handlePrint}
238-
disabled={isUploading || files.length === 0}
239-
className={`w-fit rounded-md px-4 py-3 text-base ${
240-
isUploading || files.length === 0 ? "opacity-60" : ""
241-
}`}
242-
>
243-
{isUploading ? "Uploading..." : "Upload Papers"}
244-
</Button>
245-
</div>
246-
</div>
247-
);
248-
};
249-
250-
export default Page;
159+
))}
160+
<Button onClick={handlePrint} disabled={isUploading}>
161+
{isUploading ? 'Uploading...' : 'Upload Papers'}
162+
</Button>
163+
</section>
164+
)}
165+
</main>
166+
)
167+
}

0 commit comments

Comments
 (0)