diff --git a/apps/portal/.env.example b/apps/portal/.env.example index bf48e8b5..a3d958b7 100644 --- a/apps/portal/.env.example +++ b/apps/portal/.env.example @@ -8,3 +8,7 @@ NEXTAUTH_URL=http://localhost:3000 # Next Auth GitHub Provider GITHUB_CLIENT_ID=a5164b1943b5413ff2f5 GITHUB_CLIENT_SECRET= + +# Supabase +SUPABASE_URL= +SUPABASE_ANON_KEY= diff --git a/apps/portal/package.json b/apps/portal/package.json index 9948c01e..88ad7dfd 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -16,13 +16,16 @@ "@heroicons/react": "^2.0.11", "@next-auth/prisma-adapter": "^1.0.4", "@prisma/client": "^4.4.0", + "@supabase/supabase-js": "^1.35.7", "@tih/ui": "*", "@trpc/client": "^9.27.2", "@trpc/next": "^9.27.2", "@trpc/react": "^9.27.2", "@trpc/server": "^9.27.2", + "axios": "^1.1.2", "clsx": "^1.2.1", "date-fns": "^2.29.3", + "formidable": "^2.0.1", "next": "12.3.1", "next-auth": "~4.10.3", "react": "18.2.0", @@ -36,6 +39,7 @@ "devDependencies": { "@tih/tailwind-config": "*", "@tih/tsconfig": "*", + "@types/formidable": "^2.0.5", "@types/node": "^18.0.0", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", diff --git a/apps/portal/prisma/migrations/20221009093357_update_question_encounter_schema/migration.sql b/apps/portal/prisma/migrations/20221009093357_update_question_encounter_schema/migration.sql new file mode 100644 index 00000000..3972d1b5 --- /dev/null +++ b/apps/portal/prisma/migrations/20221009093357_update_question_encounter_schema/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `seenAt` to the `QuestionsQuestionEncounter` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "QuestionsQuestionEncounter" ADD COLUMN "seenAt" TIMESTAMP(3) NOT NULL; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 13287fea..59c53a40 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -206,6 +206,7 @@ model QuestionsQuestionEncounter { company String @db.Text location String @db.Text role String @db.Text + seenAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/portal/public/favicon.ico b/apps/portal/public/favicon.ico index d3b456c0..2b50a782 100644 Binary files a/apps/portal/public/favicon.ico and b/apps/portal/public/favicon.ico differ diff --git a/apps/portal/src/components/global/AppShell.tsx b/apps/portal/src/components/global/AppShell.tsx index fde9d3b6..eeb2dfc9 100644 --- a/apps/portal/src/components/global/AppShell.tsx +++ b/apps/portal/src/components/global/AppShell.tsx @@ -1,29 +1,21 @@ import clsx from 'clsx'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import { signIn, signOut, useSession } from 'next-auth/react'; import type { ReactNode } from 'react'; import { Fragment, useState } from 'react'; -import { Dialog, Menu, Transition } from '@headlessui/react'; -import { - Bars3BottomLeftIcon, - BriefcaseIcon, - CurrencyDollarIcon, - DocumentTextIcon, - HomeIcon, - XMarkIcon, -} from '@heroicons/react/24/outline'; +import { Menu, Transition } from '@headlessui/react'; +import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline'; -const sidebarNavigation = [ - { current: false, href: '/', icon: HomeIcon, name: 'Home' }, - { current: false, href: '/resumes', icon: DocumentTextIcon, name: 'Resumes' }, - { - current: false, - href: '/questions', - icon: BriefcaseIcon, - name: 'Questions', - }, - { current: false, href: '/offers', icon: CurrencyDollarIcon, name: 'Offers' }, -]; +import GlobalNavigation from '~/components/global/GlobalNavigation'; +import HomeNavigation from '~/components/global/HomeNavigation'; +import OffersNavigation from '~/components/offers/OffersNavigation'; +import QuestionsNavigation from '~/components/questions/QuestionsNavigation'; +import ResumesNavigation from '~/components/resumes/ResumesNavigation'; + +import MobileNavigation from './MobileNavigation'; +import type { ProductNavigationItems } from './ProductNavigation'; +import ProductNavigation from './ProductNavigation'; type Props = Readonly<{ children: ReactNode; @@ -39,14 +31,15 @@ function ProfileJewel() { if (session == null) { return ( - { event.preventDefault(); signIn(); }}> Sign in - + ); } @@ -65,7 +58,7 @@ function ProfileJewel() { return (
- + Open user menu {session?.user?.image == null ? ( Render some icon @@ -110,153 +103,95 @@ function ProfileJewel() { export default function AppShell({ children }: Props) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const router = useRouter(); + + const currentProductNavigation: Readonly<{ + navigation: ProductNavigationItems; + showGlobalNav: boolean; + title: string; + }> = (() => { + const path = router.pathname; + if (path.startsWith('/resumes')) { + return ResumesNavigation; + } + + if (path.startsWith('/offers')) { + return OffersNavigation; + } + + if (path.startsWith('/questions')) { + return QuestionsNavigation; + } + + return HomeNavigation; + })(); return (
{/* Narrow sidebar */} -
-
-
- Your Company -
-
- {sidebarNavigation.map((item) => ( - -
-
+ )} {/* Mobile menu */} - - - -
- - -
- - - -
- -
-
-
- Your Company -
-
- -
-
-
- -
-
-
+ {/* Content area */}
-
Some menu items
+
+ +
diff --git a/apps/portal/src/components/global/GlobalNavigation.ts b/apps/portal/src/components/global/GlobalNavigation.ts new file mode 100644 index 00000000..d60463ef --- /dev/null +++ b/apps/portal/src/components/global/GlobalNavigation.ts @@ -0,0 +1,24 @@ +import { + BriefcaseIcon, + CurrencyDollarIcon, + DocumentTextIcon, +} from '@heroicons/react/24/outline'; + +type GlobalNavigationItem = Readonly<{ + href: string; + icon: (props: React.ComponentProps<'svg'>) => JSX.Element; + name: string; +}>; +export type GlobalNavigationItems = ReadonlyArray; + +const globalNavigation: GlobalNavigationItems = [ + { href: '/offers', icon: CurrencyDollarIcon, name: 'Offers' }, + { + href: '/questions', + icon: BriefcaseIcon, + name: 'Questions', + }, + { href: '/resumes', icon: DocumentTextIcon, name: 'Resumes' }, +]; + +export default globalNavigation; diff --git a/apps/portal/src/components/global/HomeNavigation.ts b/apps/portal/src/components/global/HomeNavigation.ts new file mode 100644 index 00000000..073a7b36 --- /dev/null +++ b/apps/portal/src/components/global/HomeNavigation.ts @@ -0,0 +1,22 @@ +import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; + +const navigation: ProductNavigationItems = [ + { href: '/offers', name: 'Offers' }, + { href: '/questions', name: 'Question Bank' }, + { + children: [ + { href: '/resumes', name: 'View Resumes' }, + { href: '/resumes/submit', name: 'Submit Resume' }, + ], + href: '#', + name: 'Resumes', + }, +]; + +const config = { + navigation, + showGlobalNav: true, + title: 'Tech Interview Handbook', +}; + +export default config; diff --git a/apps/portal/src/components/global/MobileNavigation.tsx b/apps/portal/src/components/global/MobileNavigation.tsx new file mode 100644 index 00000000..8ce0591f --- /dev/null +++ b/apps/portal/src/components/global/MobileNavigation.tsx @@ -0,0 +1,132 @@ +import clsx from 'clsx'; +import Link from 'next/link'; +import { Fragment } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { HorizontalDivider } from '@tih/ui'; + +import type { GlobalNavigationItems } from './GlobalNavigation'; +import type { ProductNavigationItems } from './ProductNavigation'; + +type Props = Readonly<{ + globalNavigationItems: GlobalNavigationItems; + isShown?: boolean; + productNavigationItems: ProductNavigationItems; + productTitle: string; + setIsShown: (isShown: boolean) => void; +}>; + +export default function MobileNavigation({ + globalNavigationItems, + isShown, + productNavigationItems, + productTitle, + setIsShown, +}: Props) { + return ( + + + +
+ +
+ + + +
+ +
+
+
+ + Tech Interview Handbook + +
+
+
{productTitle}
+ +
+
+
+ +
+
+
+ ); +} diff --git a/apps/portal/src/components/global/ProductNavigation.tsx b/apps/portal/src/components/global/ProductNavigation.tsx new file mode 100644 index 00000000..caae6c08 --- /dev/null +++ b/apps/portal/src/components/global/ProductNavigation.tsx @@ -0,0 +1,75 @@ +import clsx from 'clsx'; +import Link from 'next/link'; +import { Fragment } from 'react'; +import { Menu, Transition } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/20/solid'; + +type NavigationItem = Readonly<{ + children?: ReadonlyArray; + href: string; + name: string; +}>; + +export type ProductNavigationItems = ReadonlyArray; + +type Props = Readonly<{ + items: ProductNavigationItems; + title: string; +}>; + +export default function ProductNavigation({ items, title }: Props) { + return ( + + ); +} diff --git a/apps/portal/src/components/offers/OffersNavigation.ts b/apps/portal/src/components/offers/OffersNavigation.ts new file mode 100644 index 00000000..c6d47806 --- /dev/null +++ b/apps/portal/src/components/offers/OffersNavigation.ts @@ -0,0 +1,23 @@ +import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; + +const navigation: ProductNavigationItems = [ + { + children: [ + { href: '#', name: 'Technical Support' }, + { href: '#', name: 'Sales' }, + { href: '#', name: 'General' }, + ], + href: '#', + name: 'Inboxes', + }, + { children: [], href: '#', name: 'Reporting' }, + { children: [], href: '#', name: 'Settings' }, +]; + +const config = { + navigation, + showGlobalNav: true, + title: 'Offers', +}; + +export default config; diff --git a/apps/portal/src/components/questions/NavBar.tsx b/apps/portal/src/components/questions/NavBar.tsx deleted file mode 100644 index 99b76457..00000000 --- a/apps/portal/src/components/questions/NavBar.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import Link from 'next/link'; - -const navigation = [ - { href: '/questions/landing', name: '*Landing*' }, - { href: '/questions', name: 'Home' }, - { href: '#', name: 'My Lists' }, - { href: '#', name: 'My Questions' }, - { href: '#', name: 'History' }, -]; - -export default function NavBar() { - return ( -
- -
- ); -} diff --git a/apps/portal/src/components/questions/QuestionsNavigation.ts b/apps/portal/src/components/questions/QuestionsNavigation.ts new file mode 100644 index 00000000..1a09afb0 --- /dev/null +++ b/apps/portal/src/components/questions/QuestionsNavigation.ts @@ -0,0 +1,16 @@ +import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; + +const navigation: ProductNavigationItems = [ + { href: '/questions', name: 'Home' }, + { href: '#', name: 'My Lists' }, + { href: '#', name: 'My Questions' }, + { href: '#', name: 'History' }, +]; + +const config = { + navigation, + showGlobalNav: true, + title: 'Questions Bank', +}; + +export default config; diff --git a/apps/portal/src/components/resumes/ResumePdf.tsx b/apps/portal/src/components/resumes/ResumePdf.tsx index 82e26395..5281a52b 100644 --- a/apps/portal/src/components/resumes/ResumePdf.tsx +++ b/apps/portal/src/components/resumes/ResumePdf.tsx @@ -1,10 +1,13 @@ -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'; -pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`; +import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys'; + +pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`; type Props = Readonly<{ url: string; @@ -12,18 +15,33 @@ type Props = Readonly<{ export default function ResumePdf({ url }: Props) { const [numPages, setNumPages] = useState(0); - const [pageNumber] = useState(1); + const [pageNumber, setPageNumber] = useState(1); + const [file, setFile] = useState(); 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 (
} + noData="" onLoadSuccess={onPdfLoadSuccess}> @@ -35,16 +53,18 @@ export default function ResumePdf({ url }: Props) { isLabelHidden={true} label="Previous" variant="tertiary" + onClick={() => setPageNumber(pageNumber - 1)} />

Page {pageNumber} of {numPages}

diff --git a/apps/portal/src/components/resumes/ResumesNavigation.ts b/apps/portal/src/components/resumes/ResumesNavigation.ts new file mode 100644 index 00000000..36633398 --- /dev/null +++ b/apps/portal/src/components/resumes/ResumesNavigation.ts @@ -0,0 +1,23 @@ +import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; + +const navigation: ProductNavigationItems = [ + { + children: [ + { href: '#', name: 'Technical Support' }, + { href: '#', name: 'Sales' }, + { href: '#', name: 'General' }, + ], + href: '#', + name: 'Inboxes', + }, + { children: [], href: '#', name: 'Reporting' }, + { children: [], href: '#', name: 'Settings' }, +]; + +const config = { + navigation, + showGlobalNav: false, + title: 'Resumes', +}; + +export default config; diff --git a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx b/apps/portal/src/components/resumes/browse/ResumeListItem.tsx similarity index 87% rename from apps/portal/src/components/resumes/browse/BrowseListItem.tsx rename to apps/portal/src/components/resumes/browse/ResumeListItem.tsx index 3c10a463..a76a503a 100644 --- a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx +++ b/apps/portal/src/components/resumes/browse/ResumeListItem.tsx @@ -35,8 +35,11 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
- Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '} - {resumeInfo.user} +
+ Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '} + {resumeInfo.user} +
+
{resumeInfo.location}
diff --git a/apps/portal/src/components/resumes/browse/ResumeListItems.tsx b/apps/portal/src/components/resumes/browse/ResumeListItems.tsx new file mode 100644 index 00000000..9e1203a5 --- /dev/null +++ b/apps/portal/src/components/resumes/browse/ResumeListItems.tsx @@ -0,0 +1,35 @@ +import { Spinner } from '@tih/ui'; + +import ResumseListItem from './ResumeListItem'; + +import type { Resume } from '~/types/resume'; + +type Props = Readonly<{ + isLoading: boolean; + resumes: Array; +}>; + +export default function ResumeListItems({ isLoading, resumes }: Props) { + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
    + {resumes.map((resumeObj: Resume) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/apps/portal/src/components/resumes/browse/constants.ts b/apps/portal/src/components/resumes/browse/constants.ts index 18448cee..6996538a 100644 --- a/apps/portal/src/components/resumes/browse/constants.ts +++ b/apps/portal/src/components/resumes/browse/constants.ts @@ -53,9 +53,9 @@ export const EXPERIENCE = [ ]; export const LOCATION = [ - { checked: false, label: 'Singapore', value: 'singapore' }, - { checked: false, label: 'United States', value: 'usa' }, - { checked: false, label: 'India', value: 'india' }, + { checked: false, label: 'Singapore', value: 'Singapore' }, + { checked: false, label: 'United States', value: 'United States' }, + { checked: false, label: 'India', value: 'India' }, ]; export const TEST_RESUMES = [ diff --git a/apps/portal/src/components/resumes/comments/CommentListItems.tsx b/apps/portal/src/components/resumes/comments/CommentListItems.tsx new file mode 100644 index 00000000..f043b78c --- /dev/null +++ b/apps/portal/src/components/resumes/comments/CommentListItems.tsx @@ -0,0 +1,35 @@ +import { useSession } from 'next-auth/react'; +import { Spinner } from '@tih/ui'; + +import Comment from './comment/Comment'; + +import type { ResumeComment } from '~/types/resume-comments'; + +type Props = Readonly<{ + comments: Array; + isLoading: boolean; +}>; + +export default function CommentListItems({ comments, isLoading }: Props) { + const { data: session } = useSession(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {comments.map((comment) => ( + + ))} +
+ ); +} diff --git a/apps/portal/src/components/resumes/comments/CommentsList.tsx b/apps/portal/src/components/resumes/comments/CommentsList.tsx index 54843bca..d40b500a 100644 --- a/apps/portal/src/components/resumes/comments/CommentsList.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsList.tsx @@ -1,10 +1,9 @@ -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 CommentListItems from './CommentListItems'; import CommentsListButton from './CommentsListButton'; import { COMMENTS_SECTIONS } from './constants'; @@ -18,15 +17,8 @@ export default function CommentsList({ 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 + const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]); return (
@@ -37,18 +29,10 @@ export default function CommentsList({ value={tab} onChange={(value) => setTab(value)} /> - -
- {commentsQuery.data?.map((comment) => { - return ( - - ); - })} -
+ c.section === tab) ?? []} + isLoading={commentsQuery.isFetching} + />
); } diff --git a/apps/portal/src/components/resumes/comments/CommentsSection.tsx b/apps/portal/src/components/resumes/comments/CommentsSection.tsx index b4e5f535..6df9c4cf 100644 --- a/apps/portal/src/components/resumes/comments/CommentsSection.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsSection.tsx @@ -10,15 +10,29 @@ type ICommentsSectionProps = { export default function CommentsSection({ resumeId }: ICommentsSectionProps) { const [showCommentsForm, setShowCommentsForm] = useState(false); - return showCommentsForm ? ( - - ) : ( - + return ( + <> +
+ + {showCommentsForm ? ( + + ) : ( + + )} + ); } diff --git a/apps/portal/src/components/shared/CompaniesTypeahead.tsx b/apps/portal/src/components/shared/CompaniesTypeahead.tsx new file mode 100644 index 00000000..b25f9cd8 --- /dev/null +++ b/apps/portal/src/components/shared/CompaniesTypeahead.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import type { TypeaheadOption } from '@tih/ui'; +import { Typeahead } from '@tih/ui'; + +import { trpc } from '~/utils/trpc'; + +type Props = Readonly<{ + disabled?: boolean; + onSelect: (option: TypeaheadOption) => void; +}>; + +export default function CompaniesTypeahead({ disabled, onSelect }: Props) { + const [query, setQuery] = useState(''); + const companies = trpc.useQuery([ + 'companies.list', + { + name: query, + }, + ]); + + const { data } = companies; + + return ( + ({ + id, + label: name, + value: id, + })) ?? [] + } + onQueryChange={setQuery} + onSelect={onSelect} + /> + ); +} diff --git a/apps/portal/src/components/shared/MonthYearPicker.tsx b/apps/portal/src/components/shared/MonthYearPicker.tsx new file mode 100644 index 00000000..bb6539f1 --- /dev/null +++ b/apps/portal/src/components/shared/MonthYearPicker.tsx @@ -0,0 +1,96 @@ +import { Select } from '@tih/ui'; + +type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + +export type MonthYear = Readonly<{ + month: Month; + year: number; +}>; + +type Props = Readonly<{ + onChange: (value: MonthYear) => void; + value: MonthYear; +}>; + +const MONTH_OPTIONS = [ + { + label: 'January', + value: 1, + }, + { + label: 'February', + value: 2, + }, + { + label: 'March', + value: 3, + }, + { + label: 'April', + value: 4, + }, + { + label: 'May', + value: 5, + }, + { + label: 'June', + value: 6, + }, + { + label: 'July', + value: 7, + }, + { + label: 'August', + value: 8, + }, + { + label: 'September', + value: 9, + }, + { + label: 'October', + value: 10, + }, + { + label: 'November', + value: 11, + }, + { + label: 'December', + value: 12, + }, +]; + +const NUM_YEARS = 5; +const YEAR_OPTIONS = Array.from({ length: NUM_YEARS }, (_, i) => { + const year = new Date().getFullYear() - NUM_YEARS + i + 1; + return { + label: String(year), + value: year, + }; +}); + +export default function MonthYearPicker({ value, onChange }: Props) { + return ( +
+ + onChange({ month: value.month, year: Number(newYear) }) + } + /> +
+ ); +} diff --git a/apps/portal/src/constants/file-storage-keys.ts b/apps/portal/src/constants/file-storage-keys.ts new file mode 100644 index 00000000..753a5892 --- /dev/null +++ b/apps/portal/src/constants/file-storage-keys.ts @@ -0,0 +1 @@ +export const RESUME_STORAGE_KEY = 'resumes'; diff --git a/apps/portal/src/env/schema.mjs b/apps/portal/src/env/schema.mjs index d9525f82..69064437 100644 --- a/apps/portal/src/env/schema.mjs +++ b/apps/portal/src/env/schema.mjs @@ -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(), }); /** diff --git a/apps/portal/src/pages/api/file-storage.ts b/apps/portal/src/pages/api/file-storage.ts new file mode 100644 index 00000000..d57f2453 --- /dev/null +++ b/apps/portal/src/pages/api/file-storage.ts @@ -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); + } +} diff --git a/apps/portal/src/pages/index.tsx b/apps/portal/src/pages/index.tsx index 2d3ab330..1a8a1b64 100644 --- a/apps/portal/src/pages/index.tsx +++ b/apps/portal/src/pages/index.tsx @@ -1,15 +1,32 @@ -import { Button, Spinner } from '@tih/ui'; +import { useState } from 'react'; +import type { TypeaheadOption } from '@tih/ui'; +import { HorizontalDivider } from '@tih/ui'; + +import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; +import type { MonthYear } from '~/components/shared/MonthYearPicker'; +import MonthYearPicker from '~/components/shared/MonthYearPicker'; export default function HomePage() { + const [selectedCompany, setSelectedCompany] = + useState(null); + const [monthYear, setMonthYear] = useState({ + month: new Date().getMonth() + 1, + year: new Date().getFullYear(), + }); + return (
-
+

Homepage

-
diff --git a/apps/portal/src/pages/questions/index.tsx b/apps/portal/src/pages/questions/index.tsx index cf6611e3..ee7b3512 100644 --- a/apps/portal/src/pages/questions/index.tsx +++ b/apps/portal/src/pages/questions/index.tsx @@ -3,7 +3,6 @@ import { useMemo, useState } from 'react'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; import type { FilterOptions } from '~/components/questions/filter/FilterSection'; import FilterSection from '~/components/questions/filter/FilterSection'; -import NavBar from '~/components/questions/NavBar'; import QuestionOverviewCard from '~/components/questions/QuestionOverviewCard'; import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; @@ -98,10 +97,7 @@ export default function QuestionsHomePage() { return (
-
- -
-
+

Filter by

diff --git a/apps/portal/src/pages/resumes/[resumeId].tsx b/apps/portal/src/pages/resumes/[resumeId].tsx index 7103655b..52d4f3b7 100644 --- a/apps/portal/src/pages/resumes/[resumeId].tsx +++ b/apps/portal/src/pages/resumes/[resumeId].tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import formatDistanceToNow from 'date-fns/formatDistanceToNow'; import Error from 'next/error'; +import Head from 'next/head'; import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; import { useEffect } from 'react'; @@ -26,17 +27,17 @@ export default function ResumeReviewPage() { const { data: session } = useSession(); const router = useRouter(); const { resumeId } = router.query; - const utils = trpc.useContext(); + const trpcContext = trpc.useContext(); // Safe to assert resumeId type as string because query is only sent if so const detailsQuery = trpc.useQuery( - ['resumes.details.find', { resumeId: resumeId as string }], + ['resumes.resume.findOne', { resumeId: resumeId as string }], { - enabled: typeof resumeId === 'string' && session?.user?.id !== undefined, + enabled: typeof resumeId === 'string', }, ); - const starMutation = trpc.useMutation('resumes.details.update_star', { + const starMutation = trpc.useMutation('resumes.star.user.create_or_delete', { onSuccess() { - utils.invalidateQueries(); + trpcContext.invalidateQueries(['resumes.resume.findOne']); }, }); @@ -59,88 +60,98 @@ export default function ResumeReviewPage() { return ( <> {detailsQuery.isError && ErrorPage} - {detailsQuery.isLoading && } + {detailsQuery.isLoading && ( +
+ {' '} + {' '} +
+ )} {detailsQuery.isFetched && detailsQuery.data && ( -
-
-

- {detailsQuery.data.title} -

- +
+
+
+
-
-
-
-
-
-
-
-
-
-
- {detailsQuery.data.additionalInfo && ( -
-
- )} -
-
- + {detailsQuery.data.role} +
+
+
+
+
+
+
-
- + {detailsQuery.data.additionalInfo && ( +
+
+ )} +
+
+ +
+
+ +
-
-
+
+ )} ); diff --git a/apps/portal/src/pages/resumes/index.tsx b/apps/portal/src/pages/resumes/index.tsx index 99891d5e..fab32bfe 100644 --- a/apps/portal/src/pages/resumes/index.tsx +++ b/apps/portal/src/pages/resumes/index.tsx @@ -1,7 +1,8 @@ import clsx from 'clsx'; +import Head from 'next/head'; import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; -import { Fragment, useEffect, useState } from 'react'; +import { Fragment, useState } from 'react'; import { Disclosure, Menu, Transition } from '@headlessui/react'; import { ChevronDownIcon, @@ -11,7 +12,6 @@ import { import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { Tabs, TextInput } from '@tih/ui'; -import BrowseListItem from '~/components/resumes/browse/BrowseListItem'; import { BROWSE_TABS_VALUES, EXPERIENCE, @@ -21,6 +21,10 @@ import { TOP_HITS, } from '~/components/resumes/browse/constants'; import FilterPill from '~/components/resumes/browse/FilterPill'; +import ResumeListItems from '~/components/resumes/browse/ResumeListItems'; +import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle'; + +import { trpc } from '~/utils/trpc'; import type { Resume } from '~/types/resume'; @@ -41,54 +45,41 @@ const filters = [ options: LOCATION, }, ]; -import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle'; - -import { trpc } from '~/utils/trpc'; export default function ResumeHomePage() { - const { data } = useSession(); + const { data: sessionData } = useSession(); const router = useRouter(); const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL); const [searchValue, setSearchValue] = useState(''); - const [resumes, setResumes] = useState(Array()); + const [resumes, setResumes] = useState>([]); - const allResumesQuery = trpc.useQuery(['resumes.resume.all'], { + const allResumesQuery = trpc.useQuery(['resumes.resume.findAll'], { enabled: tabsValue === BROWSE_TABS_VALUES.ALL, + onSuccess: (data) => { + setResumes(data); + }, }); - const starredResumesQuery = trpc.useQuery(['resumes.resume.browse.stars'], { - enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, - }); - const myResumesQuery = trpc.useQuery(['resumes.resume.browse.my'], { - enabled: tabsValue === BROWSE_TABS_VALUES.MY, - }); - - useEffect(() => { - switch (tabsValue) { - case BROWSE_TABS_VALUES.ALL: { - setResumes(allResumesQuery.data ?? Array()); - break; - } - case BROWSE_TABS_VALUES.STARRED: { - setResumes(starredResumesQuery.data ?? Array()); - break; - } - case BROWSE_TABS_VALUES.MY: { - setResumes(myResumesQuery.data ?? Array()); - break; - } - default: { - setResumes(Array()); - } - } - }, [ - allResumesQuery.data, - starredResumesQuery.data, - myResumesQuery.data, - tabsValue, - ]); + const starredResumesQuery = trpc.useQuery( + ['resumes.resume.user.findUserStarred'], + { + enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, + onSuccess: (data) => { + setResumes(data); + }, + }, + ); + const myResumesQuery = trpc.useQuery( + ['resumes.resume.user.findUserCreated'], + { + enabled: tabsValue === BROWSE_TABS_VALUES.MY, + onSuccess: (data) => { + setResumes(data); + }, + }, + ); const onClickNew = () => { - if (data?.user?.id) { + if (sessionData?.user?.id) { router.push('/resumes/submit'); } else { // TODO: Handle non-logged in user behaviour @@ -96,205 +87,201 @@ export default function ResumeHomePage() { }; return ( -
-
- -
-
-
-
-
-

Filters

-
-
-
-
- -
-
-
- + + Resume Review Portal + +
+
+ +
+
+
+
+
+

Filters

+
+
+
+
+ - -
-
- -
- - Sort - -
+
+
+
+ + +
+
+ +
+ {/* TODO: Sort logic */} + + Sort + +
- - -
- {SORT_OPTIONS.map((option) => ( - - {({ active }) => ( - - {option.name} - - )} - - ))} -
-
-
-
-
-
- + + +
+ {SORT_OPTIONS.map((option) => ( + + {({ active }) => ( + + {option.name} + + )} + + ))} +
+
+
+
+ +
+ +
- -
-
-
-
-

Categories

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

Categories

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

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

- -
- {section.options.map((option, optionIdx) => ( -
- - -
- ))} -
-
- - )} -
- ))} - + {filters.map((section) => ( + + {({ open }) => ( + <> +

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

+ +
+ {section.options.map((option, optionIdx) => ( +
+ + +
+ ))} +
+
+ + )} +
+ ))} + +
+
- {allResumesQuery.isLoading || - starredResumesQuery.isLoading || - myResumesQuery.isLoading ? ( -
Loading...
- ) : ( -
-
    - {resumes.map((resumeObj) => ( -
  • - -
  • - ))} -
-
- )}
-
- + + ); } diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx index b95a202a..1ca06031 100644 --- a/apps/portal/src/pages/resumes/submit.tsx +++ b/apps/portal/src/pages/resumes/submit.tsx @@ -1,3 +1,4 @@ +import axios from 'axios'; import clsx from 'clsx'; import Head from 'next/head'; import { useRouter } from 'next/router'; @@ -5,7 +6,7 @@ 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, TextArea, TextInput } from '@tih/ui'; +import { Button, CheckboxInput, Select, TextArea, TextInput } from '@tih/ui'; import { EXPERIENCE, @@ -13,19 +14,22 @@ import { ROLES, } from '~/components/resumes/browse/constants'; +import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys'; import { trpc } from '~/utils/trpc'; +const FILE_SIZE_LIMIT_MB = 3; +const FILE_SIZE_LIMIT_BYTES = FILE_SIZE_LIMIT_MB * 1000000; + 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!`; -const FILE_UPLOAD_ERROR = 'Please upload a PDF file that is less than 10MB.'; - -const MAX_FILE_SIZE_LIMIT = 10000000; +const FILE_UPLOAD_ERROR = `Please upload a PDF file that is less than ${FILE_SIZE_LIMIT_MB}MB.`; type IFormInput = { additionalInfo?: string; experience: string; file: File; + isChecked: boolean; location: string; role: string; title: string; @@ -36,6 +40,7 @@ export default function SubmitResumeForm() { const router = useRouter(); const [resumeFile, setResumeFile] = useState(); + const [isLoading, setIsLoading] = useState(false); const [invalidFileUploadError, setInvalidFileUploadError] = useState< string | null >(null); @@ -46,13 +51,51 @@ export default function SubmitResumeForm() { setValue, reset, formState: { errors }, - } = useForm(); + } = useForm({ + defaultValues: { + isChecked: false, + }, + }); const onSubmit: SubmitHandler = async (data) => { - await resumeCreateMutation.mutate({ - ...data, + if (resumeFile == null) { + console.error('Resume file is empty'); + return; + } + setIsLoading(true); + + 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', + }, }); - router.push('/resumes'); + const { url } = res.data; + + resumeCreateMutation.mutate( + { + additionalInfo: data.additionalInfo, + experience: data.experience, + location: data.location, + role: data.role, + title: data.title, + url, + }, + { + onError: (error) => { + console.error(error); + }, + onSettled: () => { + setIsLoading(false); + }, + onSuccess: () => { + router.push('/resumes'); + }, + }, + ); }; const onUploadFile = (event: React.ChangeEvent) => { @@ -60,7 +103,7 @@ export default function SubmitResumeForm() { if (file == null) { return; } - if (file.type !== 'application/pdf' || file.size > MAX_FILE_SIZE_LIMIT) { + if (file.type !== 'application/pdf' || file.size > FILE_SIZE_LIMIT_BYTES) { setInvalidFileUploadError(FILE_UPLOAD_ERROR); return; } @@ -85,7 +128,7 @@ export default function SubmitResumeForm() { return ( <> - Upload a resume + Upload a Resume
-

PDF up to 10MB

+

+ PDF up to {FILE_SIZE_LIMIT_MB}MB +

{fileUploadError && (

{fileUploadError}

)} -
+