Skip to content

Commit bbabc1a

Browse files
AndresLieowenowenismed-ben-bAndreas7703
authored
feat: add axios config and upload button (#20)
* Dev/backend@0.0.0 (#5) * remove golang * Init fastapi (#11) * inited fastapi * updated .gitignore and added template .env file * remove unused script in poetry * add simple Readme * update Readme * added read different .env files based on command line args & update dockerfile * File card initial commit * feat: add axios config and upload button * refactor: add default value in config and type in pastExamApi * chore: add project README for documentation * Revert "Dev/backend@0.0.0 (#5)" This reverts commit 316d9e3. --------- Co-authored-by: Owen Lin <106612301+owenowenisme@users.noreply.github.com> Co-authored-by: d-ben-b <155895903+d-ben-b@users.noreply.github.com> Co-authored-by: unknown <f74107048@gs.ncku.edu.tw> Co-authored-by: owenowenisme <mses010108@gmail.com>
1 parent d83b1cd commit bbabc1a

11 files changed

Lines changed: 1887 additions & 63 deletions

File tree

frontend/components/FileUploadField.tsx

Lines changed: 96 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,51 @@
11
'use client';
22
import Image from 'next/image';
3-
import React, { useCallback, useState } from 'react';
3+
import React, { useCallback, useEffect, useState } from 'react';
44
import { useDropzone } from 'react-dropzone';
5+
import pastExamApi from '@/module/pastExamApi';
56

67
interface FileWithPreview extends File {
78
preview: string;
89
}
9-
1010
export const FileUploadField = () => {
11-
const [files, setFiles] = useState<FileWithPreview[]>([]);
12-
11+
/**
12+
* @todo
13+
* 1. Use uploaderId from user global state
14+
* 2. Handle multiple file submit
15+
* 3. Handle filename (Base on file or explicit input)
16+
*/
17+
const [file, setFile] = useState<FileWithPreview | null>(null);
18+
const [uploaderId] = useState(1);
19+
const [fileName, setFileName] = useState('');
20+
useEffect(() => {
21+
return () => {
22+
if (file?.preview) {
23+
URL.revokeObjectURL(file.preview);
24+
}
25+
};
26+
}, [file]);
1327
const onDrop = useCallback((acceptedFiles: File[]) => {
14-
const mappedFiles = acceptedFiles.map((file) =>
15-
Object.assign(file, {
16-
preview: URL.createObjectURL(file),
17-
}),
18-
);
19-
setFiles((prevFiles) => [...prevFiles, ...mappedFiles]);
28+
if (acceptedFiles.length > 0) {
29+
const uploadedFile = acceptedFiles[0];
30+
const mappedFile = Object.assign(uploadedFile, {
31+
preview: URL.createObjectURL(uploadedFile),
32+
});
33+
setFile(mappedFile);
34+
setFileName(uploadedFile.name);
35+
}
2036
}, []);
2137

2238
const { getRootProps, getInputProps } = useDropzone({
2339
onDrop,
24-
multiple: true,
40+
multiple: false,
2541
});
2642

27-
const removeFile = (fileName: string): void => {
28-
setFiles((prevFiles) => prevFiles.filter((file) => file.name !== fileName));
43+
const removeFile = (): void => {
44+
if (file?.preview) {
45+
URL.revokeObjectURL(file.preview);
46+
}
47+
setFile(null);
48+
setFileName('');
2949
};
3050

3151
const formatFileSize = (bytes: number): string => {
@@ -35,6 +55,30 @@ export const FileUploadField = () => {
3555
return `${(bytes / 1024).toFixed(2)} KB`;
3656
};
3757

58+
const handleSubmit = async () => {
59+
if (!file) {
60+
alert('No file uploaded!');
61+
return;
62+
}
63+
64+
const formData = new FormData();
65+
formData.append('upload_file', file);
66+
formData.append('file_name', fileName || file.name);
67+
formData.append('uploader_id', uploaderId.toString());
68+
69+
try {
70+
const response = await pastExamApi.uploadFile(formData);
71+
72+
if (response) {
73+
alert('File uploaded successfully!');
74+
setFile(null);
75+
}
76+
} catch (error) {
77+
console.error('Error uploading file:', error);
78+
alert('Failed to upload file.');
79+
}
80+
};
81+
3882
return (
3983
<>
4084
<div
@@ -43,49 +87,53 @@ export const FileUploadField = () => {
4387
>
4488
<div className="flex h-full w-full flex-col justify-center rounded-xl border-2 border-dotted p-5">
4589
<input {...getInputProps()} />
46-
<p>Drag & drop files here, or click to select files</p>
90+
<p>Drag & drop a file here, or click to select a file</p>
4791
<button
4892
type="button"
4993
className="mt-2 max-w-40 self-center rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
5094
>
51-
Select Files
95+
Select File
5296
</button>
5397
</div>
5498
</div>
55-
{files.length !== 0 && (
99+
{file && (
56100
<div className="mt-4">
57-
<h3 className="font-semibold">Selected Files</h3>
58-
<ul className="mt-2 space-y-4">
59-
{files.map((file, index) => (
60-
<li
61-
key={index}
62-
className="flex items-center justify-between rounded-xl border border-gray-300 p-2"
63-
>
64-
<div className="flex items-center space-x-4">
65-
<div className="relative h-12 w-12 overflow-hidden rounded sm:h-16 sm:w-16 md:h-20 md:w-20 lg:h-24 lg:w-24">
66-
<Image
67-
src={file.preview}
68-
alt={file.name}
69-
fill
70-
className="object-cover"
71-
/>
72-
</div>
73-
<div>
74-
<p className="font-medium">{file.name}</p>
75-
<p className="text-sm text-slate-50">
76-
{`${file.name.split('.')[1]} - ${formatFileSize(file.size)}`}
77-
</p>
78-
</div>
79-
</div>
80-
<button
81-
onClick={() => removeFile(file.name)}
82-
className="rounded-lg bg-red-500 px-2 py-1 text-white hover:bg-red-600"
83-
>
84-
Remove
85-
</button>
86-
</li>
87-
))}
88-
</ul>
101+
<h3 className="font-semibold">Selected File</h3>
102+
<div className="mt-2 flex items-center justify-between rounded-xl border border-gray-300 p-2">
103+
<div className="flex items-center space-x-4">
104+
<div className="relative h-12 w-12 overflow-hidden rounded sm:h-16 sm:w-16 md:h-20 md:w-20 lg:h-24 lg:w-24">
105+
<Image
106+
src={file.preview}
107+
alt={file.name}
108+
fill
109+
className="object-cover"
110+
/>
111+
</div>
112+
<div>
113+
<p className="font-medium">{file.name}</p>
114+
<p className="text-sm text-slate-50">
115+
{formatFileSize(file.size)}
116+
</p>
117+
</div>
118+
</div>
119+
<button
120+
onClick={removeFile}
121+
className="rounded-lg bg-red-500 px-2 py-1 text-white hover:bg-red-600"
122+
>
123+
Remove
124+
</button>
125+
</div>
126+
</div>
127+
)}
128+
{file && (
129+
<div className="mt-4 flex justify-end">
130+
<button
131+
type="button"
132+
onClick={handleSubmit}
133+
className="rounded-lg bg-green-500 px-4 py-2 text-white hover:bg-green-600"
134+
>
135+
Upload
136+
</button>
89137
</div>
90138
)}
91139
</>

frontend/module/pastExamApi.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
2+
interface UploadResponse {
3+
success: boolean;
4+
fileId: string;
5+
url: string;
6+
}
7+
class PastExamApi {
8+
private axiosInstance: AxiosInstance;
9+
10+
constructor() {
11+
this.axiosInstance = axios.create({
12+
baseURL: process.env.NEXT_PUBLIC_API_URL,
13+
timeout: 15000,
14+
});
15+
16+
this.axiosInstance.interceptors.request.use(
17+
(config) => {
18+
console.log(`Sending request to ${config.baseURL}${config.url}`);
19+
return config;
20+
},
21+
(error) => {
22+
console.error('Request error:', error);
23+
return Promise.reject(error);
24+
},
25+
);
26+
27+
this.axiosInstance.interceptors.response.use(
28+
(response) => response,
29+
(error) => {
30+
console.error('Response error:', error.response?.data || error.message);
31+
return Promise.reject(error);
32+
},
33+
);
34+
}
35+
36+
private async request<T>(config: AxiosRequestConfig): Promise<T> {
37+
try {
38+
const response: AxiosResponse<T> =
39+
await this.axiosInstance.request(config);
40+
return response.data;
41+
} catch (error) {
42+
console.error(`Error in request to ${config.url}:`, error);
43+
throw error;
44+
}
45+
}
46+
47+
/**
48+
* Upload file with `multipart/form-data`.
49+
* @param formData - FormData containing the file and associated data.
50+
*/
51+
52+
async uploadFile(formData: FormData): Promise<UploadResponse> {
53+
try {
54+
const response = await this.request<UploadResponse>({
55+
url: '/file',
56+
method: 'POST',
57+
headers: {
58+
'Content-Type': 'multipart/form-data',
59+
},
60+
data: formData,
61+
});
62+
console.log('File uploaded successfully:', response);
63+
return response;
64+
} catch (error) {
65+
console.error('Error uploading file:', error);
66+
throw error;
67+
}
68+
}
69+
}
70+
71+
const pastExamApi = new PastExamApi();
72+
export default pastExamApi;

frontend/next.config.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,16 @@ const nextConfig = {
3030
}
3131
};
3232

33-
module.exports = nextConfig;
33+
module.exports = {
34+
env: {
35+
NEXT_PUBLIC_API_URL:
36+
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
37+
},
38+
onDevelopment: () => {
39+
if (!process.env.NEXT_PUBLIC_API_URL) {
40+
console.warn(
41+
'Warning: NEXT_PUBLIC_API_URL is not set, using default value',
42+
);
43+
}
44+
},
45+
};

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@typescript-eslint/parser": "8.11.0",
5353
"@vercel/git-hooks": "1.0.0",
5454
"autoprefixer": "10.4.19",
55+
"cross-env": "7.0.3",
5556
"eslint": "9.13.0",
5657
"eslint-config-next": "15.0.1",
5758
"eslint-plugin-react": "7.37.2",

0 commit comments

Comments
 (0)