From eb7fdc86c77475bebcbd647f2f5b599d8b7e81b1 Mon Sep 17 00:00:00 2001 From: Kapil jangid <103230903+CuriousCoder00@users.noreply.github.com> Date: Mon, 21 Oct 2024 20:03:53 +0530 Subject: [PATCH] Feat/project stack (#527) * Refactor User model in schema.prisma * Refactor user.profile.validator.ts: Add stack enum for projectSchema * Refactor user-multistep-form: Add project stack selection * Refactor project schema to add default value for stack field * Refactor schema.prisma: Add cascade deletion for user relations * Refactor project schema: Add projectThumbnail field and update projectSummary * Refactor user.profile.validator.ts: Add optional projectThumbnail field to projectSchema * Refactor profile pages: Add container and heading * Refactor AddProject form: Add project thumbnail upload functionality * Refactor UserProject component: Add project thumbnail display and link functionality * Refactor project schema: Update ProjectStack enum values * Refactor project schema: Update ProjectStack enum values * Refactor icons.ts: Add loading spinner icon * Refactor profile pages: Add "Add more" functionality * Refactor project schema: Update ProjectStack enum values * Refactor UserProject component: Add loading spinner and no projects found message * Refactor UserExperience component: Improve loading and no experiences found handling --- prisma/schema.prisma | 74 ++++---- src/app/profile/experience/page.tsx | 24 ++- src/app/profile/projects/page.tsx | 24 ++- src/app/profile/resume/page.tsx | 5 +- src/app/profile/skills/page.tsx | 24 ++- src/components/profile/UserExperience.tsx | 78 ++++---- src/components/profile/UserProject.tsx | 66 +++++-- .../user-multistep-form/add-project-form.tsx | 175 +++++++++++++++++- src/lib/icons.ts | 2 + src/lib/validators/user.profile.validator.ts | 10 + 10 files changed, 391 insertions(+), 91 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51b13f7a..a1082e84 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,8 +8,8 @@ datasource db { } model User { - id String @id @default(cuid()) - name String + id String @id @default(cuid()) + name String password String? avatar String? @@ -19,34 +19,34 @@ model User { email String @unique emailVerified DateTime? - - skills String[] - experience Experience[] - project Project[] - resume String? - - oauthProvider OauthProvider? // Tracks OAuth provider (e.g., 'google') - oauthId String? + + skills String[] + experience Experience[] + project Project[] + resume String? + + oauthProvider OauthProvider? // Tracks OAuth provider (e.g., 'google') + oauthId String? blockedByAdmin DateTime? - onBoard Boolean @default(false) + onBoard Boolean @default(false) } enum OauthProvider { GOOGLE } - model VerificationToken { - token String + token String identifier String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - type TokenType - @@unique([token,identifier]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + type TokenType + + @@unique([token, identifier]) } -enum TokenType { +enum TokenType { EMAIL_VERIFICATION RESET_PASSWORD } @@ -80,12 +80,12 @@ model Job { isVerifiedJob Boolean @default(false) @map("is_verified_job") postedAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id],onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Experience { - id Int @id @default(autoincrement()) - companyName String + id Int @id @default(autoincrement()) + companyName String designation String EmploymentType EmployementType address String @@ -93,19 +93,31 @@ model Experience { currentWorkStatus Boolean startDate DateTime endDate DateTime? - description String - userId String - user User @relation(fields: [userId] ,references: [id]) + description String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Project { - id Int @id @default(autoincrement()) - projectName String - projectSummary String - projectLiveLink String? - projectGithub String - userId String - user User @relation(fields: [userId] , references: [id]) + id Int @id @default(autoincrement()) + projectName String + projectThumbnail String? + projectSummary String + projectLiveLink String? + projectGithub String + stack ProjectStack @default(OTHERS) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum ProjectStack { + GO + PYTHON + MERN + NEXTJS + AI_GPT_APIS + SPRINGBOOT + OTHERS } enum Currency { diff --git a/src/app/profile/experience/page.tsx b/src/app/profile/experience/page.tsx index adf6b8b0..e657a7a9 100644 --- a/src/app/profile/experience/page.tsx +++ b/src/app/profile/experience/page.tsx @@ -1,5 +1,13 @@ 'use client'; import { UserExperience } from '@/components/profile/UserExperience'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { AddExperience } from '@/components/user-multistep-form/addExperience-form'; import APP_PATHS from '@/config/path.config'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; @@ -13,7 +21,21 @@ export default function AccountExperiencePage() { router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); }, [session.status, router]); return ( -
+
+
+ Experience + + Add more + + + Add Experience + +
+ +
+
+
+
); diff --git a/src/app/profile/projects/page.tsx b/src/app/profile/projects/page.tsx index e92c1775..517a3480 100644 --- a/src/app/profile/projects/page.tsx +++ b/src/app/profile/projects/page.tsx @@ -1,5 +1,13 @@ 'use client'; import { UserProjects } from '@/components/profile/UserProject'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { AddProject } from '@/components/user-multistep-form/add-project-form'; import APP_PATHS from '@/config/path.config'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; @@ -13,7 +21,21 @@ export default function AccountProjectPage() { router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); }, [session.status, router]); return ( -
+
+
+ Projects + + Add more + + + Add Project + +
+ +
+
+
+
); diff --git a/src/app/profile/resume/page.tsx b/src/app/profile/resume/page.tsx index b0639fa9..f9efcefe 100644 --- a/src/app/profile/resume/page.tsx +++ b/src/app/profile/resume/page.tsx @@ -13,7 +13,10 @@ export default function AccountResumePage() { router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); }, [session.status, router]); return ( -
+
+
+ Resume +
); diff --git a/src/app/profile/skills/page.tsx b/src/app/profile/skills/page.tsx index 8a731a40..c9fd1b2b 100644 --- a/src/app/profile/skills/page.tsx +++ b/src/app/profile/skills/page.tsx @@ -1,5 +1,13 @@ 'use client'; import { UserSkills } from '@/components/profile/UserSkills'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { AddSkills } from '@/components/user-multistep-form/add-skills-form'; import APP_PATHS from '@/config/path.config'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; @@ -13,7 +21,21 @@ export default function AccountResumePage() { router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); }, [session.status, router]); return ( -
+
+
+ Skills + + Add more + + + Add Skills + +
+ +
+
+
+
); diff --git a/src/components/profile/UserExperience.tsx b/src/components/profile/UserExperience.tsx index 366ef085..16438d4f 100644 --- a/src/components/profile/UserExperience.tsx +++ b/src/components/profile/UserExperience.tsx @@ -2,9 +2,8 @@ import { getUserExperience } from '@/actions/user.profile.actions'; import { useEffect, useState } from 'react'; import { useToast } from '../ui/use-toast'; import { Experience } from '@prisma/client'; -import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import _ from 'lodash'; - +import icons from '@/lib/icons'; export function UserExperience() { const { toast } = useToast(); const [experiences, setExperiences] = useState(); @@ -34,53 +33,52 @@ export function UserExperience() { }, []); if (!experiences) { - return null; + return ( +
+ +
+ ); } return (
{experiences.map((item: Experience) => ( - - - - Company Name: - {item.companyName} - - - -

- Designation: {item.designation} -

-

- Employment Type:{' '} - {_.startCase(item.EmploymentType)} -

-

- Work Mode: {item.workMode} -

-

- Current Status:{' '} - {item.currentWorkStatus - ? 'Currently Employed here' - : 'Not Currently Employed here'} -

-

- Duration:{' '} - {new Date(item.startDate).toLocaleDateString()}{' '} - {item.endDate - ? ` - ${new Date(item.endDate).toLocaleDateString()}` - : ' - Present'} -

-

- Description: +

+
+
+ {new Date(item.startDate).toLocaleDateString()} + {item.endDate + ? ` - ${new Date(item.endDate).toLocaleDateString()}` + : ' - Present'} +
+
+ {_.startCase(item.EmploymentType)}, {_.startCase(item.workMode)} +
+
+
+ {item.companyName} +

+ {item.designation} +

+
+
+
+ {item.description} -

- - +
+
+
))} + {experiences.length === 0 && ( +
+ + No Experiences Found +
+ )}
); } diff --git a/src/components/profile/UserProject.tsx b/src/components/profile/UserProject.tsx index 5da4af54..b8ab2d9e 100644 --- a/src/components/profile/UserProject.tsx +++ b/src/components/profile/UserProject.tsx @@ -2,6 +2,7 @@ import { getUserProjects } from '@/actions/user.profile.actions'; import { useEffect, useState } from 'react'; import { useToast } from '../ui/use-toast'; import { Project } from '@prisma/client'; +import icons from '@/lib/icons'; import Link from 'next/link'; import { Card, @@ -10,10 +11,12 @@ import { CardHeader, CardTitle, } from '../ui/card'; +import { SquareArrowOutUpRightIcon } from 'lucide-react'; export function UserProjects() { const { toast } = useToast(); const [projects, setProjects] = useState(); + useEffect(() => { async function fetchProjects() { try { @@ -39,42 +42,75 @@ export function UserProjects() { }, [toast]); if (!projects) { - return null; + return ( +
+ +
+ ); } return ( -
+
{projects.map((item: Project) => ( +
+ {item.projectThumbnail ? ( + {item.projectName} + ) : ( +
+ + {item.projectName} + +
+ )} +
- + {item.projectName} - +

{item.projectSummary}

- {item.projectLiveLink && ( + {item.stack && ( +
+ Stack: + {item.stack} +
+ )} +
+ {item.projectLiveLink && ( + + + + )} - Live Project + - )} - - GitHub Repository - +
))} + {projects.length === 0 && ( +
+ + No Projects Found +
+ )}
); } diff --git a/src/components/user-multistep-form/add-project-form.tsx b/src/components/user-multistep-form/add-project-form.tsx index 7c16e967..3a555e60 100644 --- a/src/components/user-multistep-form/add-project-form.tsx +++ b/src/components/user-multistep-form/add-project-form.tsx @@ -17,25 +17,108 @@ import { Button } from '../ui/button'; import { Textarea } from '../ui/textarea'; import { useToast } from '../ui/use-toast'; import { addUserProjects } from '@/actions/user.profile.actions'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { LoadingSpinner } from '../loading-spinner'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { uploadFileAction } from '@/actions/upload-to-cdn'; +import { FaFileUpload } from 'react-icons/fa'; +import Image from 'next/image'; +import { X } from 'lucide-react'; export const AddProject = () => { const form = useForm({ resolver: zodResolver(projectSchema), defaultValues: { + projectThumbnail: '', projectName: '', projectSummary: '', projectGithub: '', projectLiveLink: '', + stack: 'OTHERS', }, }); const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); + const [file, setFile] = useState(null); + const [previewImg, setPreviewImg] = useState(null); + + const projectThumbnail = useRef(null); + + const handleClick = () => { + const fileInput = document.getElementById('fileInput') as HTMLInputElement; + + if (fileInput) { + fileInput.click(); + } + }; + const clearImage = () => { + const fileInput = document.getElementById('fileInput') as HTMLInputElement; + + if (fileInput) { + fileInput.value = ''; + } + setPreviewImg(null); + setFile(null); + }; + + const submitImage = async (file: File | null) => { + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const uniqueFileName = `${Date.now()}-${file.name}`; + formData.append('uniqueFileName', uniqueFileName); + + const res = await uploadFileAction(formData, 'webp'); + if (!res) { + throw new Error('Failed to upload image'); + } + + const uploadRes = res; + return uploadRes.url; + } catch (error) { + console.error('Image upload failed:', error); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const selectedFile = e.target.files ? e.target.files[0] : null; + if (!selectedFile) { + return; + } + if (!selectedFile.type.includes('image')) { + toast({ + title: + 'Invalid file format. Please upload an image file (e.g., .png, .jpg, .jpeg, .svg ) for the company logo', + variant: 'destructive', + }); + return; + } + const reader = new FileReader(); + reader.onload = () => { + if (projectThumbnail.current) { + projectThumbnail.current.src = reader.result as string; + } + setPreviewImg(reader.result as string); + }; + reader.readAsDataURL(selectedFile); + if (selectedFile) { + setFile(selectedFile); + } + }; const onSubmit = async (data: projectSchemaType) => { try { setIsLoading(true); + data.projectThumbnail = (await submitImage(file)) ?? ''; const response = await addUserProjects(data); if (!response.status) { return toast({ @@ -47,6 +130,7 @@ export const AddProject = () => { title: response.message, variant: 'success', }); + setPreviewImg(null); form.reset(form.formState.defaultValues); } catch (_error) { toast({ @@ -62,6 +146,53 @@ export const AddProject = () => { return (
+ ( + + Project Thumbnail + + + + + )} + /> +
+
+ {previewImg ? ( + Company Logo + ) : ( + + )} +
+ {previewImg && ( + + )} +
{ )} /> + + ( + + Project Stack + + + + + )} + /> + {isLoading ? (
diff --git a/src/lib/icons.ts b/src/lib/icons.ts index f8af6351..8cb54c36 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -5,6 +5,7 @@ import { FaLinkedin, FaTelegramPlane, FaYoutube, + FaSpinner, } from 'react-icons/fa'; import { ArrowRight, @@ -37,6 +38,7 @@ const icons = { github: FaGithub, instagram: FaInstagram, telergam: FaTelegramPlane, + loading: FaSpinner, copyright: Copyright, sun: Sun, moon: Moon, diff --git a/src/lib/validators/user.profile.validator.ts b/src/lib/validators/user.profile.validator.ts index af17111b..8e7d94cd 100644 --- a/src/lib/validators/user.profile.validator.ts +++ b/src/lib/validators/user.profile.validator.ts @@ -44,6 +44,7 @@ export const expFormSchema = z.object({ .max(255, { message: 'Description cannot exceed 255 characters' }), }); export const projectSchema = z.object({ + projectThumbnail: z.string().optional(), projectName: z.string().min(1, 'Project name is required'), projectSummary: z .string() @@ -62,6 +63,15 @@ export const projectSchema = z.object({ .refine((url) => url.startsWith('https://github.com/'), { message: 'URL must be a GitHub link starting with "https://github.com/"', }), + stack: z.enum([ + 'GO', + 'PYTHON', + 'MERN', + 'NEXTJS', + 'AI_GPT_APIS', + 'SPRINGBOOT', + 'OTHERS', + ]), }); export type projectSchemaType = z.infer;