SimpleProgressBar.tsx
import prettyMS from "pretty-ms";
const SimpleProgressBar = ({
progress = 0,
remaining = 0,
}: {
progress?: number;
remaining?: number;
}) => {
return (
<>
{!!remaining && (
<div className="mb-1.5 text-sm text-gray-700">
Remaining time: {prettyMS(remaining)}
</div>
)}
<div className="py-1.5 h-6 relative">
<div className="absolute top-0 bottom-0 left-0 w-full h-full bg-gray-400"></div>
<div
style={{
width: `${progress}%`,
}}
className="absolute top-0 bottom-0 left-0 h-full transition-all duration-150 bg-gray-600"
></div>
<div className="absolute top-0 bottom-0 left-0 flex items-center justify-center w-full h-full">
<span className="text-xs font-bold text-white">{progress}%</span>
</div>
</div>
</>
);
};
export default SimpleProgressBar;
parse-form.ts
import type { NextApiRequest } from "next";
import mime from "mime";
import { join } from "path";
import * as dateFn from "date-fns";
import formidable from "formidable";
import { mkdir, stat } from "fs/promises";
export const FormidableError = formidable.errors.FormidableError;
export const parseForm = async (
req: NextApiRequest
): Promise<{ fields: formidable.Fields; files: formidable.Files }> => {
return await new Promise(async (resolve, reject) => {
const uploadDir = join(
process.env.ROOT_DIR || process.cwd(),
`/uploads/${dateFn.format(Date.now(), "dd-MM-Y")}`
);
try {
await stat(uploadDir);
} catch (e: any) {
if (e.code === "ENOENT") {
await mkdir(uploadDir, { recursive: true });
} else {
console.error(e);
reject(e);
return;
}
}
let filename = ""; // To avoid duplicate upload
const form = formidable({
maxFiles: 2,
maxFileSize: 1024 * 1024, // 1mb
uploadDir,
filename: (_name, _ext, part) => {
if (filename !== "") {
return filename;
}
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
filename = `${part.name || "unknown"}-${uniqueSuffix}.${
mime.getExtension(part.mimetype || "") || "unknown"
}`;
return filename;
},
filter: (part) => {
return (
part.name === "media" && (part.mimetype?.includes("image") || false)
);
},
});
form.parse(req, function (err, fields, files) {
if (err) reject(err);
else resolve({ fields, files });
});
});
};
upload.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { parseForm, FormidableError } from "../../lib/parse-form";
const handler = async (
req: NextApiRequest,
res: NextApiResponse<{
data: {
url: string | string[];
} | null;
error: string | null;
}>
) => {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
res.status(405).json({
data: null,
error: "Method Not Allowed",
});
return;
}
// Just after the "Method Not Allowed" code
try {
const { fields, files } = await parseForm(req);
const file = files.media;
let url = Array.isArray(file) ? file.map((f) => f.filepath) : file.filepath;
res.status(200).json({
data: {
url,
},
error: null,
});
} catch (e) {
if (e instanceof FormidableError) {
res.status(e.httpCode || 400).json({ data: null, error: e.message });
} else {
console.error(e);
res.status(500).json({ data: null, error: "Internal Server Error" });
}
}
};
export const config = {
api: {
bodyParser: false,
},
};
export default handler;
_app.tsx
import '../styles/globals.css'
import type { AppProps } from 'next/app'
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
export default MyApp
index.tsx
import type { NextPage } from "next";
import axios, { AxiosRequestConfig } from "axios";
import Head from "next/head";
import Image from "next/image";
import { ChangeEvent, MouseEvent, useState } from "react";
import SimpleProgressBar from "../components/common/SimpleProgressBar";
const Home: NextPage = () => {
const [progress, setProgress] = useState(0);
const [remaining, setRemaining] = useState(0);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const onFileUploadChange = (e: ChangeEvent<HTMLInputElement>) => {
const fileInput = e.target;
if (!fileInput.files) {
alert("No file was chosen");
return;
}
if (!fileInput.files || fileInput.files.length === 0) {
alert("Files list is empty");
return;
}
const file = fileInput.files[0];
/** File validation */
if (!file.type.startsWith("image")) {
alert("Please select a valide image");
return;
}
/** Setting file state */
setFile(file); // we will use the file state, to send it later to the server
setPreviewUrl(URL.createObjectURL(file)); // we will use this to show the preview of the image
/** Reset file input */
e.currentTarget.type = "text";
e.currentTarget.type = "file";
};
const onCancelFile = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!previewUrl && !file) {
return;
}
setFile(null);
setPreviewUrl(null);
};
const onUploadFile = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!file) {
return;
}
try {
let startAt = Date.now();
let formData = new FormData();
formData.append("media", file);
const options: AxiosRequestConfig = {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent: any) => {
const { loaded, total } = progressEvent;
// Calculate the progress percentage
const percentage = (loaded * 100) / total;
setProgress(+percentage.toFixed(2));
// Calculate the progress duration
const timeElapsed = Date.now() - startAt;
const uploadSpeed = loaded / timeElapsed;
const duration = (total - loaded) / uploadSpeed;
setRemaining(duration);
},
};
const {
data: { data },
} = await axios.post<{
data: {
url: string | string[];
};
}>("/api/upload", formData, options);
console.log("File was uploaded successfylly:", data);
} catch (e: any) {
console.error(e);
const error =
e.response && e.response.data
? e.response.data.error
: "Sorry! something went wrong.";
alert(error);
}
};
return (
<div>
<Head>
<title>File uploader</title>
<meta name="description" content="File uploader" />
</Head>
<main className="py-10">
<div className="w-full max-w-3xl px-3 mx-auto">
<h1 className="mb-10 text-3xl font-bold text-gray-900">
Upload your files
</h1>
<form
className="w-full p-3 border border-gray-500 border-dashed"
onSubmit={(e) => e.preventDefault()}
>
<div className="flex flex-col md:flex-row gap-1.5 md:py-4">
<div className="flex-grow">
{previewUrl ? (
<div className="mx-auto w-80">
<Image
alt="file uploader preview"
objectFit="cover"
src={previewUrl}
width={320}
height={218}
layout="fixed"
/>
</div>
) : (
<label className="flex flex-col items-center justify-center h-full py-3 transition-colors duration-150 cursor-pointer hover:text-gray-600">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-14 h-14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
/>
</svg>
<strong className="text-sm font-medium">
Select an image
</strong>
<input
className="block w-0 h-0"
name="file"
type="file"
onChange={onFileUploadChange}
/>
</label>
)}
</div>
<div className="flex mt-4 md:mt-0 md:flex-col justify-center gap-1.5">
<button
disabled={!previewUrl}
onClick={onCancelFile}
className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
>
Cancel file
</button>
<button
disabled={!previewUrl}
onClick={onUploadFile}
className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
>
Upload file
</button>
</div>
</div>
</form>
<div className="mt-3">
<SimpleProgressBar progress={progress} remaining={remaining} />
</div>
</div>
</main>
<footer>
<div className="w-full max-w-3xl px-3 mx-auto">
<p>All right reserved</p>
</div>
</footer>
</div>
);
};
export default Home;
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;.eslintrc.json
{
"extends": "next/core-web-vitals"
}
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
package.json
{
"name": "file_uploader",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"axios": "^0.27.2",
"date-fns": "^2.28.0",
"formidable": "^2.0.1",
"mime": "^3.0.0",
"next": "12.1.6",
"pretty-ms": "^8.0.0",
"react": "18.1.0",
"react-dom": "18.1.0"
},
"devDependencies": {
"@types/formidable": "^2.0.5",
"@types/mime": "^2.0.3",
"@types/node": "17.0.35",
"@types/react": "18.0.9",
"@types/react-dom": "18.0.5",
"autoprefixer": "^10.4.7",
"eslint": "8.16.0",
"eslint-config-next": "12.1.6",
"postcss": "^8.4.14",
"tailwindcss": "^3.0.24",
"typescript": "4.7.2"
}
}postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
tailwind.config.js
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}