From 6a108e1083592812e509654f4d21ea5ca2694750 Mon Sep 17 00:00:00 2001 From: Mohamad Salimi Date: Thu, 1 Feb 2024 02:23:22 +0800 Subject: [PATCH] Add upload PDF file resume --- app/admin/resume/page.tsx | 42 ++++++ app/api/edgestore/[...edgestore]/route.ts | 8 ++ app/api/resume/route.ts | 66 +++++++++ components/admin/menu.tsx | 7 + components/admin/page-title.tsx | 2 + components/admin/upload-pdf-button.tsx | 30 ++++ components/admin/upload-pdf-form.tsx | 161 ++++++++++++++++++++++ components/admin/view-resume.tsx | 37 +++++ components/modals/upload-pdf-modal.tsx | 40 ++++++ prisma/schema.prisma | 14 ++ 10 files changed, 407 insertions(+) create mode 100644 app/admin/resume/page.tsx create mode 100644 app/api/resume/route.ts create mode 100644 components/admin/upload-pdf-button.tsx create mode 100644 components/admin/upload-pdf-form.tsx create mode 100644 components/admin/view-resume.tsx create mode 100644 components/modals/upload-pdf-modal.tsx diff --git a/app/admin/resume/page.tsx b/app/admin/resume/page.tsx new file mode 100644 index 0000000..9fa3f06 --- /dev/null +++ b/app/admin/resume/page.tsx @@ -0,0 +1,42 @@ +import { redirect } from 'next/navigation'; + +import prismadb from '@/lib/prismadb'; +import { currentUser } from '@/lib/authentication'; +import ViewResume from '@/components/admin/view-resume'; +import UploadPdfButton from '@/components/admin/upload-pdf-button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@/components/ui/card'; + +export default async function AboutPage() { + const user = await currentUser(); + + if (!user || !user.id) { + redirect('/auth/sign-in'); + } + + const resume = await prismadb.resume.findFirst({ + where: { + userId: user.id + } + }); + + return ( + + + + Resume + + + Manage your resume pdf file. + + + + + + ); +} diff --git a/app/api/edgestore/[...edgestore]/route.ts b/app/api/edgestore/[...edgestore]/route.ts index 56ed0c6..bdc7f95 100644 --- a/app/api/edgestore/[...edgestore]/route.ts +++ b/app/api/edgestore/[...edgestore]/route.ts @@ -11,6 +11,14 @@ const edgeStoreRouter = es.router({ .imageBucket({ maxSize: 1024 * 1024 * 2 // 2MB }) + .beforeDelete(() => { + return true; + }), + publicFiles: es + .fileBucket({ + maxSize: 1024 * 1024 * 2, // 2MB + accept: ['application/pdf'] // accept only pdf file + }) .beforeDelete(() => { return true; }) diff --git a/app/api/resume/route.ts b/app/api/resume/route.ts new file mode 100644 index 0000000..3a55bb7 --- /dev/null +++ b/app/api/resume/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; +import { revalidatePath } from 'next/cache'; + +import prismadb from '@/lib/prismadb'; +import { currentUser } from '@/lib/authentication'; + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { pdf } = body; + + const user = await currentUser(); + + if (!pdf) { + return NextResponse.json( + { success: false, error: 'File is required.' }, + { status: 400 } + ); + } + + if (!user || !user.id) { + return NextResponse.json( + { success: false, error: 'Unauthenticated.' }, + { status: 401 } + ); + } + + const currentResume = await prismadb.resume.findFirst({ + where: { + userId: user.id + } + }); + + if (currentResume) { + const resume = await prismadb.resume.update({ + where: { + id: currentResume.id + }, + data: { + pdf + } + }); + + revalidatePath('/'); + + return NextResponse.json({ success: true, resume }); + } else { + const resume = await prismadb.resume.create({ + data: { + pdf, + userId: user.id + } + }); + + revalidatePath('/'); + + return NextResponse.json({ success: true, resume }); + } + } catch (error: any) { + console.log('[RESUME_POST]', error); + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} diff --git a/components/admin/menu.tsx b/components/admin/menu.tsx index 21aeefa..0175c06 100644 --- a/components/admin/menu.tsx +++ b/components/admin/menu.tsx @@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'; import { Book, ClipboardCheck, + FileText, FolderGit2, GraduationCap, LayoutGrid, @@ -81,6 +82,12 @@ export default function Menu({ isOpen }: MenuProps) { active: pathname.includes('/admin/tool'), icon: }, + { + href: '/admin/resume', + label: 'Resume', + active: pathname.includes('/admin/resume'), + icon: + }, { href: '/admin/account', label: 'Account', diff --git a/components/admin/page-title.tsx b/components/admin/page-title.tsx index aefc5b8..a6734a4 100644 --- a/components/admin/page-title.tsx +++ b/components/admin/page-title.tsx @@ -23,6 +23,8 @@ export default function PageTitle() { pageTitle = 'Miscellaneous'; } else if (pathname.includes('/admin/tool')) { pageTitle = 'Tool & Apps'; + } else if (pathname.includes('/admin/resume')) { + pageTitle = 'Resume'; } else if (pathname.includes('/admin/account')) { pageTitle = 'Account'; } diff --git a/components/admin/upload-pdf-button.tsx b/components/admin/upload-pdf-button.tsx new file mode 100644 index 0000000..5ed4b6a --- /dev/null +++ b/components/admin/upload-pdf-button.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useState } from 'react'; +import { Upload } from 'lucide-react'; +import { Resume } from '@prisma/client'; + +import { Button } from '@/components/ui/button'; +import UploadPdfModal from '@/components/modals/upload-pdf-modal'; + +interface UploadPdfButtonProps { + resume: Resume | null; +} + +export default function UploadPdfButton({ resume }: UploadPdfButtonProps) { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + resume={resume} + /> + + ); +} diff --git a/components/admin/upload-pdf-form.tsx b/components/admin/upload-pdf-form.tsx new file mode 100644 index 0000000..810775f --- /dev/null +++ b/components/admin/upload-pdf-form.tsx @@ -0,0 +1,161 @@ +'use client'; + +import * as z from 'zod'; +import axios from 'axios'; +import { useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Resume } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { useRouter } from 'next/navigation'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import { Input } from '@/components/ui/input'; +import { useEdgeStore } from '@/lib/edgestore'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/use-toast'; +import { + Form, + FormControl, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form'; + +interface UploadPdfFormProps { + onClose: () => void; + resume: Resume | null; +} + +const formSchema = z.object({ + pdf: z.string() +}); + +export default function UploadPdfForm({ onClose, resume }: UploadPdfFormProps) { + const router = useRouter(); + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [fileError, setFileError] = useState(''); + const [file, setFile] = useState( + resume?.pdf ?? undefined + ); + + const { edgestore } = useEdgeStore(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + pdf: resume?.pdf ?? '' + } + }); + + const onSubmit = async (values: z.infer) => { + try { + setLoading(true); + + let pdfURL = resume?.pdf ?? ''; + if (file && file instanceof File) { + const res = await edgestore.publicFiles.upload({ + file, + options: { + replaceTargetUrl: resume?.pdf ?? undefined + } + }); + + if (res.url) { + pdfURL = res.url; + } + } + + if (pdfURL === '') { + setFileError('Please select file.'); + return; + } + + setFileError(''); + const newValues = { ...values, pdf: pdfURL }; + + const response = await axios.post('/api/resume', newValues); + + if (response.data.success) { + onClose(); + router.refresh(); + + toast({ + variant: 'default', + title: 'Success!', + description: 'Data has been successfully saved.' + }); + } + } catch (error) { + console.log(error); + + toast({ + variant: 'destructive', + title: 'Uh oh! Something went wrong.', + description: 'There was a problem with your request.' + }); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+ + PDF File + + { + const selectedFile = event.target.files?.[0]; + if (selectedFile) { + if ( + selectedFile.type !== 'application/pdf' && + selectedFile.type !== 'application/x-pdf' + ) { + setFileError('Invalid file type.'); + return; + } + + if (selectedFile.size > 1024 * 1024 * 2) { + setFileError('File size is too large. Max size is 2MB.'); + return; + } + + setFileError(''); + setFile(selectedFile); + } + }} + /> + + {fileError} + +
+ + +
+
+
+ + ); +} diff --git a/components/admin/view-resume.tsx b/components/admin/view-resume.tsx new file mode 100644 index 0000000..86d3138 --- /dev/null +++ b/components/admin/view-resume.tsx @@ -0,0 +1,37 @@ +'use client'; + +interface ViewResumeProps { + url: string | null; +} + +export default function ViewResume({ url }: ViewResumeProps) { + return ( + <> + {!url && ( +
+

No resume found.

+
+ )} + {url && ( + <> + + + + + )} + + ); +} diff --git a/components/modals/upload-pdf-modal.tsx b/components/modals/upload-pdf-modal.tsx new file mode 100644 index 0000000..82250c7 --- /dev/null +++ b/components/modals/upload-pdf-modal.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Resume } from '@prisma/client'; +import { useEffect, useState } from 'react'; + +import { Modal } from '@/components/ui/modal'; +import UploadPdfForm from '@/components/admin/upload-pdf-form'; + +interface UploadPdfModalProps { + isOpen: boolean; + onClose: () => void; + resume: Resume | null; +} + +export default function UploadPdfModal({ + isOpen, + onClose, + resume +}: UploadPdfModalProps) { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + return ( + + + + ); +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 714a436..f9eb49c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model User { portfolios Portfolio[] miscellaneous Miscellaneous? tools Tool[] + resume Resume? } model Account { @@ -238,5 +239,18 @@ model Tool { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([userId]) +} + +model Resume { + id String @id @default(cuid()) + pdf String? + + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([userId]) } \ No newline at end of file