mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-17 19:14:08 +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:
@ -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 type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
|
||||
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`;
|
||||
|
||||
type Props = Readonly<{
|
||||
@ -13,16 +16,30 @@ type Props = Readonly<{
|
||||
export default function ResumePdf({ url }: Props) {
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [pageNumber] = useState(1);
|
||||
const [file, setFile] = useState<File>();
|
||||
|
||||
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
|
||||
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 (
|
||||
<div>
|
||||
<Document
|
||||
className="h-[calc(100vh-17rem)] overflow-scroll"
|
||||
file={url}
|
||||
file={file}
|
||||
loading={<Spinner display="block" label="" size="lg" />}
|
||||
onLoadSuccess={onPdfLoadSuccess}>
|
||||
<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({
|
||||
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_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 Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
@ -13,6 +14,7 @@ import {
|
||||
ROLES,
|
||||
} from '~/components/resumes/browse/constants';
|
||||
|
||||
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
const TITLE_PLACEHOLDER =
|
||||
@ -49,8 +51,24 @@ export default function SubmitResumeForm() {
|
||||
} = useForm<IFormInput>();
|
||||
|
||||
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({
|
||||
...data,
|
||||
url,
|
||||
});
|
||||
router.push('/resumes');
|
||||
};
|
||||
|
@ -12,14 +12,13 @@ export const resumesResumeUserRouter = createProtectedRouter().mutation(
|
||||
location: z.string(),
|
||||
role: z.string(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user.id;
|
||||
// TODO: Store file in file storage and retrieve URL
|
||||
return await ctx.prisma.resumesResume.create({
|
||||
data: {
|
||||
...input,
|
||||
url: '',
|
||||
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