diff --git a/apps/portal/package.json b/apps/portal/package.json index f135cab3..9948c01e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -22,11 +22,13 @@ "@trpc/react": "^9.27.2", "@trpc/server": "^9.27.2", "clsx": "^1.2.1", + "date-fns": "^2.29.3", "next": "12.3.1", "next-auth": "~4.10.3", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.36.1", + "react-pdf": "^5.7.2", "react-query": "^3.39.2", "superjson": "^1.10.0", "zod": "^3.18.0" @@ -37,6 +39,7 @@ "@types/node": "^18.0.0", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", + "@types/react-pdf": "^5.7.2", "autoprefixer": "^10.4.12", "postcss": "^8.4.16", "prettier-plugin-tailwindcss": "^0.1.13", diff --git a/apps/portal/prisma/migrations/20221006064944_add_resume_schemas/migration.sql b/apps/portal/prisma/migrations/20221006090216_add_resume_schemas/migration.sql similarity index 95% rename from apps/portal/prisma/migrations/20221006064944_add_resume_schemas/migration.sql rename to apps/portal/prisma/migrations/20221006090216_add_resume_schemas/migration.sql index 67b66abf..fac3cdd7 100644 --- a/apps/portal/prisma/migrations/20221006064944_add_resume_schemas/migration.sql +++ b/apps/portal/prisma/migrations/20221006090216_add_resume_schemas/migration.sql @@ -6,8 +6,11 @@ CREATE TABLE "ResumesResume" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "title" TEXT NOT NULL, - "additionalInfo" TEXT NOT NULL, + "role" TEXT NOT NULL, + "experience" TEXT NOT NULL, + "location" TEXT NOT NULL, "url" TEXT NOT NULL, + "additionalInfo" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, diff --git a/apps/portal/prisma/migrations/20221007062555_add_resume_profile_model/migration.sql b/apps/portal/prisma/migrations/20221007062555_add_resume_profile_model/migration.sql new file mode 100644 index 00000000..64ef6108 --- /dev/null +++ b/apps/portal/prisma/migrations/20221007062555_add_resume_profile_model/migration.sql @@ -0,0 +1,74 @@ +/* + Warnings: + + - You are about to drop the column `userId` on the `ResumesComment` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `ResumesCommentVote` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `ResumesResume` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `ResumesStar` table. All the data in the column will be lost. + - A unique constraint covering the columns `[commentId,resumesProfileId]` on the table `ResumesCommentVote` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[resumeId,resumesProfileId]` on the table `ResumesStar` will be added. If there are existing duplicate values, this will fail. + - Added the required column `resumesProfileId` to the `ResumesComment` table without a default value. This is not possible if the table is not empty. + - Added the required column `resumesProfileId` to the `ResumesCommentVote` table without a default value. This is not possible if the table is not empty. + - Added the required column `resumesProfileId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty. + - Added the required column `resumesProfileId` to the `ResumesStar` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "ResumesComment" DROP CONSTRAINT "ResumesComment_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesCommentVote" DROP CONSTRAINT "ResumesCommentVote_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesResume" DROP CONSTRAINT "ResumesResume_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesStar" DROP CONSTRAINT "ResumesStar_userId_fkey"; + +-- AlterTable +ALTER TABLE "ResumesComment" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesCommentVote" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesResume" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesStar" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "ResumesProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "ResumesProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesProfile_userId_key" ON "ResumesProfile"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesCommentVote_commentId_resumesProfileId_key" ON "ResumesCommentVote"("commentId", "resumesProfileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesStar_resumeId_resumesProfileId_key" ON "ResumesStar"("resumeId", "resumesProfileId"); + +-- AddForeignKey +ALTER TABLE "ResumesProfile" ADD CONSTRAINT "ResumesProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesStar" ADD CONSTRAINT "ResumesStar_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesCommentVote" ADD CONSTRAINT "ResumesCommentVote_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql b/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql new file mode 100644 index 00000000..5b9baead --- /dev/null +++ b/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql @@ -0,0 +1,73 @@ +/* + Warnings: + + - You are about to drop the column `resumesProfileId` on the `ResumesComment` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesCommentVote` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesResume` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesStar` table. All the data in the column will be lost. + - You are about to drop the `ResumesProfile` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[userId,commentId]` on the table `ResumesCommentVote` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[userId,resumeId]` on the table `ResumesStar` will be added. If there are existing duplicate values, this will fail. + - Added the required column `userId` to the `ResumesComment` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesCommentVote` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesStar` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "ResumesComment" DROP CONSTRAINT "ResumesComment_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesCommentVote" DROP CONSTRAINT "ResumesCommentVote_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesProfile" DROP CONSTRAINT "ResumesProfile_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesResume" DROP CONSTRAINT "ResumesResume_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesStar" DROP CONSTRAINT "ResumesStar_resumesProfileId_fkey"; + +-- DropIndex +DROP INDEX "ResumesCommentVote_commentId_resumesProfileId_key"; + +-- DropIndex +DROP INDEX "ResumesStar_resumeId_resumesProfileId_key"; + +-- AlterTable +ALTER TABLE "ResumesComment" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesCommentVote" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesResume" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesStar" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- DropTable +DROP TABLE "ResumesProfile"; + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesCommentVote_userId_commentId_key" ON "ResumesCommentVote"("userId", "commentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesStar_userId_resumeId_key" ON "ResumesStar"("userId", "resumeId"); + +-- AddForeignKey +ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesStar" ADD CONSTRAINT "ResumesStar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesCommentVote" ADD CONSTRAINT "ResumesCommentVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 78fe5e7a..fcf404fb 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -97,15 +97,16 @@ model Company { // Add Resumes project models here, prefix all models with "Resumes", // use camelCase for field names, and try to name them consistently // across all models in this file. -// End of Resumes project models. - model ResumesResume { id String @id @default(cuid()) userId String title String @db.Text - additionalInfo String @db.Text - // TODO: Add role, experience, location from Enums + // TODO: Update role, experience, location to use Enums + role String @db.Text + experience String @db.Text + location String @db.Text url String + additionalInfo String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -115,24 +116,26 @@ model ResumesResume { model ResumesStar { id String @id @default(cuid()) - resumeId String userId String + resumeId String createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, resumeId]) } model ResumesComment { id String @id @default(cuid()) - resumeId String userId String + resumeId String description String @db.Text section ResumesSection createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) votes ResumesCommentVote[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum ResumesSection { @@ -145,15 +148,19 @@ enum ResumesSection { model ResumesCommentVote { id String @id @default(cuid()) - commentId String userId String + commentId String value Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, commentId]) } +// End of Resumes project models. + // Start of Offers project models. // Add Offers project models here, prefix all models with "Offer", // use camelCase for field names, and try to name them consistently diff --git a/apps/portal/public/test_resume.pdf b/apps/portal/public/test_resume.pdf new file mode 100644 index 00000000..279b6a25 Binary files /dev/null and b/apps/portal/public/test_resume.pdf differ diff --git a/apps/portal/src/components/resumes/ResumePdf.tsx b/apps/portal/src/components/resumes/ResumePdf.tsx new file mode 100644 index 00000000..82e26395 --- /dev/null +++ b/apps/portal/src/components/resumes/ResumePdf.tsx @@ -0,0 +1,52 @@ +import { 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'; + +pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`; + +type Props = Readonly<{ + url: string; +}>; + +export default function ResumePdf({ url }: Props) { + const [numPages, setNumPages] = useState(0); + const [pageNumber] = useState(1); + + const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => { + setNumPages(pdf.numPages); + }; + + return ( +
+ } + onLoadSuccess={onPdfLoadSuccess}> + + + +
+
+
+ ); +} diff --git a/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx b/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx index 13565a24..5e9cfda7 100644 --- a/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx +++ b/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx @@ -1,3 +1,13 @@ +import { Badge } from '@tih/ui'; + export default function ResumeReviewsTitle() { - return

Resume Reviews

; + return ( +
+

Resume Reviews

+ +
+ ); } diff --git a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx new file mode 100644 index 00000000..3588f0c0 --- /dev/null +++ b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link'; +import type { UrlObject } from 'url'; +import { ChevronRightIcon } from '@heroicons/react/20/solid'; +import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline'; + +import type { Resume } from '~/types/resume'; + +type Props = Readonly<{ + href: UrlObject | string; + resumeInfo: Resume; +}>; + +export default function BrowseListItem({ href, resumeInfo }: Props) { + return ( + +
+
+ {resumeInfo.title} +
+ {resumeInfo.role} +
+ {resumeInfo.experience} +
+
+
+
+ + {resumeInfo.numComments} comments +
+
+ + {resumeInfo.numStars} stars +
+
+
+
+ {/* TODO: Replace hardcoded days ago with calculated days ago*/} + Uploaded 2 days ago by {resumeInfo.user} +
+ +
+ + ); +} diff --git a/apps/portal/src/components/resumes/browse/FilterPill.tsx b/apps/portal/src/components/resumes/browse/FilterPill.tsx new file mode 100644 index 00000000..c34f4763 --- /dev/null +++ b/apps/portal/src/components/resumes/browse/FilterPill.tsx @@ -0,0 +1,15 @@ +type Props = Readonly<{ + onClick?: (event: React.MouseEvent) => void; + title: string; +}>; + +export default function FilterPill({ title, onClick }: Props) { + return ( + + ); +} diff --git a/apps/portal/src/components/resumes/browse/constants.ts b/apps/portal/src/components/resumes/browse/constants.ts new file mode 100644 index 00000000..6a52fc7f --- /dev/null +++ b/apps/portal/src/components/resumes/browse/constants.ts @@ -0,0 +1,69 @@ +export const SORT_OPTIONS = [ + { current: true, href: '#', name: 'Latest' }, + { current: false, href: '#', name: 'Popular' }, + { current: false, href: '#', name: 'Top Comments' }, +]; + +export const TOP_HITS = [ + { href: '#', name: 'Unreviewed' }, + { href: '#', name: 'Fresh Grad' }, + { href: '#', name: 'GOATs' }, + { href: '#', name: 'US Only' }, +]; + +export const ROLES = [ + { + checked: false, + label: 'Full-Stack Engineer', + value: 'Full-Stack Engineer', + }, + { checked: false, label: 'Frontend Engineer', value: 'frontend-engineer' }, + { checked: false, label: 'Backend Engineer', value: 'backend-engineer' }, + { checked: false, label: 'DevOps Engineer', value: 'devops-engineer' }, + { checked: false, label: 'iOS Engineer', value: 'ios-engineer' }, + { checked: false, label: 'Android Engineer', value: 'android-engineer' }, +]; + +export const EXPERIENCE = [ + { checked: false, label: 'Freshman', value: 'freshman' }, + { checked: false, label: 'Sophomore', value: 'sophomore' }, + { checked: false, label: 'Junior', value: 'junior' }, + { checked: false, label: 'Senior', value: 'senior' }, + { checked: false, label: 'Fresh Grad (0-1 years)', value: 'freshgrad' }, +]; + +export const LOCATION = [ + { checked: false, label: 'Singapore', value: 'singapore' }, + { checked: false, label: 'United States', value: 'usa' }, + { checked: false, label: 'India', value: 'india' }, +]; + +export const TEST_RESUMES = [ + { + createdAt: new Date(), + experience: 'Fresh Grad (0-1 years)', + numComments: 9, + numStars: 1, + role: 'Backend Engineer', + title: 'Rejected from multiple companies, please help...:(', + user: 'Git Ji Ra', + }, + { + createdAt: new Date(), + experience: 'Fresh Grad (0-1 years)', + numComments: 9, + numStars: 1, + role: 'Backend Engineer', + title: 'Rejected from multiple companies, please help...:(', + user: 'Git Ji Ra', + }, + { + createdAt: new Date(), + experience: 'Fresh Grad (0-1 years)', + numComments: 9, + numStars: 1, + role: 'Backend Engineer', + title: 'Rejected from multiple companies, please help...:(', + user: 'Git Ji Ra', + }, +]; diff --git a/apps/portal/src/components/resumes/comments/CommentsForm.tsx b/apps/portal/src/components/resumes/comments/CommentsForm.tsx new file mode 100644 index 00000000..c7b7dc6a --- /dev/null +++ b/apps/portal/src/components/resumes/comments/CommentsForm.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { Button, Dialog, TextInput } from '@tih/ui'; + +import { trpc } from '~/utils/trpc'; + +type CommentsFormProps = Readonly<{ + resumeId: string; + setShowCommentsForm: (show: boolean) => void; +}>; + +type IFormInput = { + education: string; + experience: string; + general: string; + projects: string; + skills: string; +}; + +type InputKeys = keyof IFormInput; + +export default function CommentsForm({ + resumeId, + setShowCommentsForm, +}: CommentsFormProps) { + const [showDialog, setShowDialog] = useState(false); + const { + register, + handleSubmit, + setValue, + formState: { isDirty }, + } = useForm({ + defaultValues: { + education: '', + experience: '', + general: '', + projects: '', + skills: '', + }, + }); + + const trpcContext = trpc.useContext(); + const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create', { + onSuccess: () => { + // New review added, invalidate query to trigger refetch + trpcContext.invalidateQueries(['resumes.reviews.list']); + }, + }); + + // TODO: Give a feedback to the user if the action succeeds/fails + const onSubmit: SubmitHandler = async (data) => { + return await reviewCreateMutation.mutate( + { + resumeId, + ...data, + }, + { + onSuccess: () => { + // Redirect back to comments section + setShowCommentsForm(false); + }, + }, + ); + }; + + const onCancel = () => { + if (isDirty) { + setShowDialog(true); + } else { + setShowCommentsForm(false); + } + }; + + const onValueChange = (section: InputKeys, value: string) => { + setValue(section, value.trim(), { shouldDirty: true }); + }; + + return ( +
+

Add your review

+

+ Please fill in at least one section to submit your review +

+ +
+ {/* TODO: Convert TextInput to TextArea */} +
+ onValueChange('general', value)} + /> + + onValueChange('education', value)} + /> + + onValueChange('experience', value)} + /> + + onValueChange('projects', value)} + /> + + onValueChange('skills', value)} + /> +
+ +
+
+
+ + setShowCommentsForm(false)} + /> + } + secondaryButton={ + +
+ ); +} diff --git a/apps/portal/src/components/resumes/comments/CommentsList.tsx b/apps/portal/src/components/resumes/comments/CommentsList.tsx new file mode 100644 index 00000000..54843bca --- /dev/null +++ b/apps/portal/src/components/resumes/comments/CommentsList.tsx @@ -0,0 +1,54 @@ +import { useSession } from 'next-auth/react'; +import { useState } from 'react'; +import { Tabs } from '@tih/ui'; + +import { trpc } from '~/utils/trpc'; + +import Comment from './comment/Comment'; +import CommentsListButton from './CommentsListButton'; +import { COMMENTS_SECTIONS } from './constants'; + +type CommentsListProps = Readonly<{ + resumeId: string; + setShowCommentsForm: (show: boolean) => void; +}>; + +export default function CommentsList({ + resumeId, + setShowCommentsForm, +}: CommentsListProps) { + const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value); + const { data: session } = useSession(); + + // Fetch the most updated comments to render + const commentsQuery = trpc.useQuery([ + 'resumes.reviews.list', + { resumeId, section: tab }, + ]); + + // TODO: Add loading prompt + + return ( +
+ + setTab(value)} + /> + +
+ {commentsQuery.data?.map((comment) => { + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/portal/src/components/resumes/comments/CommentsListButton.tsx b/apps/portal/src/components/resumes/comments/CommentsListButton.tsx new file mode 100644 index 00000000..3df23889 --- /dev/null +++ b/apps/portal/src/components/resumes/comments/CommentsListButton.tsx @@ -0,0 +1,48 @@ +import { signIn, useSession } from 'next-auth/react'; +import { Button } from '@tih/ui'; + +type CommentsListButtonProps = { + setShowCommentsForm: (show: boolean) => void; +}; + +export default function CommentsListButton({ + setShowCommentsForm, +}: CommentsListButtonProps) { + const { data: session, status } = useSession(); + const isSessionLoading = status === 'loading'; + + // Don't render anything + if (isSessionLoading) { + return null; + } + + // Not signed in + if (session == null) { + return ( +
+

+ { + event.preventDefault(); + signIn(); + }}> + Sign in + {' '} + to join discussion +

+
+ ); + } + + // Signed in. Return Add review button + return ( + + +
+
+
+
+
+
+
+
+
+
+ {detailsQuery.data.additionalInfo && ( +
+
+ )} +
+
+ +
+
+ +
+
+ + )} + + ); +} diff --git a/apps/portal/src/pages/resumes/index.tsx b/apps/portal/src/pages/resumes/index.tsx index fbc48f49..fb0ca8b0 100644 --- a/apps/portal/src/pages/resumes/index.tsx +++ b/apps/portal/src/pages/resumes/index.tsx @@ -1,11 +1,244 @@ +import clsx from 'clsx'; +import { Fragment, useState } from 'react'; +import { Disclosure, Menu, Transition } from '@headlessui/react'; +import { + ChevronDownIcon, + MinusIcon, + PlusIcon, +} from '@heroicons/react/20/solid'; +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { Tabs, TextInput } from '@tih/ui'; + +import BrowseListItem from '~/components/resumes/browse/BrowseListItem'; +import { + EXPERIENCE, + LOCATION, + ROLES, + SORT_OPTIONS, + TOP_HITS, +} from '~/components/resumes/browse/constants'; +import FilterPill from '~/components/resumes/browse/FilterPill'; + +const filters = [ + { + id: 'roles', + name: 'Roles', + options: ROLES, + }, + { + id: 'experience', + name: 'Experience', + options: EXPERIENCE, + }, + { + id: 'location', + name: 'Location', + options: LOCATION, + }, +]; import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle'; +import { trpc } from '~/utils/trpc'; + export default function ResumeHomePage() { + const [tabsValue, setTabsValue] = useState('all'); + const [searchValue, setSearchValue] = useState(''); + const resumesQuery = trpc.useQuery(['resumes.resume.list']); + return ( -
-
+
+
+
+
+
+
+

Filters

+
+
+
+
+ +
+
+
+ + +
+
+ +
+ + Sort + +
+ + + +
+ {SORT_OPTIONS.map((option) => ( + + {({ active }) => ( + + {option.name} + + )} + + ))} +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+

Categories

+
    + {TOP_HITS.map((category) => ( +
  • + {/* TODO: Replace onClick with filtering function */} + true} + /> +
  • + ))} +
+ + {filters.map((section) => ( + + {({ open }) => ( + <> +

+ + + {section.name} + + + {open ? ( + + +

+ +
+ {section.options.map((option, optionIdx) => ( +
+ + +
+ ))} +
+
+ + )} +
+ ))} +
+
+
+ {resumesQuery.isLoading ? ( +
Loading...
+ ) : ( +
+
    + {resumesQuery.data?.map((resumeObj) => ( +
  • + +
  • + ))} +
+
+ )} +
+
+
); } diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx index e725a730..54bb0e6d 100644 --- a/apps/portal/src/pages/resumes/submit.tsx +++ b/apps/portal/src/pages/resumes/submit.tsx @@ -1,10 +1,13 @@ import Head from 'next/head'; +import { useRouter } from 'next/router'; import { useMemo, useState } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { PaperClipIcon } from '@heroicons/react/24/outline'; import { Button, Select, TextInput } from '@tih/ui'; +import { trpc } from '~/utils/trpc'; + const TITLE_PLACEHOLDER = 'e.g. Applying for Company XYZ, please help me to review!'; const ADDITIONAL_INFO_PLACEHOLDER = `e.g. I’m applying for company XYZ. I have been resume-rejected by N companies that I have applied for. Please help me to review so company XYZ gives me an interview!`; @@ -13,7 +16,7 @@ const FILE_UPLOAD_ERROR = 'Please upload a PDF file that is less than 10MB.'; const MAX_FILE_SIZE_LIMIT = 10485760; type IFormInput = { - additionalInformation?: string; + additionalInfo?: string; experience: string; file: File; location: string; @@ -68,6 +71,9 @@ export default function SubmitResumeForm() { }, ]; + const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create'); + const router = useRouter(); + const [resumeFile, setResumeFile] = useState(); const [invalidFileUploadError, setInvalidFileUploadError] = useState< string | null @@ -81,10 +87,11 @@ export default function SubmitResumeForm() { formState: { errors }, } = useForm(); - // TODO: Add Create resume mutation - const onSubmit: SubmitHandler = (data) => { - alert(JSON.stringify(data)); - onClickReset(); + const onSubmit: SubmitHandler = async (data) => { + await resumeCreateMutation.mutate({ + ...data, + }); + router.push('/resumes'); }; const onUploadFile = (event: React.ChangeEvent) => { @@ -196,10 +203,10 @@ export default function SubmitResumeForm() {
{/* TODO: Use TextInputArea instead */} setValue('additionalInformation', val)} + onChange={(val) => setValue('additionalInfo', val)} />
diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 050f95ea..ddeff431 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -2,6 +2,11 @@ import superjson from 'superjson'; import { createRouter } from './context'; import { protectedExampleRouter } from './protected-example-router'; +import { resumesRouter } from './resumes'; +import { resumesDetailsRouter } from './resumes-details-router'; +import { resumesResumeUserRouter } from './resumes-resume-user-router'; +import { resumeReviewsRouter } from './resumes-reviews-router'; +import { resumesReviewsUserRouter } from './resumes-reviews-user-router'; import { todosRouter } from './todos'; import { todosUserRouter } from './todos-user-router'; @@ -12,7 +17,12 @@ export const appRouter = createRouter() // Example routers. Learn more about tRPC routers: https://trpc.io/docs/v9/router .merge('auth.', protectedExampleRouter) .merge('todos.', todosRouter) - .merge('todos.user.', todosUserRouter); + .merge('todos.user.', todosUserRouter) + .merge('resumes.resume.', resumesRouter) + .merge('resumes.details.', resumesDetailsRouter) + .merge('resumes.resume.user.', resumesResumeUserRouter) + .merge('resumes.reviews.', resumeReviewsRouter) + .merge('resumes.reviews.user.', resumesReviewsUserRouter); // Export type definition of API export type AppRouter = typeof appRouter; diff --git a/apps/portal/src/server/router/resumes-details-router.ts b/apps/portal/src/server/router/resumes-details-router.ts new file mode 100644 index 00000000..1255ac2e --- /dev/null +++ b/apps/portal/src/server/router/resumes-details-router.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +import { createRouter } from './context'; + +export const resumesDetailsRouter = createRouter() + .query('find', { + input: z.object({ + resumeId: z.string(), + }), + async resolve({ ctx, input }) { + const { resumeId } = input; + const userId = ctx.session?.user?.id; + + // Use the resumeId to query all related information of a single resume + // from Resumesresume: + return await ctx.prisma.resumesResume.findUnique({ + include: { + _count: { + select: { + stars: true, + }, + }, + stars: { + where: { + userId, + }, + }, + user: { + select: { + name: true, + }, + }, + }, + where: { + id: resumeId, + }, + }); + }, + }) + .mutation('update_star', { + input: z.object({ + resumeId: z.string(), + }), + async resolve({ ctx, input }) { + const { resumeId } = input; + // Update_star will only be called if user is logged in + const userId = ctx.session!.user!.id; + + // Use the resumeId and resumeProfileId to check if star exists + const resumesStar = await ctx.prisma.resumesStar.findUnique({ + select: { + id: true, + }, + where: { + userId_resumeId: { + resumeId, + userId, + }, + }, + }); + + if (resumesStar === null) { + return await ctx.prisma.resumesStar.create({ + data: { + resumeId, + userId, + }, + }); + } + return await ctx.prisma.resumesStar.delete({ + where: { + userId_resumeId: { + resumeId, + userId, + }, + }, + }); + }, + }); diff --git a/apps/portal/src/server/router/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes-resume-user-router.ts new file mode 100644 index 00000000..9f014795 --- /dev/null +++ b/apps/portal/src/server/router/resumes-resume-user-router.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { createProtectedRouter } from './context'; + +export const resumesResumeUserRouter = createProtectedRouter().mutation( + 'create', + { + // TODO: Use enums for experience, location, role + input: z.object({ + additionalInfo: z.string().optional(), + experience: z.string(), + location: z.string(), + role: z.string(), + title: 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, + }, + }); + }, + }, +); diff --git a/apps/portal/src/server/router/resumes-reviews-router.ts b/apps/portal/src/server/router/resumes-reviews-router.ts new file mode 100644 index 00000000..8219edce --- /dev/null +++ b/apps/portal/src/server/router/resumes-reviews-router.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { ResumesSection } from '@prisma/client'; + +import { createRouter } from './context'; + +import type { ResumeComment } from '~/types/resume-comments'; + +export const resumeReviewsRouter = createRouter().query('list', { + input: z.object({ + resumeId: z.string(), + section: z.nativeEnum(ResumesSection), + }), + async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; + const { resumeId, section } = input; + + // For this resume, we retrieve every comment's information, along with: + // The user's name and image to render + // Number of votes, and whether the user (if-any) has voted + const comments = await ctx.prisma.resumesComment.findMany({ + include: { + _count: { + select: { + votes: true, + }, + }, + user: { + select: { + image: true, + name: true, + }, + }, + votes: { + take: 1, + where: { + userId, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + where: { + resumeId, + section, + }, + }); + + return comments.map((data) => { + const hasVoted = data.votes.length > 0; + const numVotes = data._count.votes; + + const comment: ResumeComment = { + createdAt: data.createdAt, + description: data.description, + hasVoted, + id: data.id, + numVotes, + resumeId: data.resumeId, + section: data.section, + updatedAt: data.updatedAt, + user: { + image: data.user.image, + name: data.user.name, + userId: data.userId, + }, + }; + + return comment; + }); + }, +}); diff --git a/apps/portal/src/server/router/resumes-reviews-user-router.ts b/apps/portal/src/server/router/resumes-reviews-user-router.ts new file mode 100644 index 00000000..5730887f --- /dev/null +++ b/apps/portal/src/server/router/resumes-reviews-user-router.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; +import { ResumesSection } from '@prisma/client'; + +import { createProtectedRouter } from './context'; + +type IResumeCommentInput = Readonly<{ + description: string; + resumeId: string; + section: ResumesSection; + userId: string; +}>; + +export const resumesReviewsUserRouter = createProtectedRouter().mutation( + 'create', + { + input: z.object({ + education: z.string(), + experience: z.string(), + general: z.string(), + projects: z.string(), + resumeId: z.string(), + skills: z.string(), + }), + async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; + const { resumeId, education, experience, general, projects, skills } = + input; + + // For each section, convert them into ResumesComment model if provided + const comments: Array = [ + { description: education, section: ResumesSection.EDUCATION }, + { description: experience, section: ResumesSection.EXPERIENCE }, + { description: general, section: ResumesSection.GENERAL }, + { description: projects, section: ResumesSection.PROJECTS }, + { description: skills, section: ResumesSection.SKILLS }, + ] + .filter(({ description }) => { + return description.trim().length > 0; + }) + .map(({ description, section }) => { + return { + description, + resumeId, + section, + userId, + }; + }); + + return await ctx.prisma.resumesComment.createMany({ + data: comments, + }); + }, + }, +); diff --git a/apps/portal/src/server/router/resumes.ts b/apps/portal/src/server/router/resumes.ts new file mode 100644 index 00000000..2e7f9f9c --- /dev/null +++ b/apps/portal/src/server/router/resumes.ts @@ -0,0 +1,42 @@ +import { createRouter } from './context'; + +import type { Resume } from '~/types/resume'; + +export const resumesRouter = createRouter().query('list', { + async resolve({ ctx }) { + const resumesData = await ctx.prisma.resumesResume.findMany({ + include: { + _count: { + select: { + comments: true, + stars: true, + }, + }, + user: { + select: { + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + return resumesData.map((r) => { + const resume: Resume = { + additionalInfo: r.additionalInfo, + createdAt: r.createdAt, + experience: r.experience, + id: r.id, + location: r.location, + numComments: r._count.comments, + numStars: r._count.stars, + role: r.role, + title: r.title, + url: r.url, + user: r.user.name!, + }; + return resume; + }); + }, +}); diff --git a/apps/portal/src/types/resume-comments.d.ts b/apps/portal/src/types/resume-comments.d.ts new file mode 100644 index 00000000..5a6dfff8 --- /dev/null +++ b/apps/portal/src/types/resume-comments.d.ts @@ -0,0 +1,21 @@ +import type { ResumesSection } from '@prisma/client'; + +/** + * Returned by `resumeReviewsRouter` (query for 'resumes.reviews.list') and received as prop by `Comment` in `CommentsList` + * frontend-friendly representation of the query + */ +export type ResumeComment = { + createdAt: Date; + description: string; + hasVoted: boolean; + id: string; + numVotes: number; + resumeId: string; + section: ResumesSection; + updatedAt: Date; + user: { + image: string?; + name: string?; + userId: string; + }; +}; diff --git a/apps/portal/src/types/resume.d.ts b/apps/portal/src/types/resume.d.ts new file mode 100644 index 00000000..5b2a33a9 --- /dev/null +++ b/apps/portal/src/types/resume.d.ts @@ -0,0 +1,13 @@ +export type Resume = { + additionalInfo: string?; + createdAt: Date; + experience: string; + id: string; + location: string; + numComments: number; + numStars: number; + role: string; + title: string; + url: string; + user: string; +}; diff --git a/apps/storybook/stories/collapsible.stories.tsx b/apps/storybook/stories/collapsible.stories.tsx new file mode 100644 index 00000000..68826881 --- /dev/null +++ b/apps/storybook/stories/collapsible.stories.tsx @@ -0,0 +1,46 @@ +import type { ComponentMeta } from '@storybook/react'; +import { Collapsible } from '@tih/ui'; + +export default { + argTypes: { + children: { + control: { type: 'text' }, + }, + label: { + control: { type: 'text' }, + }, + }, + component: Collapsible, + title: 'Collapsible', +} as ComponentMeta; + +export const Basic = { + args: { + children: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', + label: 'Reveal more content below', + }, +}; + +export function AccordionLayout() { + return ( +
+
+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad + minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. + +
+
+ + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est + laborum. + +
+
+ ); +} diff --git a/apps/storybook/stories/horizontal-divider.stories.tsx b/apps/storybook/stories/horizontal-divider.stories.tsx new file mode 100644 index 00000000..7bee6bda --- /dev/null +++ b/apps/storybook/stories/horizontal-divider.stories.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import { HorizontalDivider } from '@tih/ui'; + +export default { + argTypes: {}, + component: HorizontalDivider, + title: 'HorizontalDivider', +} as ComponentMeta; + +export function Basic() { + return ( +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. +

+ +

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +

+
+ ); +} diff --git a/apps/storybook/stories/pagination.stories.tsx b/apps/storybook/stories/pagination.stories.tsx new file mode 100644 index 00000000..435bed7b --- /dev/null +++ b/apps/storybook/stories/pagination.stories.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import { Pagination } from '@tih/ui'; + +export default { + argTypes: {}, + component: Pagination, + title: 'Pagination', +} as ComponentMeta; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +function emptyFunction() {} + +export function Basic({ + current, + end, + start, + pagePadding, +}: Pick< + React.ComponentProps, + 'current' | 'end' | 'pagePadding' | 'start' +>) { + return ( +
+ +
+ ); +} + +Basic.args = { + current: 3, + end: 10, + pagePadding: 1, + start: 1, +}; + +export function Interaction() { + const [currentPage, setCurrentPage] = useState(5); + + return ( +
+
+ setCurrentPage(page)} + /> +
+
+ ); +} + +export function PageRanges() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +} + +export function PagePadding() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/storybook/stories/text-area.stories.tsx b/apps/storybook/stories/text-area.stories.tsx new file mode 100644 index 00000000..14d4acdb --- /dev/null +++ b/apps/storybook/stories/text-area.stories.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import type { TextAreaResize } from '@tih/ui'; +import { TextArea } from '@tih/ui'; + +const textAreaResize: ReadonlyArray = [ + 'vertical', + 'horizontal', + 'none', + 'both', +]; + +export default { + argTypes: { + autoComplete: { + control: 'text', + }, + disabled: { + control: 'boolean', + }, + errorMessage: { + control: 'text', + }, + isLabelHidden: { + control: 'boolean', + }, + label: { + control: 'text', + }, + name: { + control: 'text', + }, + placeholder: { + control: 'text', + }, + readOnly: { + control: 'boolean', + }, + required: { + control: 'boolean', + }, + resize: { + control: { type: 'select' }, + options: textAreaResize, + }, + rows: { + control: 'number', + }, + }, + component: TextArea, + title: 'TextArea', +} as ComponentMeta; + +export const Basic = { + args: { + label: 'Comment', + placeholder: 'Type your comment here', + }, +}; + +export function HiddenLabel() { + const [value, setValue] = useState(''); + + return ( +