Merge branch 'main' into hongpo/add-question-comment-crud

pull/330/head
hpkoh 3 years ago
commit 7a45c3fb82

@ -8,3 +8,7 @@ NEXTAUTH_URL=http://localhost:3000
# Next Auth GitHub Provider # Next Auth GitHub Provider
GITHUB_CLIENT_ID=a5164b1943b5413ff2f5 GITHUB_CLIENT_ID=a5164b1943b5413ff2f5
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
# Supabase
SUPABASE_URL=
SUPABASE_ANON_KEY=

@ -16,13 +16,16 @@
"@heroicons/react": "^2.0.11", "@heroicons/react": "^2.0.11",
"@next-auth/prisma-adapter": "^1.0.4", "@next-auth/prisma-adapter": "^1.0.4",
"@prisma/client": "^4.4.0", "@prisma/client": "^4.4.0",
"@supabase/supabase-js": "^1.35.7",
"@tih/ui": "*", "@tih/ui": "*",
"@trpc/client": "^9.27.2", "@trpc/client": "^9.27.2",
"@trpc/next": "^9.27.2", "@trpc/next": "^9.27.2",
"@trpc/react": "^9.27.2", "@trpc/react": "^9.27.2",
"@trpc/server": "^9.27.2", "@trpc/server": "^9.27.2",
"axios": "^1.1.2",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"formidable": "^2.0.1",
"next": "12.3.1", "next": "12.3.1",
"next-auth": "~4.10.3", "next-auth": "~4.10.3",
"react": "18.2.0", "react": "18.2.0",
@ -36,6 +39,7 @@
"devDependencies": { "devDependencies": {
"@tih/tailwind-config": "*", "@tih/tailwind-config": "*",
"@tih/tsconfig": "*", "@tih/tsconfig": "*",
"@types/formidable": "^2.0.5",
"@types/node": "^18.0.0", "@types/node": "^18.0.0",
"@types/react": "^18.0.21", "@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",

@ -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;

@ -206,6 +206,7 @@ model QuestionsQuestionEncounter {
company String @db.Text company String @db.Text
location String @db.Text location String @db.Text
role String @db.Text role String @db.Text
seenAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

@ -1,29 +1,21 @@
import clsx from 'clsx'; import clsx from 'clsx';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router';
import { signIn, signOut, useSession } from 'next-auth/react'; import { signIn, signOut, useSession } from 'next-auth/react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { Dialog, Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline';
Bars3BottomLeftIcon,
BriefcaseIcon,
CurrencyDollarIcon,
DocumentTextIcon,
HomeIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
const sidebarNavigation = [ import GlobalNavigation from '~/components/global/GlobalNavigation';
{ current: false, href: '/', icon: HomeIcon, name: 'Home' }, import HomeNavigation from '~/components/global/HomeNavigation';
{ current: false, href: '/resumes', icon: DocumentTextIcon, name: 'Resumes' }, import OffersNavigation from '~/components/offers/OffersNavigation';
{ import QuestionsNavigation from '~/components/questions/QuestionsNavigation';
current: false, import ResumesNavigation from '~/components/resumes/ResumesNavigation';
href: '/questions',
icon: BriefcaseIcon, import MobileNavigation from './MobileNavigation';
name: 'Questions', import type { ProductNavigationItems } from './ProductNavigation';
}, import ProductNavigation from './ProductNavigation';
{ current: false, href: '/offers', icon: CurrencyDollarIcon, name: 'Offers' },
];
type Props = Readonly<{ type Props = Readonly<{
children: ReactNode; children: ReactNode;
@ -39,14 +31,15 @@ function ProfileJewel() {
if (session == null) { if (session == null) {
return ( return (
<a <Link
className="text-sm font-medium"
href="/api/auth/signin" href="/api/auth/signin"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
signIn(); signIn();
}}> }}>
Sign in Sign in
</a> </Link>
); );
} }
@ -65,7 +58,7 @@ function ProfileJewel() {
return ( return (
<Menu as="div" className="relative flex-shrink-0"> <Menu as="div" className="relative flex-shrink-0">
<div> <div>
<Menu.Button className="flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"> <Menu.Button className="focus:ring-primary-500 flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-offset-2">
<span className="sr-only">Open user menu</span> <span className="sr-only">Open user menu</span>
{session?.user?.image == null ? ( {session?.user?.image == null ? (
<span>Render some icon</span> <span>Render some icon</span>
@ -110,153 +103,95 @@ function ProfileJewel() {
export default function AppShell({ children }: Props) { export default function AppShell({ children }: Props) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 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 ( return (
<div className="flex h-full min-h-screen"> <div className="flex h-full min-h-screen">
{/* Narrow sidebar */} {/* Narrow sidebar */}
<div className="hidden w-28 overflow-y-auto bg-indigo-700 md:block"> {currentProductNavigation.showGlobalNav && (
<div className="flex w-full flex-col items-center py-6"> <div className="hidden w-28 overflow-y-auto border-r border-slate-200 bg-white md:block">
<div className="flex flex-shrink-0 items-center"> <div className="flex w-full flex-col items-center py-6">
<img <div className="flex flex-shrink-0 items-center">
alt="Your Company" <Link href="/">
className="h-8 w-auto" <img
src="https://tailwindui.com/img/logos/mark.svg?color=white" alt="Tech Interview Handbook"
/> className="h-8 w-auto"
</div> src="/logo.svg"
<div className="mt-6 w-full flex-1 space-y-1 px-2">
{sidebarNavigation.map((item) => (
<Link
key={item.name}
aria-current={item.current ? 'page' : undefined}
className={clsx(
item.current
? 'bg-indigo-800 text-white'
: 'text-indigo-100 hover:bg-indigo-800 hover:text-white',
'group flex w-full flex-col items-center rounded-md p-3 text-xs font-medium',
)}
href={item.href}>
<item.icon
aria-hidden="true"
className={clsx(
item.current
? 'text-white'
: 'text-indigo-300 group-hover:text-white',
'h-6 w-6',
)}
/> />
<span className="mt-2">{item.name}</span>
</Link> </Link>
))} </div>
<div className="mt-6 w-full flex-1 space-y-1 px-2">
{GlobalNavigation.map((item) => (
<Link
key={item.name}
className={clsx(
'text-slate-700 hover:bg-slate-100',
'group flex w-full flex-col items-center rounded-md p-3 text-xs font-medium',
)}
href={item.href}>
<item.icon
aria-hidden="true"
className={clsx(
'text-slate-500 group-hover:text-slate-700',
'h-6 w-6',
)}
/>
<span className="mt-2">{item.name}</span>
</Link>
))}
</div>
</div> </div>
</div> </div>
</div> )}
{/* Mobile menu */} {/* Mobile menu */}
<Transition.Root as={Fragment} show={mobileMenuOpen}> <MobileNavigation
<Dialog globalNavigationItems={GlobalNavigation}
as="div" isShown={mobileMenuOpen}
className="relative z-20 md:hidden" productNavigationItems={currentProductNavigation.navigation}
onClose={setMobileMenuOpen}> productTitle={currentProductNavigation.title}
<Transition.Child setIsShown={setMobileMenuOpen}
as={Fragment} />
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full">
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-indigo-700 pt-5 pb-4">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="absolute top-1 right-0 -mr-14 p-1">
<button
className="flex h-12 w-12 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-white"
type="button"
onClick={() => setMobileMenuOpen(false)}>
<XMarkIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
<span className="sr-only">Close sidebar</span>
</button>
</div>
</Transition.Child>
<div className="flex flex-shrink-0 items-center px-4">
<img
alt="Your Company"
className="h-8 w-auto"
src="https://tailwindui.com/img/logos/mark.svg?color=white"
/>
</div>
<div className="mt-5 h-0 flex-1 overflow-y-auto px-2">
<nav className="flex h-full flex-col">
<div className="space-y-1">
{sidebarNavigation.map((item) => (
<a
key={item.name}
aria-current={item.current ? 'page' : undefined}
className={clsx(
item.current
? 'bg-indigo-800 text-white'
: 'text-indigo-100 hover:bg-indigo-800 hover:text-white',
'group flex items-center rounded-md py-2 px-3 text-sm font-medium',
)}
href={item.href}>
<item.icon
aria-hidden="true"
className={clsx(
item.current
? 'text-white'
: 'text-indigo-300 group-hover:text-white',
'mr-3 h-6 w-6',
)}
/>
<span>{item.name}</span>
</a>
))}
</div>
</nav>
</div>
</Dialog.Panel>
</Transition.Child>
<div aria-hidden="true" className="w-14 flex-shrink-0">
{/* Dummy element to force sidebar to shrink to fit close icon */}
</div>
</div>
</Dialog>
</Transition.Root>
{/* Content area */} {/* Content area */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<header className="w-full"> <header className="w-full">
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white shadow-sm"> <div className="relative z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white shadow-sm">
<button <button
className="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 md:hidden" className="focus:ring-primary-500 border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset md:hidden"
type="button" type="button"
onClick={() => setMobileMenuOpen(true)}> onClick={() => setMobileMenuOpen(true)}>
<span className="sr-only">Open sidebar</span> <span className="sr-only">Open sidebar</span>
<Bars3BottomLeftIcon aria-hidden="true" className="h-6 w-6" /> <Bars3BottomLeftIcon aria-hidden="true" className="h-6 w-6" />
</button> </button>
<div className="flex flex-1 justify-between px-4 sm:px-6"> <div className="flex flex-1 justify-between px-4 sm:px-6">
<div className="flex flex-1 items-center">Some menu items</div> <div className="flex flex-1 items-center">
<ProductNavigation
items={currentProductNavigation.navigation}
title={currentProductNavigation.title}
/>
</div>
<div className="ml-2 flex items-center space-x-4 sm:ml-6 sm:space-x-6"> <div className="ml-2 flex items-center space-x-4 sm:ml-6 sm:space-x-6">
<ProfileJewel /> <ProfileJewel />
</div> </div>

@ -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<GlobalNavigationItem>;
const globalNavigation: GlobalNavigationItems = [
{ href: '/offers', icon: CurrencyDollarIcon, name: 'Offers' },
{
href: '/questions',
icon: BriefcaseIcon,
name: 'Questions',
},
{ href: '/resumes', icon: DocumentTextIcon, name: 'Resumes' },
];
export default globalNavigation;

@ -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;

@ -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 (
<Transition.Root as={Fragment} show={isShown}>
<Dialog as="div" className="relative z-20 md:hidden" onClose={setIsShown}>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full">
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="absolute top-1 right-0 -mr-14 p-1">
<button
className="flex h-12 w-12 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-white"
type="button"
onClick={() => setIsShown(false)}>
<XMarkIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
<span className="sr-only">Close sidebar</span>
</button>
</div>
</Transition.Child>
<div className="flex flex-shrink-0 items-center px-4">
<Link href="/">
<img
alt="Tech Interview Handbook"
className="h-8 w-auto"
src="/logo.svg"
/>
</Link>
</div>
<div className="mt-5 h-0 flex-1 overflow-y-auto px-2">
<div className="mb-2 px-3 py-2 font-medium">{productTitle}</div>
<nav className="flex flex-col">
<div className="space-y-1">
{productNavigationItems.map((item) => (
<a
key={item.name}
className={clsx(
'text-slate-700 hover:bg-slate-100',
'group flex items-center rounded-md py-2 px-3 text-sm font-medium',
)}
href={item.href}>
<span>{item.name}</span>
</a>
))}
</div>
<div className="px-3">
<HorizontalDivider />
</div>
<div className="mb-2 px-3 py-2 text-sm font-medium">
Other Products
</div>
<div className="space-y-1">
{globalNavigationItems.map((item) => (
<a
key={item.name}
className={clsx(
'text-slate-700 hover:bg-slate-100',
'group flex items-center rounded-md py-2 px-3 text-sm font-medium',
)}
href={item.href}>
<item.icon
aria-hidden="true"
className={clsx(
'text-slate-500 group-hover:text-slate-700',
'mr-3 h-6 w-6',
)}
/>
<span>{item.name}</span>
</a>
))}
</div>
</nav>
</div>
</Dialog.Panel>
</Transition.Child>
<div aria-hidden="true" className="w-14 flex-shrink-0">
{/* Dummy element to force sidebar to shrink to fit close icon */}
</div>
</div>
</Dialog>
</Transition.Root>
);
}

@ -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<NavigationItem>;
href: string;
name: string;
}>;
export type ProductNavigationItems = ReadonlyArray<NavigationItem>;
type Props = Readonly<{
items: ProductNavigationItems;
title: string;
}>;
export default function ProductNavigation({ items, title }: Props) {
return (
<nav aria-label="Global" className="flex space-x-8">
<span className="text-primary-700 text-sm font-medium">{title}</span>
<div className="hidden space-x-8 md:flex">
{items.map((item) =>
item.children != null && item.children.length > 0 ? (
<Menu key={item.name} as="div" className="relative text-left">
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
<span>{item.name}</span>
<ChevronDownIcon
aria-hidden="true"
className="ml-1 h-5 w-5 text-slate-500"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items className="absolute left-0 z-10 mt-2 w-40 origin-top-left rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{item.children.map((child) => (
<Menu.Item key={child.name}>
{({ active }) => (
<Link
className={clsx(
active ? 'bg-slate-100' : '',
'block px-4 py-2 text-sm text-slate-700',
)}
href={child.href}>
{child.name}
</Link>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
) : (
<Link
key={item.name}
className="hover:text-primary-600 text-sm font-medium text-slate-900"
href={item.href}>
{item.name}
</Link>
),
)}
</div>
</nav>
);
}

@ -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;

@ -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 (
<header className="bg-indigo-600">
<nav aria-label="Top" className="max-w-8xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex w-full items-center justify-between border-b border-indigo-500 py-3 lg:border-none">
<div className="flex items-center">
<a className="flex items-center" href="/questions">
<span className="sr-only">TIH Question Bank</span>
<img alt="TIH Logo" className="h-10 w-auto" src="/logo.svg" />
<span className="ml-4 font-bold text-white">
TIH Question Bank
</span>
</a>
<div className="ml-8 hidden space-x-6 lg:block">
{navigation.map((link) => (
<Link
key={link.name}
className="font-sm text-sm text-white hover:text-indigo-50"
href={link.href}>
{link.name}
</Link>
))}
</div>
</div>
<div className="ml-8 space-x-4">
<a
className="inline-block rounded-md border border-transparent bg-indigo-500 py-2 px-4 text-base font-medium text-white hover:bg-opacity-75"
href="#">
Sign in
</a>
</div>
</div>
<div className="flex flex-wrap justify-center space-x-6 py-4 lg:hidden">
{navigation.map((link) => (
<Link
key={link.name}
className="text-base font-medium text-white hover:text-indigo-50"
href={link.href}>
{link.name}
</Link>
))}
</div>
</nav>
</header>
);
}

@ -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;

@ -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 { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist'; import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui'; 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<{ type Props = Readonly<{
url: string; url: string;
@ -12,18 +15,33 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) { export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
const [pageNumber] = useState(1); const [pageNumber, setPageNumber] = useState(1);
const [file, setFile] = useState<File>();
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => { const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages); 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 ( return (
<div> <div>
<Document <Document
className="h-[calc(100vh-17rem)] overflow-scroll" className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-scroll"
file={url} file={file}
loading={<Spinner display="block" label="" size="lg" />} loading={<Spinner display="block" label="" size="lg" />}
noData=""
onLoadSuccess={onPdfLoadSuccess}> onLoadSuccess={onPdfLoadSuccess}>
<Page pageNumber={pageNumber} /> <Page pageNumber={pageNumber} />
</Document> </Document>
@ -35,16 +53,18 @@ export default function ResumePdf({ url }: Props) {
isLabelHidden={true} isLabelHidden={true}
label="Previous" label="Previous"
variant="tertiary" variant="tertiary"
onClick={() => setPageNumber(pageNumber - 1)}
/> />
<p className="text-md text-gray-600"> <p className="text-md text-gray-600">
Page {pageNumber} of {numPages} Page {pageNumber} of {numPages}
</p> </p>
<Button <Button
disabled={pageNumber === numPages} disabled={pageNumber >= numPages}
icon={ArrowRightIcon} icon={ArrowRightIcon}
isLabelHidden={true} isLabelHidden={true}
label="Next" label="Next"
variant="tertiary" variant="tertiary"
onClick={() => setPageNumber(pageNumber + 1)}
/> />
</div> </div>
</div> </div>

@ -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;

@ -35,8 +35,11 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
</div> </div>
</div> </div>
<div className="col-span-3 self-center text-sm text-slate-500"> <div className="col-span-3 self-center text-sm text-slate-500">
Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '} <div>
{resumeInfo.user} Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '}
{resumeInfo.user}
</div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div> </div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center" /> <ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center" />
</div> </div>

@ -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<Resume>;
}>;
export default function ResumeListItems({ isLoading, resumes }: Props) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="col-span-10 pr-8">
<ul role="list">
{resumes.map((resumeObj: Resume) => (
<li key={resumeObj.id}>
<ResumseListItem
href={`resumes/${resumeObj.id}`}
resumeInfo={resumeObj}
/>
</li>
))}
</ul>
</div>
);
}

@ -53,9 +53,9 @@ export const EXPERIENCE = [
]; ];
export const LOCATION = [ export const LOCATION = [
{ checked: false, label: 'Singapore', value: 'singapore' }, { checked: false, label: 'Singapore', value: 'Singapore' },
{ checked: false, label: 'United States', value: 'usa' }, { checked: false, label: 'United States', value: 'United States' },
{ checked: false, label: 'India', value: 'india' }, { checked: false, label: 'India', value: 'India' },
]; ];
export const TEST_RESUMES = [ export const TEST_RESUMES = [

@ -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<ResumeComment>;
isLoading: boolean;
}>;
export default function CommentListItems({ comments, isLoading }: Props) {
const { data: session } = useSession();
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
{comments.map((comment) => (
<Comment
key={comment.id}
comment={comment}
userId={session?.user?.id}
/>
))}
</div>
);
}

@ -1,10 +1,9 @@
import { useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { Tabs } from '@tih/ui'; import { Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import Comment from './comment/Comment'; import CommentListItems from './CommentListItems';
import CommentsListButton from './CommentsListButton'; import CommentsListButton from './CommentsListButton';
import { COMMENTS_SECTIONS } from './constants'; import { COMMENTS_SECTIONS } from './constants';
@ -18,15 +17,8 @@ export default function CommentsList({
setShowCommentsForm, setShowCommentsForm,
}: CommentsListProps) { }: CommentsListProps) {
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value); 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 }]);
const commentsQuery = trpc.useQuery([
'resumes.reviews.list',
{ resumeId, section: tab },
]);
// TODO: Add loading prompt
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -37,18 +29,10 @@ export default function CommentsList({
value={tab} value={tab}
onChange={(value) => setTab(value)} onChange={(value) => setTab(value)}
/> />
<CommentListItems
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll"> comments={commentsQuery.data?.filter((c) => c.section === tab) ?? []}
{commentsQuery.data?.map((comment) => { isLoading={commentsQuery.isFetching}
return ( />
<Comment
key={comment.id}
comment={comment}
userId={session?.user?.id}
/>
);
})}
</div>
</div> </div>
); );
} }

@ -10,15 +10,29 @@ type ICommentsSectionProps = {
export default function CommentsSection({ resumeId }: ICommentsSectionProps) { export default function CommentsSection({ resumeId }: ICommentsSectionProps) {
const [showCommentsForm, setShowCommentsForm] = useState(false); const [showCommentsForm, setShowCommentsForm] = useState(false);
return showCommentsForm ? ( return (
<CommentsForm <>
resumeId={resumeId} <div className="relative p-2 lg:hidden">
setShowCommentsForm={setShowCommentsForm} <div aria-hidden="true" className="absolute inset-0 flex items-center">
/> <div className="w-full border-t border-gray-300" />
) : ( </div>
<CommentsList <div className="relative flex justify-center">
resumeId={resumeId} <span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
setShowCommentsForm={setShowCommentsForm} Comments
/> </span>
</div>
</div>
{showCommentsForm ? (
<CommentsForm
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
) : (
<CommentsList
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
)}
</>
); );
} }

@ -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 (
<Typeahead
disabled={disabled}
label="Company"
noResultsMessage="No companies found"
nullable={true}
options={
data?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
}
onQueryChange={setQuery}
onSelect={onSelect}
/>
);
}

@ -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 (
<div className="flex space-x-4">
<Select
label="Month"
options={MONTH_OPTIONS}
value={value.month}
onChange={(newMonth) =>
onChange({ month: Number(newMonth) as Month, year: value.year })
}
/>
<Select
label="Year"
options={YEAR_OPTIONS}
value={value.year}
onChange={(newYear) =>
onChange({ month: value.month, year: Number(newYear) })
}
/>
</div>
);
}

@ -0,0 +1 @@
export const RESUME_STORAGE_KEY = 'resumes';

@ -7,11 +7,13 @@ import { z } from 'zod';
*/ */
export const serverSchema = z.object({ export const serverSchema = z.object({
DATABASE_URL: z.string().url(), 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_ID: z.string(),
GITHUB_CLIENT_SECRET: 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(),
}); });
/** /**

@ -0,0 +1,65 @@
import formidable from 'formidable';
import * as fs from 'fs';
import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '~/utils/supabase';
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === 'POST') {
try {
const form = formidable({ keepExtensions: true });
form.parse(req, async (err, fields, files) => {
if (err) {
throw err;
}
const { key } = fields;
const { file } = files;
const parsedFile: formidable.File =
file instanceof Array ? file[0] : file;
const filePath = `${Date.now()}-${parsedFile.originalFilename}`;
const convertedFile = fs.readFileSync(parsedFile.filepath);
const { error } = await supabase.storage
.from(key as string)
.upload(filePath, convertedFile);
if (error) {
throw error;
}
return res.status(200).json({
url: filePath,
});
});
} catch (error: unknown) {
return Promise.reject(error);
}
}
if (req.method === 'GET') {
const { key, url } = req.query;
const { data, error } = await supabase.storage
.from(`public/${key as string}`)
.download(url as string);
if (error || data == null) {
throw error;
}
const arrayBuffer = await data.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
res.status(200).send(buffer);
}
}

@ -1,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() { export default function HomePage() {
const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null);
const [monthYear, setMonthYear] = useState<MonthYear>({
month: new Date().getMonth() + 1,
year: new Date().getFullYear(),
});
return ( return (
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div> <div className="space-y-4">
<h1 className="text-primary-600 text-center text-4xl font-bold"> <h1 className="text-primary-600 text-center text-4xl font-bold">
Homepage Homepage
</h1> </h1>
<Button label="Button text" size="md" variant="primary" /> <CompaniesTypeahead
<Spinner size="md" /> onSelect={(option) => setSelectedCompany(option)}
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
</div> </div>
</div> </div>
</main> </main>

@ -3,7 +3,6 @@ import { useMemo, useState } from 'react';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOptions } from '~/components/questions/filter/FilterSection'; import type { FilterOptions } from '~/components/questions/filter/FilterSection';
import FilterSection 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 QuestionOverviewCard from '~/components/questions/QuestionOverviewCard';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
@ -98,10 +97,7 @@ export default function QuestionsHomePage() {
return ( return (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto"> <main className="flex flex-1 flex-col items-stretch overflow-y-auto">
<div className="pb-4"> <div className="flex pt-4">
<NavBar></NavBar>
</div>
<div className="flex">
<section className="w-[300px] border-r px-4"> <section className="w-[300px] border-r px-4">
<h2 className="text-xl font-semibold">Filter by</h2> <h2 className="text-xl font-semibold">Filter by</h2>
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">

@ -1,6 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import formatDistanceToNow from 'date-fns/formatDistanceToNow'; import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Error from 'next/error'; import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useEffect } from 'react'; import { useEffect } from 'react';
@ -26,17 +27,17 @@ export default function ResumeReviewPage() {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const { resumeId } = router.query; 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 // Safe to assert resumeId type as string because query is only sent if so
const detailsQuery = trpc.useQuery( 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() { onSuccess() {
utils.invalidateQueries(); trpcContext.invalidateQueries(['resumes.resume.findOne']);
}, },
}); });
@ -59,88 +60,98 @@ export default function ResumeReviewPage() {
return ( return (
<> <>
{detailsQuery.isError && ErrorPage} {detailsQuery.isError && ErrorPage}
{detailsQuery.isLoading && <Spinner display="block" label="" size="lg" />} {detailsQuery.isLoading && (
<div className="w-full pt-4">
{' '}
<Spinner display="block" size="lg" />{' '}
</div>
)}
{detailsQuery.isFetched && detailsQuery.data && ( {detailsQuery.isFetched && detailsQuery.data && (
<main className="flex-1 p-4"> <>
<div className="flex flex-row md:space-x-8"> <Head>
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight"> <title>{detailsQuery.data.title}</title>
{detailsQuery.data.title} </Head>
</h1> <main className="h-[calc(100vh-2rem)] flex-1 overflow-y-scroll p-4">
<button <div className="flex flex-row space-x-8">
className="isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:z-10 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" <h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
disabled={session?.user === null} {detailsQuery.data.title}
id="star-button" </h1>
type="button" <button
onClick={onStarButtonClick}> className="isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:z-10 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
<span className="relative inline-flex"> disabled={session?.user === null}
<StarIcon id="star-button"
type="button"
onClick={onStarButtonClick}>
<span className="relative inline-flex">
<StarIcon
aria-hidden="true"
className={clsx(
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-gray-400',
'-ml-1 mr-2 h-5 w-5',
)}
id="star-icon"
/>
Star
</span>
<span className="relative -ml-px inline-flex">
{detailsQuery.data._count.stars}
</span>
</button>
</div>
<div className="flex flex-col pt-1 lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8">
<div className="mt-2 flex items-center text-sm text-gray-500">
<BriefcaseIcon
aria-hidden="true" aria-hidden="true"
className={clsx( className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-gray-400',
'-ml-1 mr-2 h-5 w-5',
)}
id="star-icon"
/> />
Star {detailsQuery.data.role}
</span> </div>
<span className="relative -ml-px inline-flex"> <div className="flex items-center pt-2 text-sm text-gray-500">
{detailsQuery.data._count.stars} <MapPinIcon
</span> aria-hidden="true"
</button> className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
</div> />
<div className="flex flex-col pt-1 sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-8"> {detailsQuery.data.location}
<div className="mt-2 flex items-center text-sm text-gray-500"> </div>
<BriefcaseIcon <div className="flex items-center pt-2 text-sm text-gray-500">
aria-hidden="true" <AcademicCapIcon
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true"
/> className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
{detailsQuery.data.role} />
</div> {detailsQuery.data.experience}
<div className="flex items-center pt-2 text-sm text-gray-500"> </div>
<MapPinIcon <div className="flex items-center pt-2 text-sm text-gray-500">
aria-hidden="true" <CalendarIcon
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true"
/> className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
{detailsQuery.data.location} />
</div> {`Uploaded ${formatDistanceToNow(
<div className="flex items-center pt-2 text-sm text-gray-500"> new Date(detailsQuery.data.createdAt),
<AcademicCapIcon { addSuffix: true },
aria-hidden="true" )} by ${detailsQuery.data.user.name}`}
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" </div>
/>
{detailsQuery.data.experience}
</div>
<div className="flex items-center pt-2 text-sm text-gray-500">
<CalendarIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{`Uploaded ${formatDistanceToNow(
new Date(detailsQuery.data.createdAt),
{ addSuffix: true },
)} by ${detailsQuery.data.user.name}`}
</div>
</div>
{detailsQuery.data.additionalInfo && (
<div className="flex items-center pt-2 text-sm text-gray-500">
<InformationCircleIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{detailsQuery.data.additionalInfo}
</div>
)}
<div className="flex h-full w-full flex-row py-4">
<div className="w-1/2">
<ResumePdf url={detailsQuery.data.url} />
</div> </div>
<div className="mx-8 w-1/2"> {detailsQuery.data.additionalInfo && (
<CommentsSection resumeId={resumeId as string} /> <div className="flex items-center pt-2 text-sm text-gray-500">
<InformationCircleIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{detailsQuery.data.additionalInfo}
</div>
)}
<div className="flex w-full flex-col py-4 lg:flex-row">
<div className="w-full lg:w-[800px]">
<ResumePdf url={detailsQuery.data.url} />
</div>
<div className="mx-8 grow">
<CommentsSection resumeId={resumeId as string} />
</div>
</div> </div>
</div> </main>
</main> </>
)} )}
</> </>
); );

@ -1,7 +1,8 @@
import clsx from 'clsx'; import clsx from 'clsx';
import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; 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 { Disclosure, Menu, Transition } from '@headlessui/react';
import { import {
ChevronDownIcon, ChevronDownIcon,
@ -11,7 +12,6 @@ import {
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Tabs, TextInput } from '@tih/ui'; import { Tabs, TextInput } from '@tih/ui';
import BrowseListItem from '~/components/resumes/browse/BrowseListItem';
import { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCE, EXPERIENCE,
@ -21,6 +21,10 @@ import {
TOP_HITS, TOP_HITS,
} from '~/components/resumes/browse/constants'; } from '~/components/resumes/browse/constants';
import FilterPill from '~/components/resumes/browse/FilterPill'; 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'; import type { Resume } from '~/types/resume';
@ -41,54 +45,41 @@ const filters = [
options: LOCATION, options: LOCATION,
}, },
]; ];
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import { trpc } from '~/utils/trpc';
export default function ResumeHomePage() { export default function ResumeHomePage() {
const { data } = useSession(); const { data: sessionData } = useSession();
const router = useRouter(); const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL); const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [resumes, setResumes] = useState(Array<Resume>()); const [resumes, setResumes] = useState<Array<Resume>>([]);
const allResumesQuery = trpc.useQuery(['resumes.resume.all'], { const allResumesQuery = trpc.useQuery(['resumes.resume.findAll'], {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL, enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
onSuccess: (data) => {
setResumes(data);
},
}); });
const starredResumesQuery = trpc.useQuery(['resumes.resume.browse.stars'], { const starredResumesQuery = trpc.useQuery(
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, ['resumes.resume.user.findUserStarred'],
}); {
const myResumesQuery = trpc.useQuery(['resumes.resume.browse.my'], { enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
enabled: tabsValue === BROWSE_TABS_VALUES.MY, onSuccess: (data) => {
}); setResumes(data);
},
useEffect(() => { },
switch (tabsValue) { );
case BROWSE_TABS_VALUES.ALL: { const myResumesQuery = trpc.useQuery(
setResumes(allResumesQuery.data ?? Array<Resume>()); ['resumes.resume.user.findUserCreated'],
break; {
} enabled: tabsValue === BROWSE_TABS_VALUES.MY,
case BROWSE_TABS_VALUES.STARRED: { onSuccess: (data) => {
setResumes(starredResumesQuery.data ?? Array<Resume>()); setResumes(data);
break; },
} },
case BROWSE_TABS_VALUES.MY: { );
setResumes(myResumesQuery.data ?? Array<Resume>());
break;
}
default: {
setResumes(Array<Resume>());
}
}
}, [
allResumesQuery.data,
starredResumesQuery.data,
myResumesQuery.data,
tabsValue,
]);
const onClickNew = () => { const onClickNew = () => {
if (data?.user?.id) { if (sessionData?.user?.id) {
router.push('/resumes/submit'); router.push('/resumes/submit');
} else { } else {
// TODO: Handle non-logged in user behaviour // TODO: Handle non-logged in user behaviour
@ -96,205 +87,201 @@ export default function ResumeHomePage() {
}; };
return ( return (
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll"> <>
<div className="ml-4 py-4"> <Head>
<ResumeReviewsTitle /> <title>Resume Review Portal</title>
</div> </Head>
<div className="mt-4 flex items-start"> <main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll">
<div className="w-screen sm:px-4 md:px-8"> <div className="ml-4 py-4">
<div className="grid grid-cols-12"> <ResumeReviewsTitle />
<div className="col-span-2 self-end"> </div>
<h1 className="mb-4 tracking-tight text-gray-900">Filters</h1> <div className="mt-4 flex items-start">
</div> <div className="w-screen sm:px-4 md:px-8">
<div className="col-span-10"> <div className="grid grid-cols-12">
<div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2"> <div className="col-span-2 self-end">
<div className="col-span-7"> <h1 className="mb-4 tracking-tight text-gray-900">Filters</h1>
<Tabs </div>
label="Resume Browse Tabs" <div className="col-span-10">
tabs={[ <div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2">
{ <div className="col-span-7">
label: 'All Resumes', <Tabs
value: BROWSE_TABS_VALUES.ALL, label="Resume Browse Tabs"
}, tabs={[
{ {
label: 'Starred Resumes', label: 'All Resumes',
value: BROWSE_TABS_VALUES.STARRED, value: BROWSE_TABS_VALUES.ALL,
}, },
{ {
label: 'My Resumes', label: 'Starred Resumes',
value: BROWSE_TABS_VALUES.MY, value: BROWSE_TABS_VALUES.STARRED,
}, },
]} {
value={tabsValue} label: 'My Resumes',
onChange={setTabsValue} value: BROWSE_TABS_VALUES.MY,
/> },
</div> ]}
<div className="col-span-3 self-end"> value={tabsValue}
<form> onChange={setTabsValue}
<TextInput
label=""
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/> />
</form> </div>
</div> <div className="col-span-3 self-end">
<div className="col-span-1 justify-self-center"> <form>
<Menu as="div" className="relative inline-block text-left"> <TextInput
<div> label=""
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-gray-700 hover:text-gray-900"> placeholder="Search Resumes"
Sort startAddOn={MagnifyingGlassIcon}
<ChevronDownIcon startAddOnType="icon"
aria-hidden="true" type="text"
className="-mr-1 ml-1 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-gray-500" value={searchValue}
/> onChange={setSearchValue}
</Menu.Button> />
</div> </form>
</div>
<div className="col-span-1 justify-self-center">
<Menu as="div" className="relative inline-block text-left">
<div>
{/* TODO: Sort logic */}
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-gray-700 hover:text-gray-900">
Sort
<ChevronDownIcon
aria-hidden="true"
className="-mr-1 ml-1 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
/>
</Menu.Button>
</div>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"> leaveTo="transform opacity-0 scale-95">
<Menu.Items className="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-white shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"> <Menu.Items className="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-white shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1"> <div className="py-1">
{SORT_OPTIONS.map((option) => ( {SORT_OPTIONS.map((option) => (
<Menu.Item key={option.name}> <Menu.Item key={option.name}>
{({ active }) => ( {({ active }) => (
<a <a
className={clsx( className={clsx(
option.current option.current
? 'font-medium text-gray-900' ? 'font-medium text-gray-900'
: 'text-gray-500', : 'text-gray-500',
active ? 'bg-gray-100' : '', active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm', 'block px-4 py-2 text-sm',
)} )}
href={option.href}> href={option.href}>
{option.name} {option.name}
</a> </a>
)} )}
</Menu.Item> </Menu.Item>
))} ))}
</div> </div>
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<button <button
className="rounded-md bg-indigo-500 py-1 px-3 text-sm text-white" className="rounded-md bg-indigo-500 py-1 px-3 text-sm text-white"
type="button" type="button"
onClick={onClickNew}> onClick={onClickNew}>
New New
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="grid grid-cols-12"> <div className="grid grid-cols-12">
<div className="col-span-2"> <div className="col-span-2">
<div className="w-100 pt-4 sm:pr-0 md:pr-4"> <div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form> <form>
<h3 className="sr-only">Categories</h3> <h3 className="sr-only">Categories</h3>
<ul <ul
className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900" className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900"
role="list"> role="list">
{TOP_HITS.map((category) => ( {TOP_HITS.map((category) => (
<li key={category.name}> <li key={category.name}>
{/* TODO: Replace onClick with filtering function */} {/* TODO: Replace onClick with filtering function */}
<FilterPill <FilterPill
title={category.name} title={category.name}
onClick={() => true} onClick={() => true}
/> />
</li> </li>
))} ))}
</ul> </ul>
{filters.map((section) => ( {filters.map((section) => (
<Disclosure <Disclosure
key={section.id} key={section.id}
as="div" as="div"
className="border-b border-gray-200 py-6"> className="border-b border-gray-200 py-6">
{({ open }) => ( {({ open }) => (
<> <>
<h3 className="-my-3 flow-root"> <h3 className="-my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-gray-400 hover:text-gray-500"> <Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-gray-400 hover:text-gray-500">
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
{section.name} {section.name}
</span> </span>
<span className="ml-6 flex items-center"> <span className="ml-6 flex items-center">
{open ? ( {open ? (
<MinusIcon <MinusIcon
aria-hidden="true" aria-hidden="true"
className="h-5 w-5" className="h-5 w-5"
/> />
) : ( ) : (
<PlusIcon <PlusIcon
aria-hidden="true" aria-hidden="true"
className="h-5 w-5" className="h-5 w-5"
/> />
)} )}
</span> </span>
</Disclosure.Button> </Disclosure.Button>
</h3> </h3>
<Disclosure.Panel className="pt-6"> <Disclosure.Panel className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
{section.options.map((option, optionIdx) => ( {section.options.map((option, optionIdx) => (
<div <div
key={option.value} key={option.value}
className="flex items-center"> className="flex items-center">
<input <input
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
defaultChecked={option.checked} defaultChecked={option.checked}
defaultValue={option.value} defaultValue={option.value}
id={`filter-${section.id}-${optionIdx}`} id={`filter-${section.id}-${optionIdx}`}
name={`${section.id}[]`} name={`${section.id}[]`}
type="checkbox" type="checkbox"
/> />
<label <label
className="ml-3 text-sm text-gray-600" className="ml-3 text-sm text-gray-600"
htmlFor={`filter-${section.id}-${optionIdx}`}> htmlFor={`filter-${section.id}-${optionIdx}`}>
{option.label} {option.label}
</label> </label>
</div> </div>
))} ))}
</div> </div>
</Disclosure.Panel> </Disclosure.Panel>
</> </>
)} )}
</Disclosure> </Disclosure>
))} ))}
</form> </form>
</div>
</div> </div>
<ResumeListItems
isLoading={
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={resumes}
/>
</div> </div>
{allResumesQuery.isLoading ||
starredResumesQuery.isLoading ||
myResumesQuery.isLoading ? (
<div>Loading...</div>
) : (
<div className="col-span-10 pr-8">
<ul role="list">
{resumes.map((resumeObj) => (
<li key={resumeObj.id}>
<BrowseListItem
href={`resumes/${resumeObj.id}`}
resumeInfo={resumeObj}
/>
</li>
))}
</ul>
</div>
)}
</div> </div>
</div> </div>
</div> </main>
</main> </>
); );
} }

@ -1,3 +1,4 @@
import axios from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
@ -5,7 +6,7 @@ import { useMemo, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { PaperClipIcon } from '@heroicons/react/24/outline'; 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 { import {
EXPERIENCE, EXPERIENCE,
@ -13,19 +14,22 @@ import {
ROLES, ROLES,
} from '~/components/resumes/browse/constants'; } from '~/components/resumes/browse/constants';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
const FILE_SIZE_LIMIT_MB = 3;
const FILE_SIZE_LIMIT_BYTES = FILE_SIZE_LIMIT_MB * 1000000;
const TITLE_PLACEHOLDER = const TITLE_PLACEHOLDER =
'e.g. Applying for Company XYZ, please help me to review!'; 'e.g. Applying for Company XYZ, please help me to review!';
const ADDITIONAL_INFO_PLACEHOLDER = `e.g. Im 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 ADDITIONAL_INFO_PLACEHOLDER = `e.g. Im 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 FILE_UPLOAD_ERROR = `Please upload a PDF file that is less than ${FILE_SIZE_LIMIT_MB}MB.`;
const MAX_FILE_SIZE_LIMIT = 10000000;
type IFormInput = { type IFormInput = {
additionalInfo?: string; additionalInfo?: string;
experience: string; experience: string;
file: File; file: File;
isChecked: boolean;
location: string; location: string;
role: string; role: string;
title: string; title: string;
@ -36,6 +40,7 @@ export default function SubmitResumeForm() {
const router = useRouter(); const router = useRouter();
const [resumeFile, setResumeFile] = useState<File | null>(); const [resumeFile, setResumeFile] = useState<File | null>();
const [isLoading, setIsLoading] = useState(false);
const [invalidFileUploadError, setInvalidFileUploadError] = useState< const [invalidFileUploadError, setInvalidFileUploadError] = useState<
string | null string | null
>(null); >(null);
@ -46,13 +51,51 @@ export default function SubmitResumeForm() {
setValue, setValue,
reset, reset,
formState: { errors }, formState: { errors },
} = useForm<IFormInput>(); } = useForm<IFormInput>({
defaultValues: {
isChecked: false,
},
});
const onSubmit: SubmitHandler<IFormInput> = async (data) => { const onSubmit: SubmitHandler<IFormInput> = async (data) => {
await resumeCreateMutation.mutate({ if (resumeFile == null) {
...data, 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<HTMLInputElement>) => { const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -60,7 +103,7 @@ export default function SubmitResumeForm() {
if (file == null) { if (file == null) {
return; 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); setInvalidFileUploadError(FILE_UPLOAD_ERROR);
return; return;
} }
@ -85,7 +128,7 @@ export default function SubmitResumeForm() {
return ( return (
<> <>
<Head> <Head>
<title>Upload a resume</title> <title>Upload a Resume</title>
</Head> </Head>
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll"> <main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll">
<section <section
@ -168,14 +211,16 @@ export default function SubmitResumeForm() {
/> />
</label> </label>
</div> </div>
<p className="text-xs text-gray-500">PDF up to 10MB</p> <p className="text-xs text-gray-500">
PDF up to {FILE_SIZE_LIMIT_MB}MB
</p>
</div> </div>
</div> </div>
{fileUploadError && ( {fileUploadError && (
<p className="text-danger-600 text-sm">{fileUploadError}</p> <p className="text-danger-600 text-sm">{fileUploadError}</p>
)} )}
</div> </div>
<div className="mb-4"> <div className="mb-8">
<TextArea <TextArea
{...register('additionalInfo')} {...register('additionalInfo')}
label="Additional Information" label="Additional Information"
@ -183,6 +228,40 @@ export default function SubmitResumeForm() {
onChange={(val) => setValue('additionalInfo', val)} onChange={(val) => setValue('additionalInfo', val)}
/> />
</div> </div>
<div className="mb-4 text-left text-sm text-slate-700">
<h2 className="mb-2 text-xl font-medium">
Submission Guidelines
</h2>
<p>
Before you submit, please review and acknolwedge our
<span className="font-bold"> submission guidelines </span>
stated below.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any of your
<span className="font-bold"> personal particulars</span>.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any
<span className="font-bold">
{' '}
company's proprietary and confidential information
</span>
.
</p>
<p>
<span className="text-lg font-bold"> </span>
Proof-read your resumes to look for grammatical/spelling
errors.
</p>
</div>
<CheckboxInput
{...register('isChecked', { required: true })}
label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)}
/>
<div className="mt-4 flex justify-end gap-4"> <div className="mt-4 flex justify-end gap-4">
<Button <Button
addonPosition="start" addonPosition="start"
@ -195,6 +274,7 @@ export default function SubmitResumeForm() {
<Button <Button
addonPosition="start" addonPosition="start"
display="inline" display="inline"
isLoading={isLoading}
label="Submit" label="Submit"
size="md" size="md"
type="submit" type="submit"

@ -0,0 +1,23 @@
import { z } from 'zod';
import { createRouter } from './context';
export const companiesRouter = createRouter().query('list', {
input: z.object({
name: z.string(),
}),
async resolve({ ctx, input }) {
return await ctx.prisma.company.findMany({
orderBy: {
name: 'desc',
},
take: 10,
where: {
name: {
contains: input.name,
mode: 'insensitive',
},
},
});
},
});

@ -1,7 +1,9 @@
import superjson from 'superjson'; import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context'; import { createRouter } from './context';
import { protectedExampleRouter } from './protected-example-router'; import { protectedExampleRouter } from './protected-example-router';
<<<<<<< HEAD
import {questionsQuestionCommentRouter} from './questions-question-comment-router'; import {questionsQuestionCommentRouter} from './questions-question-comment-router';
import { resumesRouter } from './resumes'; import { resumesRouter } from './resumes';
import { resumesDetailsRouter } from './resumes-details-router'; import { resumesDetailsRouter } from './resumes-details-router';
@ -9,6 +11,14 @@ import { resumesResumeProtectedTabsRouter } from './resumes-resume-protected-tab
import { resumesResumeUserRouter } from './resumes-resume-user-router'; import { resumesResumeUserRouter } from './resumes-resume-user-router';
import { resumeReviewsRouter } from './resumes-reviews-router'; import { resumeReviewsRouter } from './resumes-reviews-router';
import { resumesReviewsUserRouter } from './resumes-reviews-user-router'; import { resumesReviewsUserRouter } from './resumes-reviews-user-router';
=======
import { questionsQuestionRouter } from './questions-question-router';
import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
import { resumeReviewsRouter } from './resumes/resumes-reviews-router';
import { resumesReviewsUserRouter } from './resumes/resumes-reviews-user-router';
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
>>>>>>> main
import { todosRouter } from './todos'; import { todosRouter } from './todos';
import { todosUserRouter } from './todos-user-router'; import { todosUserRouter } from './todos-user-router';
@ -20,13 +30,17 @@ export const appRouter = createRouter()
.merge('auth.', protectedExampleRouter) .merge('auth.', protectedExampleRouter)
.merge('todos.', todosRouter) .merge('todos.', todosRouter)
.merge('todos.user.', todosUserRouter) .merge('todos.user.', todosUserRouter)
.merge('companies.', companiesRouter)
.merge('resumes.resume.', resumesRouter) .merge('resumes.resume.', resumesRouter)
.merge('resumes.details.', resumesDetailsRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter) .merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.resume.browse.', resumesResumeProtectedTabsRouter) .merge('resumes.star.user.', resumesStarUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter) .merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter) .merge('resumes.reviews.user.', resumesReviewsUserRouter)
<<<<<<< HEAD
.merge('questions.questions.comments.', questionsQuestionCommentRouter); .merge('questions.questions.comments.', questionsQuestionCommentRouter);
=======
.merge('questions.questions.', questionsQuestionRouter);
>>>>>>> main
// Export type definition of API // Export type definition of API
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

@ -0,0 +1,264 @@
import { z } from 'zod';
import {QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
company: z.string().optional(),
location: z.string().optional(),
questionType: z.nativeEnum(QuestionsQuestionType),
role: z.string().optional(),
}),
async resolve({ ctx, input }) {
const questionsData = await ctx.prisma.questionsQuestion.findMany({
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
questionType: input.questionType,
},
});
return questionsData
.filter((data) => {
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i]
const matchCompany = (!input.company || (encounter.company === input.company));
const matchLocation = (!input.location || (encounter.location === input.location));
const matchRole = (!input.company || (encounter.role === input.role));
if (matchCompany && matchLocation && matchRole) {return true};
}
return false;
})
.map((data) => {
const votes:number = data.votes.reduce(
(previousValue:number, currentValue) => {
let result:number = previousValue;
switch(currentValue.vote) {
case Vote.UPVOTE:
result += 1
break;
case Vote.DOWNVOTE:
result -= 1
break;
}
return result;
},
0
);
let userName = "";
if (data.user) {
userName = data.user.name!;
}
const question: Question = {
company: "",
content: data.content,
id: data.id,
location: "",
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
role: "",
updatedAt: data.updatedAt,
user: userName,
};
return question;
});
}
})
.mutation('create', {
input: z.object({
content: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestion.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType).optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionToUpdate = await ctx.prisma.questionsQuestion.findUnique({
where: {
id: input.id,
},
});
if (questionToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
// Optional: pass the original error to retain stack trace
});
}
return await ctx.prisma.questionsQuestion.update({
data: {
...input,
},
where: {
id: input.id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionToDelete = await ctx.prisma.questionsQuestion.findUnique({
where: {
id: input.id,
},
});
if (questionToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
// Optional: pass the original error to retain stack trace
});
}
return await ctx.prisma.questionsQuestion.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const {questionId} = input
return await ctx.prisma.questionsQuestionVote.findUnique({
where: {
questionId_userId : {questionId,userId }
},
});
},
})
.mutation('createVote', {
input: z.object({
questionId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionVote.create({
data: {
...input,
userId,
},
});
},
})
.mutation('updateVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const {id, vote} = input
const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionVote.update({
data: {
vote,
},
where: {
id,
},
});
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionVote.delete({
where: {
id: input.id,
},
});
},
});

@ -1,28 +0,0 @@
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,
},
});
},
},
);

@ -1,42 +0,0 @@
import { createRouter } from './context';
import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter().query('all', {
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;
});
},
});

@ -0,0 +1,79 @@
import { z } from 'zod';
import { createRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter()
.query('findAll', {
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;
});
},
})
.query('findOne', {
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,
},
});
},
});

@ -1,9 +1,31 @@
import { createProtectedRouter } from './context'; import { z } from 'zod';
import { createProtectedRouter } from '../context';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
export const resumesResumeProtectedTabsRouter = createProtectedRouter() export const resumesResumeUserRouter = createProtectedRouter()
.query('stars', { .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(),
url: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user.id;
return await ctx.prisma.resumesResume.create({
data: {
...input,
userId,
},
});
},
})
.query('findUserStarred', {
async resolve({ ctx }) { async resolve({ ctx }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const resumeStarsData = await ctx.prisma.resumesStar.findMany({ const resumeStarsData = await ctx.prisma.resumesStar.findMany({
@ -16,11 +38,11 @@ export const resumesResumeProtectedTabsRouter = createProtectedRouter()
stars: true, stars: true,
}, },
}, },
}, user: {
}, select: {
user: { name: true,
select: { },
name: true, },
}, },
}, },
}, },
@ -36,20 +58,20 @@ export const resumesResumeProtectedTabsRouter = createProtectedRouter()
additionalInfo: rs.resume.additionalInfo, additionalInfo: rs.resume.additionalInfo,
createdAt: rs.resume.createdAt, createdAt: rs.resume.createdAt,
experience: rs.resume.experience, experience: rs.resume.experience,
id: rs.id, id: rs.resume.id,
location: rs.resume.location, location: rs.resume.location,
numComments: rs.resume._count.comments, numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars, numStars: rs.resume._count.stars,
role: rs.resume.role, role: rs.resume.role,
title: rs.resume.title, title: rs.resume.title,
url: rs.resume.url, url: rs.resume.url,
user: rs.user.name!, user: rs.resume.user.name!,
}; };
return resume; return resume;
}); });
}, },
}) })
.query('my', { .query('findUserCreated', {
async resolve({ ctx }) { async resolve({ ctx }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const resumesData = await ctx.prisma.resumesResume.findMany({ const resumesData = await ctx.prisma.resumesResume.findMany({

@ -1,18 +1,16 @@
import { z } from 'zod'; import { z } from 'zod';
import { ResumesSection } from '@prisma/client';
import { createRouter } from './context'; import { createRouter } from '../context';
import type { ResumeComment } from '~/types/resume-comments'; import type { ResumeComment } from '~/types/resume-comments';
export const resumeReviewsRouter = createRouter().query('list', { export const resumeReviewsRouter = createRouter().query('list', {
input: z.object({ input: z.object({
resumeId: z.string(), resumeId: z.string(),
section: z.nativeEnum(ResumesSection),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { resumeId, section } = input; const { resumeId } = input;
// For this resume, we retrieve every comment's information, along with: // For this resume, we retrieve every comment's information, along with:
// The user's name and image to render // The user's name and image to render
@ -42,7 +40,6 @@ export const resumeReviewsRouter = createRouter().query('list', {
}, },
where: { where: {
resumeId, resumeId,
section,
}, },
}); });

@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { ResumesSection } from '@prisma/client'; import { ResumesSection } from '@prisma/client';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from '../context';
type IResumeCommentInput = Readonly<{ type IResumeCommentInput = Readonly<{
description: string; description: string;

@ -1,43 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { createRouter } from './context'; import { createProtectedRouter } from '../context';
export const resumesDetailsRouter = createRouter() export const resumesStarUserRouter = createProtectedRouter().mutation(
.query('find', { 'create_or_delete',
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({ input: z.object({
resumeId: z.string(), resumeId: z.string(),
}), }),
@ -76,4 +43,5 @@ export const resumesDetailsRouter = createRouter()
}, },
}); });
}, },
}); },
);

@ -1,7 +1,21 @@
<<<<<<< HEAD
export type QuestionComment = { export type QuestionComment = {
content: string; content: string;
createdAt: Date; createdAt: Date;
id: string; id: string;
numVotes: number; numVotes: number;
=======
export type Question = {
// TODO: company, location, role maps
company: string;
content: string;
id: string;
location: string;
numAnswers: number;
numComments: number;
numVotes: number;
role: string;
updatedAt: Date;
>>>>>>> main
user: string; user: string;
}; };

@ -0,0 +1,8 @@
import { createClient } from '@supabase/supabase-js';
import { env } from '~/env/server.mjs';
const { SUPABASE_URL, SUPABASE_ANON_KEY } = env;
// Create a single supabase client for interacting with the file storage
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

@ -0,0 +1,99 @@
import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react';
import { CheckboxInput } from '@tih/ui';
export default {
argTypes: {
defaultValue: {
control: 'boolean',
},
description: {
control: 'text',
},
disabled: {
control: 'boolean',
},
label: {
control: 'text',
},
value: {
control: 'boolean',
},
},
component: CheckboxInput,
title: 'CheckboxInput',
} as ComponentMeta<typeof CheckboxInput>;
export function Basic({
defaultValue,
description,
disabled,
label,
}: Pick<
React.ComponentProps<typeof CheckboxInput>,
'defaultValue' | 'description' | 'disabled' | 'label'
>) {
return (
<CheckboxInput
defaultValue={defaultValue}
description={description}
disabled={disabled}
label={label}
/>
);
}
Basic.args = {
description: 'I will be responsible for any mistakes',
disabled: false,
label: 'I have read the terms and conditions',
};
export function Controlled() {
const [value, setValue] = useState(true);
return (
<CheckboxInput
label="I have read the terms and conditions"
value={value}
onChange={(newValue: boolean) => {
setValue(newValue);
}}
/>
);
}
export function Disabled() {
return (
<div className="space-y-4">
<CheckboxInput
defaultValue={true}
label="I have read the terms and conditions"
/>
<CheckboxInput
defaultValue={false}
label="I have read the terms and conditions"
/>
<CheckboxInput
defaultValue={true}
disabled={true}
label="I have read the terms and conditions"
/>
<CheckboxInput
defaultValue={false}
disabled={true}
label="I have read the terms and conditions"
/>
</div>
);
}
export function ItemDescriptions() {
return (
<CheckboxInput
defaultValue={false}
description="I will be responsible for any mistakes"
label="I have read the terms and conditions"
/>
);
}

@ -0,0 +1,259 @@
import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react';
import type { CheckboxListOrientation } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import { CheckboxInput, CheckboxList } from '@tih/ui';
const CheckboxListOrientations: ReadonlyArray<CheckboxListOrientation> = [
'horizontal',
'vertical',
];
export default {
argTypes: {
description: {
control: 'text',
},
label: {
control: 'text',
},
orientation: {
control: { type: 'select' },
options: CheckboxListOrientations,
},
},
component: CheckboxList,
title: 'CheckboxList',
} as ComponentMeta<typeof CheckboxList>;
export function Basic({
description,
label,
orientation,
}: Pick<
React.ComponentProps<typeof CheckboxList>,
'description' | 'label' | 'orientation'
>) {
const items = [
{
label: 'Apple',
name: 'apple',
value: true,
},
{
label: 'Banana',
name: 'banana',
value: true,
},
{
label: 'Orange',
name: 'orange',
value: false,
},
];
return (
<CheckboxList
description={description}
label={label}
orientation={orientation}>
{items.map(({ label: itemLabel, name, value: itemValue }) => (
<CheckboxInput
key={itemLabel}
defaultValue={itemValue}
label={itemLabel}
name={name}
/>
))}
</CheckboxList>
);
}
Basic.args = {
description: 'Selected fruits will be served after dinner',
label: 'Select your favorite fruits',
orientation: 'vertical',
};
export function Controlled() {
const items = [
{
label: 'Apple',
value: 'apple',
},
{
label: 'Banana',
value: 'banana',
},
{
label: 'Orange',
value: 'orange',
},
];
const [values, setValues] = useState(new Set(['apple']));
return (
<CheckboxList
description="You will be served it during dinner"
label="Choose a fruit">
{items.map(({ label: itemLabel, value: itemValue }) => (
<CheckboxInput
key={itemLabel}
label={itemLabel}
value={values.has(itemValue)}
onChange={(newValue: boolean) => {
if (newValue) {
setValues(new Set([...Array.from(values), itemValue]));
} else {
setValues(
new Set(Array.from(values).filter((v) => v !== itemValue)),
);
}
}}
/>
))}
</CheckboxList>
);
}
export function Disabled() {
const items = [
{
description: 'A red fruit',
disabled: false,
label: 'Apple',
value: 'apple',
},
{
description: 'A yellow fruit',
disabled: true,
label: 'Banana',
value: 'banana',
},
{
description: 'An orange fruit',
disabled: false,
label: 'Orange',
value: 'orange',
},
];
const [values, setValues] = useState(new Set(['apple', 'banana']));
return (
<div className="space-y-4">
<CheckboxList label="Choose a fruit (some fruits disabled)">
{items.map(({ disabled, label: itemLabel, value: itemValue }) => (
<CheckboxInput
key={itemLabel}
disabled={disabled}
label={itemLabel}
value={values.has(itemValue)}
onChange={(newValue: boolean) => {
if (newValue) {
setValues(new Set([...Array.from(values), itemValue]));
} else {
setValues(
new Set(Array.from(values).filter((v) => v !== itemValue)),
);
}
}}
/>
))}
</CheckboxList>
</div>
);
}
export function ItemDescriptions() {
const items = [
{
description: 'A red fruit',
label: 'Apple',
value: 'apple',
},
{
description: 'A yellow fruit',
label: 'Banana',
value: 'banana',
},
{
description: 'An orange fruit',
label: 'Orange',
value: 'orange',
},
];
const [values, setValues] = useState(new Set(['apple', 'banana']));
return (
<div className="space-y-4">
<CheckboxList label="Choose a fruit (some fruits disabled)">
{items.map(({ description, label: itemLabel, value: itemValue }) => (
<CheckboxInput
key={itemLabel}
description={description}
label={itemLabel}
value={values.has(itemValue)}
onChange={(newValue: boolean) => {
if (newValue) {
setValues(new Set([...Array.from(values), itemValue]));
} else {
setValues(
new Set(Array.from(values).filter((v) => v !== itemValue)),
);
}
}}
/>
))}
</CheckboxList>
</div>
);
}
export function Orientation() {
const items = [
{
label: 'Apple',
name: 'apple',
value: true,
},
{
label: 'Banana',
name: 'banana',
value: false,
},
{
label: 'Orange',
name: 'orange',
value: true,
},
];
return (
<div className="space-y-4">
<CheckboxList label="Choose a fruit" orientation="vertical">
{items.map(({ label: itemLabel, name, value: itemValue }) => (
<CheckboxInput
key={itemLabel}
defaultValue={itemValue}
label={itemLabel}
name={name}
/>
))}
</CheckboxList>
<HorizontalDivider />
<CheckboxList label="Choose a fruit" orientation="horizontal">
{items.map(({ label: itemLabel, name, value: itemValue }) => (
<CheckboxInput
key={itemLabel}
defaultValue={itemValue}
label={itemLabel}
name={name}
/>
))}
</CheckboxList>
</div>
);
}

@ -29,7 +29,11 @@ export default {
export function Basic({ export function Basic({
description, description,
label, label,
}: Pick<React.ComponentProps<typeof RadioList>, 'description' | 'label'>) { orientation,
}: Pick<
React.ComponentProps<typeof RadioList>,
'description' | 'label' | 'orientation'
>) {
const items = [ const items = [
{ {
label: 'Apple', label: 'Apple',
@ -50,7 +54,8 @@ export function Basic({
defaultValue="apple" defaultValue="apple"
description={description} description={description}
label={label} label={label}
name="fruit"> name="fruit"
orientation={orientation}>
{items.map(({ label: itemLabel, value }) => ( {items.map(({ label: itemLabel, value }) => (
<RadioList.Item key={itemLabel} label={itemLabel} value={value} /> <RadioList.Item key={itemLabel} label={itemLabel} value={value} />
))} ))}
@ -61,6 +66,7 @@ export function Basic({
Basic.args = { Basic.args = {
description: 'Your favorite fruit', description: 'Your favorite fruit',
label: 'Choose a fruit', label: 'Choose a fruit',
orientation: 'vertical',
}; };
export function Controlled() { export function Controlled() {
@ -148,22 +154,10 @@ export function Disabled() {
}, },
]; ];
const [value, setValue] = useState('apple');
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<RadioList <RadioList
disabled={true} defaultValue="apple"
label="Choose a fruit (all fruits disabled)"
value={value}
onChange={(newValue: string) => setValue(newValue)}>
{items.map(({ label: itemLabel, value: itemValue }) => (
<RadioList.Item key={itemLabel} label={itemLabel} value={itemValue} />
))}
</RadioList>
<HorizontalDivider />
<RadioList
defaultValue={value}
label="Choose a fruit (some fruits disabled)" label="Choose a fruit (some fruits disabled)"
name="fruit-5"> name="fruit-5">
{items.map( {items.map(

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
export default {
argTypes: {
disabled: {
control: 'boolean',
},
isLabelHidden: {
control: 'boolean',
},
label: {
control: 'text',
},
noResultsMessage: {
control: 'text',
},
},
component: Typeahead,
parameters: {
docs: {
iframeHeight: 400,
inlineStories: false,
},
},
title: 'Typeahead',
} as ComponentMeta<typeof Typeahead>;
export function Basic({
disabled,
isLabelHidden,
label,
}: Pick<
React.ComponentProps<typeof Typeahead>,
'disabled' | 'isLabelHidden' | 'label'
>) {
const people = [
{ id: '1', label: 'Wade Cooper', value: '1' },
{ id: '2', label: 'Arlene Mccoy', value: '2' },
{ id: '3', label: 'Devon Webb', value: '3' },
{ id: '4', label: 'Tom Cook', value: '4' },
{ id: '5', label: 'Tanya Fox', value: '5' },
{ id: '6', label: 'Hellen Schmidt', value: '6' },
];
const [selectedEntry, setSelectedEntry] = useState<TypeaheadOption>(
people[0],
);
const [query, setQuery] = useState('');
const filteredPeople =
query === ''
? people
: people.filter((person) =>
person.label
.toLowerCase()
.replace(/\s+/g, '')
.includes(query.toLowerCase().replace(/\s+/g, '')),
);
return (
<Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label={label}
options={filteredPeople}
value={selectedEntry}
onQueryChange={setQuery}
onSelect={setSelectedEntry}
/>
);
}
Basic.args = {
disabled: false,
isLabelHidden: false,
label: 'Author',
};

@ -0,0 +1,90 @@
import clsx from 'clsx';
import type { ChangeEvent } from 'react';
import type { ForwardedRef } from 'react';
import { forwardRef, useId } from 'react';
type Props = Readonly<{
defaultValue?: boolean;
description?: string;
disabled?: boolean;
label: string;
name?: string;
onChange?: (
value: boolean,
event: ChangeEvent<HTMLInputElement>,
) => undefined | void;
value?: boolean;
}>;
function CheckboxInput(
{
defaultValue,
description,
disabled = false,
label,
name,
value,
onChange,
}: Props,
ref: ForwardedRef<HTMLInputElement>,
) {
const id = useId();
const descriptionId = useId();
return (
<div
className={clsx(
'relative flex',
// Vertically center only when there's no description.
description == null && 'items-center',
)}>
<div className="flex h-5 items-center">
<input
ref={ref}
aria-describedby={description != null ? descriptionId : undefined}
checked={value}
className={clsx(
'h-4 w-4 rounded border-slate-300',
disabled
? 'bg-slate-100 text-slate-400'
: 'text-primary-600 focus:ring-primary-500',
)}
defaultChecked={defaultValue}
disabled={disabled}
id={id}
name={name}
type="checkbox"
onChange={
onChange != null
? (event) => {
onChange?.(event.target.checked, event);
}
: undefined
}
/>
</div>
<div className="ml-3 text-sm">
<label
className={clsx(
'block font-medium',
disabled ? 'text-slate-400' : 'text-slate-700',
)}
htmlFor={id}>
{label}
</label>
{description && (
<p
className={clsx(
'text-xs',
disabled ? 'text-slate-400' : 'text-slate-500',
)}
id={descriptionId}>
{description}
</p>
)}
</div>
</div>
);
}
export default forwardRef(CheckboxInput);

@ -0,0 +1,46 @@
import clsx from 'clsx';
import { useId } from 'react';
import type CheckboxInput from '../CheckboxInput/CheckboxInput';
export type CheckboxListOrientation = 'horizontal' | 'vertical';
type Props = Readonly<{
children: ReadonlyArray<React.ReactElement<typeof CheckboxInput>>;
description?: string;
isLabelHidden?: boolean;
label: string;
orientation?: CheckboxListOrientation;
}>;
export default function CheckboxList({
children,
description,
isLabelHidden,
label,
orientation = 'vertical',
}: Props) {
const labelId = useId();
return (
<div>
<div className={clsx(isLabelHidden ? 'sr-only' : 'mb-2')}>
<label className="text-sm font-medium text-gray-900" id={labelId}>
{label}
</label>
{description && (
<p className="text-xs leading-5 text-gray-500">{description}</p>
)}
</div>
<div
aria-labelledby={labelId}
className={clsx(
'space-y-2',
orientation === 'horizontal' &&
'sm:flex sm:items-center sm:space-y-0 sm:space-x-10',
)}
role="group">
{children}
</div>
</div>
);
}

@ -4,7 +4,7 @@ import { Dialog as HeadlessDialog, Transition } from '@headlessui/react';
type Props = Readonly<{ type Props = Readonly<{
children: React.ReactNode; children: React.ReactNode;
isShown?: boolean; isShown: boolean;
onClose: () => void; onClose: () => void;
primaryButton: React.ReactNode; primaryButton: React.ReactNode;
secondaryButton?: React.ReactNode; secondaryButton?: React.ReactNode;

@ -8,7 +8,7 @@ export default function HorizontalDivider({ className }: Props) {
return ( return (
<hr <hr
aria-hidden={true} aria-hidden={true}
className={clsx('my-2 h-0 border-t border-slate-200', className)} className={clsx('my-2 h-0 border-t border-slate-100', className)}
/> />
); );
} }

@ -11,7 +11,6 @@ type Props<T> = Readonly<{
children: ReadonlyArray<React.ReactElement<typeof RadioListItem>>; children: ReadonlyArray<React.ReactElement<typeof RadioListItem>>;
defaultValue?: T; defaultValue?: T;
description?: string; description?: string;
disabled?: boolean;
isLabelHidden?: boolean; isLabelHidden?: boolean;
label: string; label: string;
name?: string; name?: string;
@ -27,10 +26,9 @@ export default function RadioList<T>({
children, children,
defaultValue, defaultValue,
description, description,
disabled,
orientation = 'vertical',
isLabelHidden, isLabelHidden,
name, name,
orientation = 'vertical',
label, label,
required, required,
value, value,
@ -41,7 +39,7 @@ export default function RadioList<T>({
<RadioListContext.Provider <RadioListContext.Provider
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: Figure out how to type the onChange. // @ts-ignore TODO: Figure out how to type the onChange.
value={{ defaultValue, disabled, name, onChange, value }}> value={{ defaultValue, name, onChange, value }}>
<div> <div>
<div className={clsx(isLabelHidden ? 'sr-only' : 'mb-2')}> <div className={clsx(isLabelHidden ? 'sr-only' : 'mb-2')}>
<label className="text-sm font-medium text-gray-900" id={labelId}> <label className="text-sm font-medium text-gray-900" id={labelId}>

@ -3,7 +3,6 @@ import { createContext, useContext } from 'react';
type RadioListContextValue<T = unknown> = { type RadioListContextValue<T = unknown> = {
defaultValue?: T; defaultValue?: T;
disabled?: boolean;
name?: string; name?: string;
onChange?: ( onChange?: (
value: T, value: T,

@ -12,14 +12,13 @@ type Props<T> = Readonly<{
export default function RadioListItem<T>({ export default function RadioListItem<T>({
description, description,
disabled: disabledProp = false, disabled = false,
label, label,
value, value,
}: Props<T>) { }: Props<T>) {
const id = useId(); const id = useId();
const descriptionId = useId(); const descriptionId = useId();
const context = useRadioListContext(); const context = useRadioListContext();
const disabled = context?.disabled ?? disabledProp;
return ( return (
<div <div
@ -67,7 +66,10 @@ export default function RadioListItem<T>({
</label> </label>
{description && ( {description && (
<p <p
className={clsx(disabled ? 'text-slate-400' : 'text-slate-500')} className={clsx(
'text-xs',
disabled ? 'text-slate-400' : 'text-slate-500',
)}
id={descriptionId}> id={descriptionId}>
{description} {description}
</p> </p>

@ -0,0 +1,131 @@
import clsx from 'clsx';
import { Fragment, useState } from 'react';
import { Combobox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon } from '@heroicons/react/20/solid';
export type TypeaheadOption = Readonly<{
// String value to uniquely identify the option.
id: string;
label: string;
value: string;
}>;
type Props = Readonly<{
disabled?: boolean;
isLabelHidden?: boolean;
label: string;
noResultsMessage?: string;
nullable?: boolean;
onQueryChange: (
value: string,
event: React.ChangeEvent<HTMLInputElement>,
) => void;
onSelect: (option: TypeaheadOption) => void;
options: ReadonlyArray<TypeaheadOption>;
value?: TypeaheadOption;
}>;
export default function Typeahead({
disabled = false,
isLabelHidden,
label,
noResultsMessage = 'No results',
nullable = false,
options,
onQueryChange,
value,
onSelect,
}: Props) {
const [query, setQuery] = useState('');
return (
<Combobox
by="id"
disabled={disabled}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
multiple={false}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
nullable={nullable}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
value={value}
onChange={(newValue) => {
if (newValue == null) {
return;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onSelect(newValue as TypeaheadOption);
}}>
<Combobox.Label
className={clsx(
isLabelHidden
? 'sr-only'
: 'mb-1 block text-sm font-medium text-slate-700',
)}>
{label}
</Combobox.Label>
<div className="relative">
<div className="focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm">
<Combobox.Input
className={clsx(
'w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-slate-900 focus:ring-0',
disabled && 'pointer-events-none select-none bg-slate-100',
)}
displayValue={(option) =>
(option as unknown as TypeaheadOption)?.label
}
onChange={(event) => {
setQuery(event.target.value);
onQueryChange(event.target.value, event);
}}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</Combobox.Button>
</div>
<Transition
afterLeave={() => setQuery('')}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Combobox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{options.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage}
</div>
) : (
options.map((option) => (
<Combobox.Option
key={option.id}
className={({ active }) =>
clsx(
'relative cursor-default select-none py-2 px-4 text-slate-500',
active && 'bg-slate-100',
)
}
value={option}>
{({ selected }) => (
<span
className={clsx(
'block truncate',
selected ? 'font-medium' : 'font-normal',
)}>
{option.label}
</span>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
);
}

@ -7,6 +7,12 @@ export { default as Badge } from './Badge/Badge';
// Button // Button
export * from './Button/Button'; export * from './Button/Button';
export { default as Button } from './Button/Button'; export { default as Button } from './Button/Button';
// CheckboxInput
export * from './CheckboxInput/CheckboxInput';
export { default as CheckboxInput } from './CheckboxInput/CheckboxInput';
// CheckboxList
export * from './CheckboxList/CheckboxList';
export { default as CheckboxList } from './CheckboxList/CheckboxList';
// Collapsible // Collapsible
export * from './Collapsible/Collapsible'; export * from './Collapsible/Collapsible';
export { default as Collapsible } from './Collapsible/Collapsible'; export { default as Collapsible } from './Collapsible/Collapsible';
@ -43,3 +49,6 @@ export { default as TextArea } from './TextArea/TextArea';
// TextInput // TextInput
export * from './TextInput/TextInput'; export * from './TextInput/TextInput';
export { default as TextInput } from './TextInput/TextInput'; export { default as TextInput } from './TextInput/TextInput';
// Typeahead
export * from './Typeahead/Typeahead';
export { default as Typeahead } from './Typeahead/Typeahead';

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save