mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-18 03:31:58 +08:00
[resumes][feat] upload pdf file into file storage (#321)
* [resumes][feat] upload pdf file into file storage * [resumes][fix] fix file upload failure * [resumes][chore] update .env.local.example * [resumes][fix] process file transfer over next.js * [resumes][feat] file upload * [resumes][chore] cleanup * [resumes][feat] add GET method for file-storage API * [portal[chore] Update env.example file * [resumes][chore] cleanup * [portal][chore] update yarn lock file
This commit is contained in:
@ -8,3 +8,7 @@ NEXTAUTH_URL=http://localhost:3000
|
|||||||
# Next Auth GitHub Provider
|
# Next Auth GitHub Provider
|
||||||
GITHUB_CLIENT_ID=a5164b1943b5413ff2f5
|
GITHUB_CLIENT_ID=a5164b1943b5413ff2f5
|
||||||
GITHUB_CLIENT_SECRET=
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Supabase
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_ANON_KEY=
|
||||||
|
@ -16,13 +16,16 @@
|
|||||||
"@heroicons/react": "^2.0.11",
|
"@heroicons/react": "^2.0.11",
|
||||||
"@next-auth/prisma-adapter": "^1.0.4",
|
"@next-auth/prisma-adapter": "^1.0.4",
|
||||||
"@prisma/client": "^4.4.0",
|
"@prisma/client": "^4.4.0",
|
||||||
|
"@supabase/supabase-js": "^1.35.7",
|
||||||
"@tih/ui": "*",
|
"@tih/ui": "*",
|
||||||
"@trpc/client": "^9.27.2",
|
"@trpc/client": "^9.27.2",
|
||||||
"@trpc/next": "^9.27.2",
|
"@trpc/next": "^9.27.2",
|
||||||
"@trpc/react": "^9.27.2",
|
"@trpc/react": "^9.27.2",
|
||||||
"@trpc/server": "^9.27.2",
|
"@trpc/server": "^9.27.2",
|
||||||
|
"axios": "^1.1.2",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
"formidable": "^2.0.1",
|
||||||
"next": "12.3.1",
|
"next": "12.3.1",
|
||||||
"next-auth": "~4.10.3",
|
"next-auth": "~4.10.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
@ -36,6 +39,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tih/tailwind-config": "*",
|
"@tih/tailwind-config": "*",
|
||||||
"@tih/tsconfig": "*",
|
"@tih/tsconfig": "*",
|
||||||
|
"@types/formidable": "^2.0.5",
|
||||||
"@types/node": "^18.0.0",
|
"@types/node": "^18.0.0",
|
||||||
"@types/react": "^18.0.21",
|
"@types/react": "^18.0.21",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import axios from 'axios';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Document, Page, pdfjs } from 'react-pdf';
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
|
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
|
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
|
||||||
import { Button, Spinner } from '@tih/ui';
|
import { Button, Spinner } from '@tih/ui';
|
||||||
|
|
||||||
|
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
|
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
|
||||||
|
|
||||||
type Props = Readonly<{
|
type Props = Readonly<{
|
||||||
@ -13,16 +16,30 @@ type Props = Readonly<{
|
|||||||
export default function ResumePdf({ url }: Props) {
|
export default function ResumePdf({ url }: Props) {
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
const [pageNumber] = useState(1);
|
const [pageNumber] = useState(1);
|
||||||
|
const [file, setFile] = useState<File>();
|
||||||
|
|
||||||
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
|
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
|
||||||
setNumPages(pdf.numPages);
|
setNumPages(pdf.numPages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
await axios
|
||||||
|
.get(`/api/file-storage?key=${RESUME_STORAGE_KEY}&url=${url}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setFile(res.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Document
|
<Document
|
||||||
className="h-[calc(100vh-17rem)] overflow-scroll"
|
className="h-[calc(100vh-17rem)] overflow-scroll"
|
||||||
file={url}
|
file={file}
|
||||||
loading={<Spinner display="block" label="" size="lg" />}
|
loading={<Spinner display="block" label="" size="lg" />}
|
||||||
onLoadSuccess={onPdfLoadSuccess}>
|
onLoadSuccess={onPdfLoadSuccess}>
|
||||||
<Page pageNumber={pageNumber} />
|
<Page pageNumber={pageNumber} />
|
||||||
|
1
apps/portal/src/constants/file-storage-keys.ts
Normal file
1
apps/portal/src/constants/file-storage-keys.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const RESUME_STORAGE_KEY = 'resumes';
|
8
apps/portal/src/env/schema.mjs
vendored
8
apps/portal/src/env/schema.mjs
vendored
@ -7,11 +7,13 @@ import { z } from 'zod';
|
|||||||
*/
|
*/
|
||||||
export const serverSchema = z.object({
|
export const serverSchema = z.object({
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']),
|
|
||||||
NEXTAUTH_SECRET: z.string(),
|
|
||||||
NEXTAUTH_URL: z.string().url(),
|
|
||||||
GITHUB_CLIENT_ID: z.string(),
|
GITHUB_CLIENT_ID: z.string(),
|
||||||
GITHUB_CLIENT_SECRET: z.string(),
|
GITHUB_CLIENT_SECRET: z.string(),
|
||||||
|
NEXTAUTH_SECRET: z.string(),
|
||||||
|
NEXTAUTH_URL: z.string().url(),
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']),
|
||||||
|
SUPABASE_ANON_KEY: z.string(),
|
||||||
|
SUPABASE_URL: z.string().url(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
65
apps/portal/src/pages/api/file-storage.ts
Normal file
65
apps/portal/src/pages/api/file-storage.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import formidable from 'formidable';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { supabase } from '~/utils/supabase';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const form = formidable({ keepExtensions: true });
|
||||||
|
form.parse(req, async (err, fields, files) => {
|
||||||
|
if (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = fields;
|
||||||
|
const { file } = files;
|
||||||
|
|
||||||
|
const parsedFile: formidable.File =
|
||||||
|
file instanceof Array ? file[0] : file;
|
||||||
|
const filePath = `${Date.now()}-${parsedFile.originalFilename}`;
|
||||||
|
const convertedFile = fs.readFileSync(parsedFile.filepath);
|
||||||
|
|
||||||
|
const { error } = await supabase.storage
|
||||||
|
.from(key as string)
|
||||||
|
.upload(filePath, convertedFile);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
url: filePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const { key, url } = req.query;
|
||||||
|
|
||||||
|
const { data, error } = await supabase.storage
|
||||||
|
.from(`public/${key as string}`)
|
||||||
|
.download(url as string);
|
||||||
|
|
||||||
|
if (error || data == null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await data.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
res.status(200).send(buffer);
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@ -13,6 +14,7 @@ import {
|
|||||||
ROLES,
|
ROLES,
|
||||||
} from '~/components/resumes/browse/constants';
|
} from '~/components/resumes/browse/constants';
|
||||||
|
|
||||||
|
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
const TITLE_PLACEHOLDER =
|
const TITLE_PLACEHOLDER =
|
||||||
@ -49,8 +51,24 @@ export default function SubmitResumeForm() {
|
|||||||
} = useForm<IFormInput>();
|
} = useForm<IFormInput>();
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
||||||
|
if (resumeFile == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', RESUME_STORAGE_KEY);
|
||||||
|
formData.append('file', resumeFile);
|
||||||
|
|
||||||
|
const res = await axios.post('/api/file-storage', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { url } = res.data;
|
||||||
|
|
||||||
await resumeCreateMutation.mutate({
|
await resumeCreateMutation.mutate({
|
||||||
...data,
|
...data,
|
||||||
|
url,
|
||||||
});
|
});
|
||||||
router.push('/resumes');
|
router.push('/resumes');
|
||||||
};
|
};
|
||||||
|
@ -12,14 +12,13 @@ export const resumesResumeUserRouter = createProtectedRouter().mutation(
|
|||||||
location: z.string(),
|
location: z.string(),
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
url: z.string(),
|
||||||
}),
|
}),
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
// TODO: Store file in file storage and retrieve URL
|
|
||||||
return await ctx.prisma.resumesResume.create({
|
return await ctx.prisma.resumesResume.create({
|
||||||
data: {
|
data: {
|
||||||
...input,
|
...input,
|
||||||
url: '',
|
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
8
apps/portal/src/utils/supabase.ts
Normal file
8
apps/portal/src/utils/supabase.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { env } from '~/env/server.mjs';
|
||||||
|
|
||||||
|
const { SUPABASE_URL, SUPABASE_ANON_KEY } = env;
|
||||||
|
|
||||||
|
// Create a single supabase client for interacting with the file storage
|
||||||
|
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
Reference in New Issue
Block a user