Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next.js Upload Files with API Routes #5

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
"dependencies": {
"axios": "0.26.1",
"clsx": "1.1.1",
"multer": "1.4.2",
"next": "12.1.4",
"next-connect": "0.9.1",
"react": "18.0.0",
"react-dom": "18.0.0"
},
Expand All @@ -35,14 +37,23 @@
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "13.0.0",
"@testing-library/user-event": "14.0.4",
"@types/multer": "1.4.5",
"@types/node": "17.0.23",
"@types/react": "18.0.1",
"@types/react-dom": "17.0.0",
"@typescript-eslint/eslint-plugin": "4.9.0",
"@typescript-eslint/parser": "4.9.0",
"duplicate-package-checker-webpack-plugin": "3.0.0",
"eslint": "8.13.0",
"eslint-config-next": "12.1.4",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-jsx-a11y": "6.4.1",
"eslint-plugin-prettier": "3.1.4",
"eslint-plugin-react": "7.21.5",
"eslint-plugin-react-hooks": "4.2.0",
"generate-template-files": "3.2.0",
"http-server": "14.1.0",
"husky": "4.3.0",
"jest": "27.5.1",
"next-compose-plugins": "2.2.1",
"npm-run-all": "4.1.5",
Expand Down
24 changes: 20 additions & 4 deletions src/components/pages/index-page/IndexPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react';
import Link from 'next/link';
import { Routes } from '../../../constants/Routes';
import { UiFileInputButton } from '../../ui/ui-file-input-button/UiFileInputButton';
import { uploadFileRequest } from '../../../domains/upload/upload.services';

interface IProps {
testId?: string;
Expand All @@ -9,17 +11,31 @@ interface IProps {
export const IndexPage: React.FC<IProps> = (props) => {
const { testId = IndexPage.displayName } = props;

const onChange = async (formData: FormData) => {
const response = await uploadFileRequest(formData, (event) => {
console.log(`Current progress:`, Math.round((event.loaded * 100) / event.total));
});

console.log('response', response);
};

return (
<div>
<h1>
Hello Next.js{' '}
<span
role="img"
aria-label="hand waving"
>
<span role="img" aria-label="hand waving">
👋
</span>
</h1>
<div>
<UiFileInputButton label="Upload Single File" uploadFileName="theFiles" onChange={onChange} />
<UiFileInputButton
label="Upload Multiple Files"
uploadFileName="theFiles"
onChange={onChange}
allowMultipleFiles={true}
/>
</div>
<p>
<Link href={Routes.About}>
<a data-testid={`${testId}_about-button`}>About</a>
Expand Down
81 changes: 81 additions & 0 deletions src/components/ui/ui-file-input-button/UiFileInputButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';

export interface IProps {
/**
* A string that defines the file types the file input should accept.
* This string is a comma-separated list of unique file type specifiers.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers
*/
acceptedFileTypes?: string;
/**
* When allowMultipleFiles is true, the file input allows the user to select more than one file.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/multiple
*
* @default false
*/
allowMultipleFiles?: boolean;
/**
* Text to display as the button text
*/
label: string;
/**
* Handler passed from parent
*
* When the file input changes a FormData object will be send on the first parameter
*/
onChange: (formData: FormData) => void;
/**
* The name of the file input that the backend is expecting
*/
uploadFileName: string;
}

export const UiFileInputButton: React.FC<IProps> = (props) => {
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const formRef = React.useRef<HTMLFormElement | null>(null);

const onClickHandler = () => {
fileInputRef.current?.click();
};

const onChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
return;
}

const formData = new FormData();

Array.from(event.target.files).forEach((file) => {
formData.append(event.target.name, file);
});

props.onChange(formData);

formRef.current?.reset();
};

return (
<form ref={formRef}>
<button type="button" onClick={onClickHandler}>
{props.label}
</button>
<input
accept={props.acceptedFileTypes}
multiple={props.allowMultipleFiles}
name={props.uploadFileName}
onChange={onChangeHandler}
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
/>
</form>
);
};

UiFileInputButton.defaultProps = {
acceptedFileTypes: '',
allowMultipleFiles: false,
};
16 changes: 16 additions & 0 deletions src/domains/upload/upload.services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ApiResponse } from '../../models/ApiResponse';

export const uploadFileRequest = async (
formData: FormData,
progressCallback?: (progressEvent: ProgressEvent) => void
): Promise<ApiResponse<string[]>> => {
const config: AxiosRequestConfig = {
headers: { 'content-type': 'multipart/form-data' },
onUploadProgress: progressCallback,
validateStatus: (status) => true,
};
const response = await axios.post('/api/uploads', formData, config);

return response.data;
};
4 changes: 4 additions & 0 deletions src/models/ApiResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type SuccessfulResponse<T> = { data: T; error?: never; statusCode?: number };
export type UnsuccessfulResponse<E> = { data?: never; error: E; statusCode?: number };

export type ApiResponse<T, E = unknown> = SuccessfulResponse<T> | UnsuccessfulResponse<E>;
51 changes: 51 additions & 0 deletions src/pages/api/uploads/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextApiRequest, NextApiResponse } from 'next';
import nextConnect from 'next-connect';
import multer from 'multer';
import fs from 'fs';
import { ApiResponse } from '../../../models/ApiResponse';

interface NextConnectApiRequest extends NextApiRequest {
files: Express.Multer.File[];
}
type ResponseData = ApiResponse<string[], string>;

const oneMegabyteInBytes = 1000000;
const outputFolderName = './public/uploads';

const upload = multer({
limits: { fileSize: oneMegabyteInBytes * 2 },
storage: multer.diskStorage({
destination: './public/uploads',
filename: (req, file, cb) => cb(null, file.originalname),
}),
/*fileFilter: (req, file, cb) => {
const acceptFile: boolean = ['image/jpeg', 'image/png'].includes(file.mimetype);

cb(null, acceptFile);
},*/
});

const apiRoute = nextConnect({
onError(error, req: NextConnectApiRequest, res: NextApiResponse<ResponseData>) {
res.status(501).json({ error: `Sorry something Happened! ${error.message}` });
},
onNoMatch(req: NextConnectApiRequest, res: NextApiResponse<ResponseData>) {
res.status(405).json({ error: `Method '${req.method}' Not Allowed` });
},
});

apiRoute.use(upload.array('theFiles'));

apiRoute.post((req: NextConnectApiRequest, res: NextApiResponse<ResponseData>) => {
const filenames = fs.readdirSync(outputFolderName);
const images = filenames.map((name) => name);

res.status(200).json({ data: images });
});

export const config = {
api: {
bodyParser: false, // Disallow body parsing, consume as stream
},
};
export default apiRoute;
Loading